Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/moe/chenxy/huaweipods/config/PodImageProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ->
Expand All @@ -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)
Expand Down Expand Up @@ -111,4 +151,8 @@ class PodImageProvider : ContentProvider() {
context.packageManager.getPackagesForUid(android.os.Binder.getCallingUid())
?.forEach(::add)
}

companion object {
private val presentationImageLock = Any()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String>): 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))
}
}
248 changes: 248 additions & 0 deletions app/src/main/java/moe/chenxy/huaweipods/hook/ColorOsDeviceSpaceHook.kt
Original file line number Diff line number Diff line change
@@ -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<ImageView>()
private val images = WeakHashMap<ImageView, OriginalImage>()
private val cards = WeakHashMap<View, WeakReference<ImageView>>()
private val scheduled = Collections.newSetFromMap(WeakHashMap<View, Boolean>())
private val cache = object : LruCache<String, Bitmap>(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<View>()?.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<View>()
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)
}
Original file line number Diff line number Diff line change
@@ -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<K : Any> {
data class Ticket(val identity: String, val revision: Long)
private val targets = WeakHashMap<K, Ticket>()
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
Loading
Loading