Files
maidong-ktv/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt
T
zijunchiang 40d1fec719 V1.4.1: 修复 Android 5.1.1 设备一打开就闪退(API 守卫缺失)
根因(极米 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);签名证书不变,可覆盖安装。
2026-09-26 13:59:51 +08:00

115 lines
4.8 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }
}