diff --git a/app/src/main/java/moe/chenxy/huaweipods/config/PodImagePrefs.kt b/app/src/main/java/moe/chenxy/huaweipods/config/PodImagePrefs.kt index 2014d0a..c42c570 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/config/PodImagePrefs.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/config/PodImagePrefs.kt @@ -54,6 +54,12 @@ object PodImagePrefs { private const val MAX_CLOUD_IDENTITIES = 16 /** 与 Manifest 中的 `${applicationId}.podimages` 保持一致,独立包名下不能指向原版 HuaweiPods。 */ const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.podimages" + + /** The provider resolves current manual/official/bundled images in its own app context. */ + fun deviceSpaceImageUri(address: String, route: moe.chenxy.huaweipods.pods.HuaweiDeviceRoute, revision: Int = 0): Uri = + Uri.Builder().scheme("content").authority(AUTHORITY) + .appendPath("device").appendPath(route.name).appendPath(address) + .appendQueryParameter("v", "${BuildConfig.MODULE_BUILD_ID}-$revision").build() private const val PREF_KEY_EARPHONES = "earphone_prefs_json" private const val PREF_KEY_CLOUD_IDENTITIES = "cloud_image_identities_json" private const val IMAGE_DIR = "pod_images" diff --git a/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProvider.kt b/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProvider.kt index 6f237c3..99dbba8 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProvider.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProvider.kt @@ -8,6 +8,9 @@ import android.net.Uri import android.os.ParcelFileDescriptor import android.os.Bundle import moe.chenxy.huaweipods.HuaweiPodsApp +import moe.chenxy.huaweipods.BuildConfig +import moe.chenxy.huaweipods.R +import moe.chenxy.huaweipods.utils.PodImageLoader import moe.chenxy.huaweipods.pods.HuaweiDeviceInfoRoutePolicy import moe.chenxy.huaweipods.smartaudio.OfficialImageIdentityBridge import moe.chenxy.huaweipods.smartaudio.SmartAudioImageCache @@ -23,6 +26,12 @@ class PodImageProvider : ContentProvider() { if (!PodImageProviderAccessPolicy.mayOpenImage(resolveCallingPackages(context))) { throw SecurityException("Caller is not an active HuaweiPods image scope") } + if (uri.pathSegments.firstOrNull() == "device") { + val request = PodPresentationImagePolicy.parse(uri.pathSegments) ?: return null + return presentationImage(context, request)?.let { + ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY) + } + } val fileName = uri.lastPathSegment ?: return null val prefs = context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) val allowedNames = PodImagePrefs.load(prefs).flatMap { earphone -> @@ -39,6 +48,37 @@ class PodImageProvider : ContentProvider() { return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) } + /** Use the same exact device and priority as the app; a host cannot select an arbitrary file. */ + private fun presentationImage(context: Context, request: PodPresentationImagePolicy.Request): File? { + val prefs = context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) + val record = PodImagePrefs.find(prefs, request.address) + val imageDir = PodImagePrefs.imageDir(context).canonicalFile + PodPresentationImagePolicy.candidatePaths(request, record).forEach { path -> + val image = File(imageDir, File(path).name).canonicalFile + if (image.parentFile == imageDir && image.isFile) return image + } + // Copy only the known drawable's bytes, never its APK file descriptor/offset. + return synchronized(presentationImageLock) { + val directory = File(context.cacheDir, "device_space_images").apply { mkdirs() } + val image = File(directory, "${request.route.name}.img") + if (!image.isFile || image.lastModified() < BuildConfig.BUILD_TIMESTAMP) { + val temporary = File.createTempFile("image-", ".tmp", directory) + try { + val drawable = PodImageLoader.modelFallbackResId( + request.route, PodImageResource.BOX, R.drawable.img_box, + ) + context.resources.openRawResource(drawable).use { input -> + temporary.outputStream().use { output -> input.copyTo(output) } + } + if (!temporary.renameTo(image)) return@synchronized null + } finally { + temporary.delete() + } + } + image + } + } + override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { if (method != SmartAudioImageCache.PROVIDER_METHOD_RECORD_IDENTITY) { return super.call(method, arg, extras) @@ -111,4 +151,8 @@ class PodImageProvider : ContentProvider() { context.packageManager.getPackagesForUid(android.os.Binder.getCallingUid()) ?.forEach(::add) } + + companion object { + private val presentationImageLock = Any() + } } diff --git a/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicy.kt b/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicy.kt index 5cb5161..cf95a7d 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicy.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicy.kt @@ -6,6 +6,7 @@ internal object PodImageProviderAccessPolicy { private val imageConsumerPackages = setOf( "com.android.bluetooth", "com.heytap.accessory", + "com.heytap.mydevices", ) fun mayOpenImage(callingPackage: String?): Boolean = diff --git a/app/src/main/java/moe/chenxy/huaweipods/config/PodPresentationImagePolicy.kt b/app/src/main/java/moe/chenxy/huaweipods/config/PodPresentationImagePolicy.kt new file mode 100644 index 0000000..ff7e5ca --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/config/PodPresentationImagePolicy.kt @@ -0,0 +1,29 @@ +package moe.chenxy.huaweipods.config + +import java.util.Locale +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import moe.chenxy.huaweipods.pods.isSupported +import moe.chenxy.huaweipods.pods.HuaweiDeviceInfoRoutePolicy + +/** Read-only, address-specific image requests. No paths or resource IDs supplied by a host. */ +internal object PodPresentationImagePolicy { + private val addressPattern = Regex("(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") + + data class Request(val route: HuaweiDeviceRoute, val address: String) + + fun candidatePaths(request: Request, record: EarphonePref?): List { + if (record == null || !record.address.equals(request.address, ignoreCase = true)) return emptyList() + val cloud = record.cloudBoxImagePath?.takeIf { + record.cloudModelId?.let { model -> HuaweiDeviceInfoRoutePolicy.isCompatible(request.route, model) } == true + } + return listOfNotNull(record.boxImagePath, cloud) + } + + fun parse(segments: List): Request? { + if (segments.size != 3 || segments[0] != "device") return null + val route = HuaweiDeviceRoute.entries.firstOrNull { it.name == segments[1] && it.isSupported } + ?: return null + if (!addressPattern.matches(segments[2])) return null + return Request(route, segments[2].uppercase(Locale.ROOT)) + } +} diff --git a/app/src/main/java/moe/chenxy/huaweipods/hook/ColorOsDeviceSpaceHook.kt b/app/src/main/java/moe/chenxy/huaweipods/hook/ColorOsDeviceSpaceHook.kt new file mode 100644 index 0000000..4e0a37b --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/hook/ColorOsDeviceSpaceHook.kt @@ -0,0 +1,248 @@ +package moe.chenxy.huaweipods.hook + +import android.annotation.SuppressLint +import android.app.Activity +import android.bluetooth.BluetoothManager +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import android.util.LruCache +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.WeakHashMap +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import moe.chenxy.huaweipods.config.DeviceRoutePrefs +import moe.chenxy.huaweipods.config.PodImagePrefs +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import moe.chenxy.huaweipods.pods.isSupported +import moe.chenxy.huaweipods.utils.PodImageLoader + +/** Verified against My Devices 17.25.10. Unknown host shapes retain their native rendering. */ +object ColorOsDeviceSpaceHook : HookContext() { + private const val TAG = "HuaweiPods-DeviceSpace" + private const val CARD = "com.oplus.mydevices.bluetooth.BlueToothCardView" + private const val CONVERTER = "com.oplus.mydevices.quickapp.data.QuickCardDeviceDataConverter" + private const val DATA = "com.oplus.mydevices.domain.entities.cards.QuickCardDeviceData" + private const val DEVICE = "com.oplus.mydevices.domain.ports.device.IDevice" + private const val DETAIL_ACTIVITY = "com.oplus.mydevices.bluetooth.PluginPanelHostActivity" + private val main = Handler(Looper.getMainLooper()) + private val requests = DeviceSpaceImageRequests() + private val images = WeakHashMap() + private val cards = WeakHashMap>() + private val scheduled = Collections.newSetFromMap(WeakHashMap()) + private val cache = object : LruCache(3 * 1024 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount + } + private val worker = ThreadPoolExecutor(0, 1, 10L, TimeUnit.SECONDS, ArrayBlockingQueue(8), { task -> + Thread(task, "HuaweiPods-DeviceSpaceImage").apply { + isDaemon = true + contextClassLoader = ClassLoader.getSystemClassLoader() + } + }) + @Volatile private var stopped = false + + override fun onHook() { + runCatching { hookHomeImages() } + .onSuccess { Log.i(TAG, "Device Space home image hook installed") } + .onFailure { Log.w(TAG, "Unknown home card shape; retaining stock images", it) } + runCatching { hookDetailImages() } + .onSuccess { Log.i(TAG, "Device Space detail image hook installed") } + .onFailure { Log.w(TAG, "Unknown detail card shape; retaining stock images", it) } + } + + private fun hookHomeImages() { + val dataType = findClass(DATA) + listOf("icon", "name", "originalIconUri").forEach { + check(dataType.getDeclaredField(it).type == String::class.java) + } + val deviceType = findClass(DEVICE) + val addressGetter = deviceType.getMethod("getMacAddress").apply { + check(returnType == String::class.java && parameterCount == 0) + } + val method = findMethod(CONVERTER, "h", deviceType, findClass("$CONVERTER\$c"), + findClass("$CONVERTER\$e"), findClass("$CONVERTER\$d"), Boolean::class.javaPrimitiveType!!) + check(method.returnType == dataType) + hookAfter(method) { + runCatching { + // h returns a freshly allocated presentation DTO. Never change the IDevice or database. + val address = addressGetter.invoke(args[0]) as? String ?: return@runCatching + val app = currentApplicationOrNull() ?: return@runCatching + val route = routeFor(app, address) ?: return@runCatching + val data = result ?: return@runCatching + val title = getObjectField(data, "name") as? String ?: return@runCatching + val uri = imageUri(address, route) + setObjectField(data, "icon", uri) + setObjectField(data, "originalIconUri", uri) + setObjectField(data, "name", deviceSpaceCardTitle(title, route)) + }.onFailure { Log.w(TAG, "Unable to customize Device Space card", it) } + } + } + + private fun hookDetailImages() { + val type = findClass(CARD) + check(View::class.java.isAssignableFrom(type)) + check(type.getDeclaredField("c").type == String::class.java) + check(type.getDeclaredField("g").type == ImageView::class.java) + // k initializes/replaces the native image; the setters also run during recycler rebinding. + val methods = listOf(findMethod(CARD, "k"), + findMethod(CARD, "setBluetoothAddress", String::class.java), + findMethod(CARD, "setDeviceName", String::class.java)) + check(methods.all { it.returnType == Void.TYPE }) + methods.forEach { method -> hookAfter(method) { (instance as? View)?.let(::schedule) } } + // Hot reload does not replay native view binding. An existing panel must be + // rediscovered after old images are restored, and again when a paused panel resumes. + hookAfter(Activity::class.java.getDeclaredMethod("onPostResume")) { + (instance as? Activity)?.takeIf { it.javaClass.name == DETAIL_ACTIVITY } + ?.let { activity -> main.post { refreshActivity(activity) } } + } + main.post { refreshVisibleDetails() } + } + + private fun refreshVisibleDetails() { + if (stopped) return + runCatching { + // The COUI detail sheet owns a Dialog window outside Activity.decorView. + // Inspect only this host process's windows, and match the verified native card class. + val windowType = Class.forName("android.view.WindowManagerGlobal") + val manager = windowType.getDeclaredMethod("getInstance").invoke(null) + val roots = (getObjectField(manager, "mViews") as? List<*>) + ?.filterIsInstance()?.take(16) ?: return@runCatching + val count = roots.sumOf(::refreshRoot) + Log.i(TAG, "Existing detail image recovery roots=${roots.size} cards=$count") + }.onFailure { Log.w(TAG, "Unable to recover visible Device Space panels", it) } + } + + private fun refreshActivity(activity: Activity) { + if (stopped || activity.javaClass.name != DETAIL_ACTIVITY || activity.isDestroyed || activity.isFinishing) return + refreshVisibleDetails() + } + + private fun refreshRoot(root: View): Int { + if (!root.isAttachedToWindow || root.windowVisibility != View.VISIBLE) return 0 + val pending = ArrayDeque() + pending.add(root) + var visited = 0 + var matches = 0 + while (pending.isNotEmpty() && visited++ < 512) { + val view = pending.removeFirst() + if (view.javaClass.name == CARD) { + schedule(view) + matches++ + } + if (view is ViewGroup) { + repeat(minOf(view.childCount, (512 - visited - pending.size).coerceAtLeast(0))) { index -> + pending.add(view.getChildAt(index)) + } + } + } + return matches + } + + private fun schedule(card: View) { + if (stopped) return + cards[card]?.get()?.let(requests::invalidate) + if (!scheduled.add(card)) return + val reference = WeakReference(card) + main.post { + val current = reference.get() ?: return@post + scheduled.remove(current) + if (!stopped) runCatching { updateDetailImage(current) } + .onFailure { Log.w(TAG, "Unable to update detail image", it) } + } + } + + private fun updateDetailImage(card: View) { + val address = getObjectField(card, "c") as? String + val route = address?.let { routeFor(card.context, it) } + val image = getObjectField(card, "g") as? ImageView + val previous = cards[card]?.get() + if (previous != null && (previous !== image || route == null)) restore(previous) + if (address == null || route == null || image == null) { + cards.remove(card) + return + } + cards[card] = WeakReference(image) + val identity = imageUri(address, route) + val ticket = requests.bind(image, identity) ?: return + fun apply(bitmap: Bitmap) { + val state = images.getOrPut(image) { OriginalImage(image.drawable, image.scaleType) } + image.scaleType = ImageView.ScaleType.FIT_CENTER + image.setImageBitmap(bitmap) + state.applied = image.drawable + } + cache.get(identity)?.takeUnless(Bitmap::isRecycled)?.let { apply(it); return } + val reference = WeakReference(image) + val app = card.context.applicationContext ?: card.context + runCatching { + worker.execute { + val bitmap = runCatching { PodImageLoader.loadDeviceSpaceBitmap(app, address, route) }.getOrNull() + if (stopped) return@execute + if (bitmap == null) { + Log.w(TAG, "Detail image decode unavailable; retaining native image") + return@execute + } + cache.put(identity, bitmap) + main.post { + val target = reference.get() ?: return@post + if (requests.isCurrent(target, ticket) && !bitmap.isRecycled) { + val state = images.getOrPut(target) { OriginalImage(target.drawable, target.scaleType) } + target.scaleType = ImageView.ScaleType.FIT_CENTER + target.setImageBitmap(bitmap) + state.applied = target.drawable + } + } + } + }.onFailure { Log.w(TAG, "Image queue unavailable; retaining native image") } + } + + @SuppressLint("MissingPermission") + private fun routeFor(context: Context, address: String): HuaweiDeviceRoute? = runCatching { + if (!android.bluetooth.BluetoothAdapter.checkBluetoothAddress(address)) return@runCatching null + val rawName = context.getSystemService(BluetoothManager::class.java)?.adapter + ?.getRemoteDevice(address)?.name + DeviceRoutePrefs.resolve(prefs, address, rawName).takeIf { it.isSupported } + }.getOrNull() + + private fun imageUri(address: String, route: HuaweiDeviceRoute): String { + val record = PodImagePrefs.find(prefs, address) + val revision = listOf(record?.boxImagePath, record?.cloudBoxImagePath, + record?.cloudModelId, record?.cloudSubModelId).hashCode() + return PodImagePrefs.deviceSpaceImageUri(address, route, revision).toString() + } + + private fun restore(image: ImageView) { + requests.invalidate(image) + images.remove(image)?.let { state -> + if (image.drawable === state.applied) { + image.setImageDrawable(state.drawable) + image.scaleType = state.scaleType + } + } + } + + override fun onCanClose(): Boolean = worker.activeCount == 0 && worker.queue.isEmpty() + + override fun onClose() { + stopped = true + requests.close() + worker.shutdownNow() + check(worker.awaitTermination(1_500L, TimeUnit.MILLISECONDS)) { "Image worker did not stop" } + check(runOnMainThreadBlocking { + main.removeCallbacksAndMessages(null) + images.keys.toList().forEach(::restore) + cards.clear() + scheduled.clear() + cache.evictAll() + }) { "Unable to restore detail images on main thread" } + } + + private data class OriginalImage(val drawable: Drawable?, val scaleType: ImageView.ScaleType, + var applied: Drawable? = null) +} diff --git a/app/src/main/java/moe/chenxy/huaweipods/hook/DeviceSpaceImageRequests.kt b/app/src/main/java/moe/chenxy/huaweipods/hook/DeviceSpaceImageRequests.kt new file mode 100644 index 0000000..6db5c0d --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/hook/DeviceSpaceImageRequests.kt @@ -0,0 +1,29 @@ +package moe.chenxy.huaweipods.hook + +import java.util.WeakHashMap +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import moe.chenxy.huaweipods.pods.displayName +import moe.chenxy.huaweipods.pods.isSupported + +/** A recycled target, a new device, and a closed module all invalidate old image callbacks. */ +internal class DeviceSpaceImageRequests { + data class Ticket(val identity: String, val revision: Long) + private val targets = WeakHashMap() + private var revision = 0L + private var closed = false + + @Synchronized fun bind(target: K, identity: String): Ticket? { + if (closed) return null + return Ticket(identity, ++revision).also { targets[target] = it } + } + + @Synchronized fun invalidate(target: K) { targets.remove(target) } + @Synchronized fun isCurrent(target: K, ticket: Ticket): Boolean = !closed && targets[target] == ticket + @Synchronized fun close() { closed = true; targets.clear() } +} + +/** Drop a redundant brand only for a canonical model title; never replace the user's alias. */ +internal fun deviceSpaceCardTitle(title: String, route: HuaweiDeviceRoute): String = + if (route.isSupported && title.trim().equals(route.displayName, ignoreCase = true)) { + title.trim().replaceFirst(Regex("^HUAWEI\\s+", RegexOption.IGNORE_CASE), "") + } else title diff --git a/app/src/main/java/moe/chenxy/huaweipods/hook/HookEntry.kt b/app/src/main/java/moe/chenxy/huaweipods/hook/HookEntry.kt index b2d56c2..6da1f43 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/hook/HookEntry.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/hook/HookEntry.kt @@ -55,8 +55,10 @@ open class HookEntry : XposedModule() { // ColorOS 没有独立的蓝牙 UI 进程,连接弹窗、常驻通知与流体云都由系统蓝牙进程发起。 loadHook("bluetooth-notification", MiBluetoothToastHook, classLoader, packageName) } - "com.heytap.mydevices" -> + "com.heytap.mydevices" -> { loadHook("coloros-mydevices", ColorOsMyDevicesHook, classLoader, packageName) + loadHook("coloros-device-space-images", ColorOsDeviceSpaceHook, classLoader, packageName) + } "com.heytap.accessory" -> loadHook("coloros-accessory", ColorOsAccessoryFrameworkHook, classLoader, packageName) "com.oplus.wirelesssettings" -> diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt index 7e6a50a..7d9d6e8 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt @@ -124,6 +124,7 @@ private const val DEVICE_CONNECT_TIMEOUT_MS = 15_000L private const val GITHUB_REPOSITORY_URL = "https://github.com/ByteJuno/HuaweiPods-ColorOS" // 开发者卡片展示本适配版维护者;“原项目”一栏保留 HuaweiPods 原作者署名与入口。 private const val GITHUB_DEVELOPER_URL = "https://github.com/ByteJuno" +private const val TELEGRAM_CHANNEL_URL = "https://t.me/ByteJuno" private const val UPSTREAM_REPOSITORY_URL = "https://github.com/Nshpiter/HuaweiPods" private const val GITHUB_RELEASES_URL = "$GITHUB_REPOSITORY_URL/releases" private const val GITHUB_ISSUES_URL = "$GITHUB_REPOSITORY_URL/issues" @@ -998,6 +999,7 @@ internal fun MainUI( }, onOpenDeveloper = { openExternalUrl(context, GITHUB_DEVELOPER_URL) }, onOpenGitHub = { openExternalUrl(context, GITHUB_REPOSITORY_URL) }, + onOpenChannel = { openExternalUrl(context, TELEGRAM_CHANNEL_URL) }, onOpenChangelog = { openExternalUrl(context, GITHUB_RELEASES_URL) }, onOpenIssues = { openExternalUrl(context, GITHUB_ISSUES_URL) }, onOpenUpstream = { openExternalUrl(context, UPSTREAM_REPOSITORY_URL) }, diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/pages/AboutPage.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/pages/AboutPage.kt index 27ec5e7..85c720c 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/ui/pages/AboutPage.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/ui/pages/AboutPage.kt @@ -86,6 +86,7 @@ internal data class AboutPageActions( val onPreviewUpdateDialog: (() -> Unit)?, val onOpenDeveloper: () -> Unit, val onOpenGitHub: () -> Unit, + val onOpenChannel: () -> Unit, val onOpenChangelog: () -> Unit, val onOpenIssues: () -> Unit, val onOpenUpstream: () -> Unit, @@ -109,6 +110,7 @@ fun AboutPage( onCheckForUpdates: () -> Unit, onPreviewUpdateDialog: (() -> Unit)? = null, onOpenGitHub: () -> Unit, + onOpenChannel: () -> Unit, onOpenChangelog: () -> Unit, onOpenIssues: () -> Unit, onOpenUpstream: () -> Unit, @@ -197,6 +199,13 @@ fun AboutPage( endLabel = "GitHub", onClick = onOpenGitHub, ) + AboutLinkRow( + title = stringResource(R.string.about_channel_title), + summary = stringResource(R.string.about_channel_summary), + icon = MiuixIcons.Messages, + trailingIcon = MiuixIcons.Link, + onClick = onOpenChannel, + ) } } } @@ -344,6 +353,7 @@ internal fun AboutTabPage( onPreviewUpdateDialog = actions.onPreviewUpdateDialog, onOpenDeveloper = actions.onOpenDeveloper, onOpenGitHub = actions.onOpenGitHub, + onOpenChannel = actions.onOpenChannel, onOpenChangelog = actions.onOpenChangelog, onOpenIssues = actions.onOpenIssues, onOpenUpstream = actions.onOpenUpstream, diff --git a/app/src/main/java/moe/chenxy/huaweipods/utils/PodImageLoader.kt b/app/src/main/java/moe/chenxy/huaweipods/utils/PodImageLoader.kt index 467c8fc..d2d0a10 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/utils/PodImageLoader.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/utils/PodImageLoader.kt @@ -22,6 +22,10 @@ import moe.chenxy.huaweipods.pods.isSupported object PodImageLoader { private const val MAX_CUSTOM_IMAGE_DIMENSION = 768 + /** System hosts read fallback assets through the app, independent of host resource caches. */ + fun loadDeviceSpaceBitmap(context: Context, address: String, route: HuaweiDeviceRoute): Bitmap? = + decodeUri(context, PodImagePrefs.deviceSpaceImageUri(address, route)) + fun loadBitmap( context: Context, prefs: SharedPreferences, diff --git a/app/src/main/res/drawable-nodpi/about_maintainer_avatar.png b/app/src/main/res/drawable-nodpi/about_maintainer_avatar.png index 8805ec5..fbb2550 100644 Binary files a/app/src/main/res/drawable-nodpi/about_maintainer_avatar.png and b/app/src/main/res/drawable-nodpi/about_maintainer_avatar.png differ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 74f1745..f3f3e25 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -175,6 +175,8 @@ ByteJuno @ByteJuno · ColorOS 适配 源码 + ByteJuno · 更新频道 + 在 Telegram 关注项目更新与测试动态 项目 模块 原项目 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 962093c..4654207 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -174,6 +174,8 @@ ByteJuno @ByteJuno · ColorOS adaptation Source Code + ByteJuno · Telegram channel + Follow project updates and test builds on Telegram Project Module Upstream diff --git a/app/src/test/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicyTest.kt b/app/src/test/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicyTest.kt index e14613c..bd9b558 100644 --- a/app/src/test/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicyTest.kt +++ b/app/src/test/java/moe/chenxy/huaweipods/config/PodImageProviderAccessPolicyTest.kt @@ -11,6 +11,7 @@ class PodImageProviderAccessPolicyTest { "io.github.artifical0.huaweipods.coloros", "com.android.bluetooth", "com.heytap.accessory", + "com.heytap.mydevices", ).forEach { assertTrue(it, PodImageProviderAccessPolicy.mayOpenImage(it)) } assertFalse(PodImageProviderAccessPolicy.mayOpenImage("com.milink.service")) @@ -40,6 +41,7 @@ class PodImageProviderAccessPolicyTest { assertTrue(PodImageProviderAccessPolicy.maySubmitOfficialImageIdentity("io.github.artifical0.huaweipods.coloros")) assertTrue(PodImageProviderAccessPolicy.maySubmitOfficialImageIdentity("com.android.bluetooth")) assertFalse(PodImageProviderAccessPolicy.maySubmitOfficialImageIdentity("com.huawei.smartaudio")) + assertFalse(PodImageProviderAccessPolicy.maySubmitOfficialImageIdentity("com.heytap.mydevices")) assertFalse(PodImageProviderAccessPolicy.maySubmitOfficialImageIdentity("com.android.settings")) assertFalse(PodImageProviderAccessPolicy.maySubmitOfficialImageIdentity("com.example.thirdparty")) } diff --git a/app/src/test/java/moe/chenxy/huaweipods/config/PodPresentationImagePolicyTest.kt b/app/src/test/java/moe/chenxy/huaweipods/config/PodPresentationImagePolicyTest.kt new file mode 100644 index 0000000..1226812 --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/config/PodPresentationImagePolicyTest.kt @@ -0,0 +1,35 @@ +package moe.chenxy.huaweipods.config + +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import org.junit.Assert.* +import org.junit.Test + +class PodPresentationImagePolicyTest { + @Test fun `manual image comes first while official images require the matching generation`() { + val request = PodPresentationImagePolicy.Request(HuaweiDeviceRoute.HUAWEI_FREECLIP, "AA:BB:CC:DD:EE:01") + val image = EarphonePref(request.address, "FreeClip", boxImagePath = "manual.png", + cloudModelId = "000160", cloudBoxImagePath = "official.png") + assertEquals(listOf("manual.png", "official.png"), PodPresentationImagePolicy.candidatePaths(request, image)) + val nextGeneration = request.copy(route = HuaweiDeviceRoute.HUAWEI_FREECLIP2) + assertEquals(listOf("manual.png"), PodPresentationImagePolicy.candidatePaths(nextGeneration, image)) + assertTrue(PodPresentationImagePolicy.candidatePaths(nextGeneration, image.copy(boxImagePath = null)).isEmpty()) + assertTrue(PodPresentationImagePolicy.candidatePaths(request.copy(address = "AA:BB:CC:DD:EE:02"), image).isEmpty()) + } + + @Test fun `binds a validated image request to its exact address and generation`() { + val request = PodPresentationImagePolicy.parse(listOf("device", "HUAWEI_FREECLIP", "aa:bb:cc:dd:ee:01"))!! + assertEquals("AA:BB:CC:DD:EE:01", request.address) + assertEquals(HuaweiDeviceRoute.HUAWEI_FREECLIP, request.route) + assertEquals(HuaweiDeviceRoute.HUAWEI_FREECLIP2, + PodPresentationImagePolicy.parse(listOf("device", "HUAWEI_FREECLIP2", request.address))!!.route) + } + + @Test fun `rejects paths unknown models malformed addresses and extra segments`() { + listOf(listOf("device", "../files", "AA:BB:CC:DD:EE:01"), + listOf("device", "UNSUPPORTED", "AA:BB:CC:DD:EE:01"), + listOf("device", "HUAWEI_FREECLIP", "latest"), + listOf("device", "HUAWEI_FREECLIP", "../private"), + listOf("device", "HUAWEI_FREECLIP", "AA:BB:CC:DD:EE:01", "box")) + .forEach { assertNull(it.toString(), PodPresentationImagePolicy.parse(it)) } + } +} diff --git a/app/src/test/java/moe/chenxy/huaweipods/hook/DeviceSpaceImageRequestsTest.kt b/app/src/test/java/moe/chenxy/huaweipods/hook/DeviceSpaceImageRequestsTest.kt new file mode 100644 index 0000000..959c7f0 --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/hook/DeviceSpaceImageRequestsTest.kt @@ -0,0 +1,38 @@ +package moe.chenxy.huaweipods.hook + +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import org.junit.Assert.* +import org.junit.Test + +class DeviceSpaceImageRequestsTest { + @Test fun `a reused view rejects old device and old image completions`() { + val requests = DeviceSpaceImageRequests() + val target = Any() + val first = requests.bind(target, "freeclip-address-1")!! + val next = requests.bind(target, "freeclip2-address-2")!! + assertFalse(requests.isCurrent(target, first)) + assertTrue(requests.isCurrent(target, next)) + val replacement = requests.bind(target, next.identity)!! + assertFalse(requests.isCurrent(target, next)) + assertTrue(requests.isCurrent(target, replacement)) + } + + @Test fun `native rebinding and module close cancel pending image updates`() { + val requests = DeviceSpaceImageRequests() + val target = Any() + val first = requests.bind(target, "image")!! + requests.invalidate(target) + assertFalse(requests.isCurrent(target, first)) + val next = requests.bind(target, "image")!! + requests.close() + assertFalse(requests.isCurrent(target, next)) + assertNull(requests.bind(target, "after-close")) + } + + @Test fun `compact model titles preserve user aliases and unknown devices`() { + assertEquals("FreeClip", deviceSpaceCardTitle("HUAWEI FreeClip", HuaweiDeviceRoute.HUAWEI_FREECLIP)) + assertEquals("FreeClip 2", deviceSpaceCardTitle("HUAWEI FreeClip 2", HuaweiDeviceRoute.HUAWEI_FREECLIP2)) + assertEquals("我的耳机", deviceSpaceCardTitle("我的耳机", HuaweiDeviceRoute.HUAWEI_FREECLIP)) + assertEquals("HUAWEI FreeClip", deviceSpaceCardTitle("HUAWEI FreeClip", HuaweiDeviceRoute.UNSUPPORTED)) + } +}