根因(极米 H1S / MStar 6A928 / GMUI2.0 / Android 5.1.1):
- MainActivity 把 API 23 才有的 android.media.AudioDeviceCallback 写成了
**字段初始化器**(private val micDeviceCallback = object : ...)。
字段初始化在 Activity 构造期执行,早于 onCreate 里的 SDK_INT 判断,
于是下面的 registerMicInputWatcher() 版本守卫形同虚设 ——
构造 MainActivity 即抛 NoClassDefFoundError,表现为一打开就闪退。
- 顺带修复 36 处同类未守卫的高版本 API 调用(lint NewApi 全量清零):
* AudioDeviceCallback / AudioDeviceInfo.isSource/getType (API 23)
→ 改为可空字段 + 仅在 SDK_INT>=23 分支按需创建(@TargetApi)
* HashMap.putIfAbsent ×5 (API 24) → MutableMap.getOrPut
* Comparator.comparing ×3 + comparingInt ×1 (API 24) → Kotlin compareBy
(同时移除 java.util.function 导入)
* ConcurrentHashMap.newKeySet ×2 (API 24) → Collections.newSetFromMap
(连带 16 处 KeySetView.add/remove/contains)
* ConcurrentHashMap.computeIfAbsent (API 24) → ConcurrentHashMap.putIfAbsent(API 1)
* URLConnection.getContentLengthLong ×5 (API 24) → 新增 Compat.contentLengthCompat()
* ByteArrayOutputStream.toString(Charset) (API 33) → String(bytes, UTF_8)
防回归:
- app/build.gradle 打开 lint 闸门: checkReleaseBuilds true + abortOnError true +
fatal += NewApi(并 disable ExpiredTargetSdkVersion,避免它挤占闸门)。
已实测: 植入一个 NewApi 违规后 assembleRelease 直接 FAILED。
- AGENTS.md 新增「低版本设备闪退(API 守卫)」条目,记录根因、替代写法与自查命令。
版本号 1.4.0(16) → 1.4.1(17);签名证书不变,可覆盖安装。
115 lines
4.8 KiB
Kotlin
115 lines
4.8 KiB
Kotlin
package com.local.ktv
|
||
|
||
import android.graphics.Bitmap
|
||
import android.graphics.BitmapFactory
|
||
import android.os.Handler
|
||
import android.os.Looper
|
||
import android.util.LruCache
|
||
import android.widget.ImageView
|
||
import java.io.File
|
||
import java.io.FileOutputStream
|
||
import java.lang.ref.WeakReference
|
||
import java.net.HttpURLConnection
|
||
import java.net.URL
|
||
import java.security.MessageDigest
|
||
import java.util.concurrent.ConcurrentHashMap
|
||
import java.util.concurrent.CopyOnWriteArrayList
|
||
import java.util.concurrent.Executors
|
||
|
||
/**
|
||
* Singer-image loader following the original app's Glide behavior: recycled-view protection,
|
||
* memory and disk cache, request coalescing and sampled decoding instead of full-size bitmaps.
|
||
*/
|
||
object SingerAvatarLoader {
|
||
private const val MAX_DOWNLOAD_BYTES = 8L * 1024L * 1024L
|
||
private val executor = Executors.newFixedThreadPool(4)
|
||
private val main = Handler(Looper.getMainLooper())
|
||
private val memory = object : LruCache<String, Bitmap>(32 * 1024) {
|
||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024
|
||
}
|
||
private val waiting = ConcurrentHashMap<String, CopyOnWriteArrayList<WeakReference<ImageView>>>()
|
||
|
||
fun load(view: ImageView, imageUrl: String?, placeholder: Int) {
|
||
val key = imageUrl.orEmpty()
|
||
view.tag = key
|
||
view.setImageResource(placeholder)
|
||
if (key.isEmpty()) return
|
||
memory.get(key)?.let { bitmap ->
|
||
view.setImageBitmap(bitmap)
|
||
return
|
||
}
|
||
|
||
// ConcurrentHashMap.computeIfAbsent 是 API 24+;putIfAbsent 自 API 1 起可用,语义等价。
|
||
val created = CopyOnWriteArrayList<WeakReference<ImageView>>()
|
||
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() }
|
||
executor.execute {
|
||
val bitmap = runCatching { decode(key, cacheDir) }.getOrNull()
|
||
if (bitmap != null) memory.put(key, bitmap)
|
||
val targets = waiting.remove(key).orEmpty()
|
||
main.post {
|
||
if (bitmap != null) targets.forEach { reference ->
|
||
reference.get()?.takeIf { it.tag == key }?.setImageBitmap(bitmap)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun decode(value: String, cacheDir: File): Bitmap? {
|
||
if (value.startsWith("/")) return decodeSampled(File(value))
|
||
val cacheFile = File(cacheDir, sha256(value) + ".img")
|
||
if (cacheFile.isFile && cacheFile.length() > 0) {
|
||
decodeSampled(cacheFile)?.let { return it }
|
||
cacheFile.delete()
|
||
}
|
||
val temporary = File(cacheDir, cacheFile.name + ".tmp")
|
||
val connection = URL(value).openConnection() as HttpURLConnection
|
||
try {
|
||
connection.connectTimeout = 4_000
|
||
connection.readTimeout = 8_000
|
||
connection.instanceFollowRedirects = true
|
||
connection.setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
|
||
require(connection.responseCode in 200..299)
|
||
require(connection.contentLengthCompat() <= 0 || connection.contentLengthCompat() <= MAX_DOWNLOAD_BYTES)
|
||
var total = 0L
|
||
connection.inputStream.buffered().use { input ->
|
||
FileOutputStream(temporary).buffered().use { output ->
|
||
val buffer = ByteArray(32 * 1024)
|
||
while (true) {
|
||
val count = input.read(buffer)
|
||
if (count < 0) break
|
||
total += count
|
||
require(total <= MAX_DOWNLOAD_BYTES)
|
||
output.write(buffer, 0, count)
|
||
}
|
||
}
|
||
}
|
||
if (!temporary.renameTo(cacheFile)) {
|
||
temporary.copyTo(cacheFile, overwrite = true)
|
||
temporary.delete()
|
||
}
|
||
return decodeSampled(cacheFile)
|
||
} finally {
|
||
connection.disconnect()
|
||
temporary.delete()
|
||
}
|
||
}
|
||
|
||
private fun decodeSampled(file: File): Bitmap? {
|
||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||
var sample = 1
|
||
while (bounds.outWidth / sample > 256 || bounds.outHeight / sample > 256) sample *= 2
|
||
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply {
|
||
inSampleSize = sample
|
||
inPreferredConfig = Bitmap.Config.RGB_565
|
||
})
|
||
}
|
||
|
||
private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
|
||
.digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||
}
|