diff --git a/AGENTS.md b/AGENTS.md index 7841c40..aab4721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,11 +160,34 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16` +- `app/build.gradle`:`versionName "1.4.1"` / `versionCode 17` - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) +- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): + 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 + 被阻断时按报错位置补版本判断或改用兼容写法,**不要**把 `checkReleaseBuilds` 改回 `false` + 来绕过 —— 这道闸门专为「低版本设备一打开就闪退」而设(见第三节同名条目) ## 三、关键实现约束(避免踩坑) +- **⚠️ 低版本设备闪退(API 守卫)**:`minSdk 21`,但**实际客户里有 Android 5.1.1 的机器** + (极米 H1S / MStar 6A928 / GMUI2.0)。以下三类写法会让 app 在 API 22 上**一打开就闪退**, + 必须避免: + 1. **把高版本 API 的类写在 `Activity`/`Fragment`/`object` 的字段初始化器里** —— + 字段初始化在**构造期**执行,早于 `onCreate` 里的 `SDK_INT` 判断,因此守卫形同虚设。 + v1.4.0 的闪退根因就是 `private val micDeviceCallback = object : android.media.AudioDeviceCallback() {...}` + (`AudioDeviceCallback` 是 API 23)→ 构造 `MainActivity` 时 `NoClassDefFoundError`。 + 正确写法:字段声明为可空、在 `SDK_INT >= 23` 分支里**按需创建**(见 `ensureMicDeviceCallback()`)。 + 2. **Java 8 的集合默认方法**:`Map.getOrDefault` / `Map.putIfAbsent` / `ConcurrentHashMap.newKeySet` + / `computeIfAbsent` / `Comparator.comparing` / `List.sort` 等都是 **API 24+**, + API 21~23 上抛 `NoSuchMethodError`。替代:`getOrPut`、 + `Collections.newSetFromMap(ConcurrentHashMap())`、`ConcurrentHashMap.putIfAbsent`(API 1)、 + Kotlin `compareBy`。 + 3. 其它漏网的:`URLConnection.getContentLengthLong()`(API 24,用 `Compat.kt` 的 + `contentLengthCompat()`)、`ByteArrayOutputStream.toString(Charset)`(API 33)。 + - 自查命令(必须为 0 条):`./gradlew :app:lintRelease` 后 + 在 `app/build/reports/lint-results-release.txt` 里搜 `NewApi`。 + - 根因扫描捷径:`grep -n "private val .* = object : android\." app/src/main/java/com/local/ktv/*.kt` + —— 字段初始化器里出现 `android.*` 的高版本类就是要闪退。 - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 diff --git a/app/build.gradle b/app/build.gradle index 98999bc..f41c401 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 16 - versionName "1.4.0" + versionCode 17 + versionName "1.4.1" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 @@ -52,10 +52,19 @@ android { noCompress 'ts', 'mp3', 'mp4', 'aac', 'm4a', 'flac', 'wav' } - // 关闭发布构建的 lint vital 检查(本地分发的 APK 不需要 Google Play 上架要求) + // lint 策略: + // - 「调用了高于 minSdk 且未做版本守卫的 API」(NewApi) 必须**阻断发布构建**: + // 这类问题在 Android 5.x / 6.x 上直接 NoSuchMethodError / NoClassDefFoundError 闪退, + // 而本地打包测试用的都是较新设备, 极容易漏出去(v1.4.0 在极米 H1S / Android 5.1.1 + // 一打开就闪退, 根因就是 AudioDeviceCallback(API 23) 被写进了 Activity 的字段初始化器)。 + // - ExpiredTargetSdkVersion 默认也是 fatal 级, 但本地分发的 APK 不上架 Google Play, + // 显式关掉, 否则会挤占下面这道闸门。 + // - abortOnError 必须为 true: 设为 false 会把 lintVital 的失败一并吞掉, 闸门会失效。 lint { - checkReleaseBuilds false - abortOnError false + fatal += ['NewApi'] + disable += ['ExpiredTargetSdkVersion'] + checkReleaseBuilds true + abortOnError true } // 启用 BuildConfig 生成,供 AboutFragment 读取版本号/版本Code等信息 diff --git a/app/src/main/java/com/local/ktv/AppUpdateManager.kt b/app/src/main/java/com/local/ktv/AppUpdateManager.kt index 51c269f..1e72e34 100644 --- a/app/src/main/java/com/local/ktv/AppUpdateManager.kt +++ b/app/src/main/java/com/local/ktv/AppUpdateManager.kt @@ -77,7 +77,7 @@ object AppUpdateManager { } try { require(connection.responseCode in 200..299) { "下载失败:HTTP ${connection.responseCode}" } - val total = connection.contentLengthLong + val total = connection.contentLengthCompat() connection.inputStream.use { input -> FileOutputStream(partial).use { output -> val buffer = ByteArray(128 * 1024) diff --git a/app/src/main/java/com/local/ktv/Compat.kt b/app/src/main/java/com/local/ktv/Compat.kt new file mode 100644 index 0000000..3c21fb2 --- /dev/null +++ b/app/src/main/java/com/local/ktv/Compat.kt @@ -0,0 +1,26 @@ +package com.local.ktv + +import android.os.Build +import java.net.URLConnection + +/** + * 低版本兼容工具。 + * + * 背景:`minSdk 21`,但代码里用过若干 API 22 不存在的方法,在 Android 5.x + * (如极米 H1S / GMUI2.0 / Android 5.1.1)上会在运行期抛 `NoSuchMethodError`。 + * 这里把这类调用收敛成带版本判断的兼容函数。 + */ + +/** + * 读取 HTTP 响应体长度。 + * + * `URLConnection.getContentLengthLong()` 是 **API 24(N)** 才加入的;API 21~23 上直接调用 + * 会抛 `NoSuchMethodError`。低版本回退到 `getContentLength()`(int,2GB 以内等价)。 + */ +fun URLConnection.contentLengthCompat(): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + contentLengthLong + } else { + @Suppress("DEPRECATION") + contentLength.toLong() + } diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 8fdca7e..6e0a218 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -271,7 +271,7 @@ class KtvJsBridge(context: Context) { if (connection.responseCode != HttpURLConnection.HTTP_OK) { throw IllegalStateException("HTTP ${connection.responseCode}") } - val declaredLength = connection.contentLengthLong + val declaredLength = connection.contentLengthCompat() require(declaredLength < 0 || declaredLength <= MAX_RESPONSE_BYTES) { "JS file is too large" } val bytes = connection.inputStream.use { it.readBytes() } require(bytes.size <= MAX_RESPONSE_BYTES) { "JS file is too large" } @@ -508,7 +508,8 @@ class KtvJsBridge(context: Context) { out.write(readExact(input, size.toLong())) readAsciiLine(input) // 吃掉 chunk 尾部 CRLF } - return out.toString(Charsets.UTF_8) + // ByteArrayOutputStream.toString(Charset) 是 API 33+;低版本用 String(bytes, charset)。 + return String(out.toByteArray(), Charsets.UTF_8) } } diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index a6fabfd..d48952e 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -1,6 +1,7 @@ package com.local.ktv import android.Manifest +import android.annotation.TargetApi import android.app.AlertDialog import android.content.DialogInterface import android.content.DialogInterface.OnShowListener @@ -106,8 +107,6 @@ import java.util.LinkedHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import java.util.function.Function -import java.util.function.ToIntFunction import kotlin.Any import kotlin.Array import kotlin.Boolean @@ -534,7 +533,7 @@ class MainActivity : AppCompatActivity() { private fun registerMicInputWatcher() { val manager = audioManager ?: return if (Build.VERSION.SDK_INT >= 23) { - manager.registerAudioDeviceCallback(micDeviceCallback, null) + manager.registerAudioDeviceCallback(ensureMicDeviceCallback(), null) } else { runCatching { val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG) @@ -546,20 +545,37 @@ class MainActivity : AppCompatActivity() { private fun unregisterMicInputWatcher() { val manager = audioManager ?: return if (Build.VERSION.SDK_INT >= 23) { - manager.unregisterAudioDeviceCallback(micDeviceCallback) + micDeviceCallback?.let { manager.unregisterAudioDeviceCallback(it) } + micDeviceCallback = null } else { runCatching { unregisterReceiver(micHotplugReceiver) } } } - private val micDeviceCallback = object : android.media.AudioDeviceCallback() { - override fun onAudioDevicesAdded(addedDevices: Array) { - handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) }) - } + /** + * 输入设备变化回调(`android.media.AudioDeviceCallback`, API 23+)。 + * + * ⚠️ **必须按需创建,不能写成字段初始化器**:字段初始化在 Activity 构造时执行, + * 早于 `onCreate`,而 `Build.VERSION.SDK_INT` 判断发生在 `onCreate` 之后。 + * Android 5.x(API 22) 没有 `AudioDeviceCallback` 这个类 —— 对象在构造期被实例化会直接 + * `NoClassDefFoundError` 闪退(极米 H1S / GMUI2.0 / Android 5.1.1 的闪退根因)。 + */ + private var micDeviceCallback: android.media.AudioDeviceCallback? = null - override fun onAudioDevicesRemoved(removedDevices: Array) { - handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) }) + @TargetApi(23) + private fun ensureMicDeviceCallback(): android.media.AudioDeviceCallback { + micDeviceCallback?.let { return it } + val created = object : android.media.AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) }) + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) }) + } } + micDeviceCallback = created + return created } private val micHotplugReceiver = object : BroadcastReceiver() { @@ -568,7 +584,8 @@ class MainActivity : AppCompatActivity() { } } - /** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。 */ + /** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。仅由 API 23+ 的输入设备回调调用。 */ + @TargetApi(23) private fun isMicInputDevice(device: android.media.AudioDeviceInfo): Boolean { if (!device.isSource) return false return when (device.type) { @@ -4635,10 +4652,7 @@ class MainActivity : AppCompatActivity() { song ) } - Collections.sort( - visibleSongs, - Comparator.comparing(Function { song: Song? -> song!!.title!!.lowercase() }) - ) + Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() }) renderSongs() } @@ -4652,10 +4666,7 @@ class MainActivity : AppCompatActivity() { )) ) visibleSongs.add(song) } - Collections.sort( - visibleSongs, - Comparator.comparing(Function { song: Song? -> song!!.title!!.lowercase() }) - ) + Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() }) renderSongs() } @@ -4710,10 +4721,7 @@ class MainActivity : AppCompatActivity() { song ) } - Collections.sort( - visibleSongs, - Comparator.comparing(Function { song: Song? -> song!!.title!!.lowercase() }) - ) + Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() }) renderSongs() if (pageInfo != null) pageInfo!!.setText("本地 " + visibleSongs.size + " 首") } @@ -5563,7 +5571,7 @@ class MainActivity : AppCompatActivity() { SongFileValidator.requiresTransportStream(file), ).valid ) { - localSongs.putIfAbsent(stableId(song), song) + localSongs.getOrPut(stableId(song)) { song } } } val allLocalSongs = localSongs.values.toList() @@ -5971,10 +5979,7 @@ class MainActivity : AppCompatActivity() { BufferedReader(InputStreamReader(FileInputStream(file), "UTF-8")).use { reader -> var line: String? while ((reader.readLine().also { line = it }) != null) parseLyricLine(line) - Collections.sort( - lyricLines, Comparator.comparingInt( - ToIntFunction { item: LyricLine? -> item!!.timeMs }) - ) + Collections.sort(lyricLines, compareBy { it!!.timeMs }) updateLyricView() } } catch (e: Exception) { @@ -8415,7 +8420,7 @@ class MainActivity : AppCompatActivity() { .plus(library.allSongs().asSequence()) .forEach { song -> if (song.hasLocalFile() || isDownloaded(song)) { - localSongs.putIfAbsent(stableId(song), song) + localSongs.getOrPut(stableId(song)) { song } } } val filtered = localSongs.values.filter { @@ -8686,7 +8691,7 @@ class MainActivity : AppCompatActivity() { ).valid ) { song.path = resolved.absolutePath - result.putIfAbsent(stableId(song), song) + result.getOrPut(stableId(song)) { song } } } @@ -8711,13 +8716,13 @@ class MainActivity : AppCompatActivity() { val databaseSongs = library.muse.songsByFilenames(filesByName.keys) databaseSongs.forEach { song -> filesByName[song.filename]?.let { song.path = it.absolutePath } - result.putIfAbsent(stableId(song), song) + result.getOrPut(stableId(song)) { song } } val matchedNames = databaseSongs.mapNotNullTo(HashSet()) { it.filename } validFiles.filter { it.name !in matchedNames }.forEach { file -> val song = Song.local(file.absolutePath, file.name) song.filename = file.name - result.putIfAbsent(stableId(song), song) + result.getOrPut(stableId(song)) { song } } return result } diff --git a/app/src/main/java/com/local/ktv/MuseDbSync.kt b/app/src/main/java/com/local/ktv/MuseDbSync.kt index 609b4e7..9d0b4cb 100644 --- a/app/src/main/java/com/local/ktv/MuseDbSync.kt +++ b/app/src/main/java/com/local/ktv/MuseDbSync.kt @@ -194,7 +194,7 @@ object MuseDbSync { } val code = conn.responseCode check(code in 200..299) { "HTTP $code" } - val total = conn.contentLengthLong + val total = conn.contentLengthCompat() conn.inputStream.buffered(256 * 1024).use { input -> dest.outputStream().buffered(256 * 1024).use { out -> val buffer = ByteArray(256 * 1024) diff --git a/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt b/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt index f0adb39..6f7d793 100644 --- a/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt +++ b/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt @@ -39,7 +39,9 @@ object SingerAvatarLoader { return } - val waiters = waiting.computeIfAbsent(key) { CopyOnWriteArrayList() } + // ConcurrentHashMap.computeIfAbsent 是 API 24+;putIfAbsent 自 API 1 起可用,语义等价。 + val created = CopyOnWriteArrayList>() + val waiters = waiting.putIfAbsent(key, created) ?: created waiters += WeakReference(view) if (waiters.size > 1) return val cacheDir = File(view.context.applicationContext.cacheDir, "singer_avatars").apply { mkdirs() } @@ -70,7 +72,7 @@ object SingerAvatarLoader { connection.instanceFollowRedirects = true connection.setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}") require(connection.responseCode in 200..299) - require(connection.contentLengthLong <= 0 || connection.contentLengthLong <= MAX_DOWNLOAD_BYTES) + require(connection.contentLengthCompat() <= 0 || connection.contentLengthCompat() <= MAX_DOWNLOAD_BYTES) var total = 0L connection.inputStream.buffered().use { input -> FileOutputStream(temporary).buffered().use { output -> diff --git a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt index f93f9c2..6fee6c9 100644 --- a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt +++ b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt @@ -32,12 +32,16 @@ object SongOkDownloadManager { } private val tasks = ConcurrentHashMap() - private val pending = ConcurrentHashMap.newKeySet() + // ConcurrentHashMap.newKeySet() 是 API 24+(低版本 NoSuchMethodError); + // Collections.newSetFromMap 自 API 9 起可用,且返回普通 Set,add/remove/contains 均成立于 API 1。 + private val pending: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) private val callbacks = ConcurrentHashMap() private val progress = ConcurrentHashMap() private val totalLengths = ConcurrentHashMap() private val forcedRetryReasons = ConcurrentHashMap() - private val manuallyCanceled = ConcurrentHashMap.newKeySet() + private val manuallyCanceled: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) private val io = Executors.newFixedThreadPool(4) private val watchdog = Executors.newSingleThreadScheduledExecutor()