From 2d0c6b41027d32d5f844f7e598d7e96f1682ff4e Mon Sep 17 00:00:00 2001 From: Absinthe Date: Tue, 20 Oct 2026 11:09:28 +0800 Subject: [PATCH 1/2] Detect Compose Multiplatform Resources instead of KMP --- .../extensions/ComposeResourcesApkSetTest.kt | 222 ++++++++++++++++++ .../utils/extensions/PackageFeaturesTest.kt | 31 ++- .../libchecker/database/entity/Features.kt | 2 +- .../BuildAppDetailFeatureItemUseCase.kt | 8 +- .../app/detail/feature/FeatureDialogSpec.kt | 6 +- .../feature/GetAppDetailFeaturesUseCase.kt | 24 +- .../utils/extensions/PackageInfoExtensions.kt | 86 +++++-- .../utils/manifest/ManifestReader.java | 35 +++ app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/untranslatable.xml | 2 +- .../BuildAppDetailFeatureItemUseCaseTest.kt | 29 +++ .../utils/manifest/ManifestReadersTest.kt | 27 +++ 13 files changed, 418 insertions(+), 56 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/ComposeResourcesApkSetTest.kt diff --git a/app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/ComposeResourcesApkSetTest.kt b/app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/ComposeResourcesApkSetTest.kt new file mode 100644 index 0000000000..563851cf9a --- /dev/null +++ b/app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/ComposeResourcesApkSetTest.kt @@ -0,0 +1,222 @@ +package com.absinthe.libchecker.utils.extensions + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.ParcelFileDescriptor +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import com.absinthe.libchecker.compat.ZipFileCompat +import com.absinthe.libchecker.domain.app.packageinfo.GetArchivePackageInfoUseCase +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import pxb.android.Res_value +import pxb.android.axml.AxmlWriter + +@SdkSuppress(minSdkVersion = 31) +class ComposeResourcesApkSetTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val directory = File("/data/local/tmp/lc-cmp-${UUID.randomUUID()}") + private val packageName = "com.example.cmp" + + @Before + fun setUp() { + shell("mkdir -m 777 ${directory.path}") + } + + @After + fun tearDown() { + shell("rm -rf ${directory.path}") + } + + @Test + fun apksEvidenceCannotCrossPackageBoundaries() { + assertArchivePackageIsolation(xapk = false) + } + + @Test + fun xapkEvidenceCannotCrossPackageBoundaries() { + assertArchivePackageIsolation(xapk = true) + } + + @Test + fun childAttributesCannotOverrideSplitPackageIdentity() { + val split = apk("com.example.unrelated", "config.resources", payload = true, applicationPackage = packageName) + assertFalse(archive(false, split).usesComposeMultiplatformResources()) + } + + private fun assertArchivePackageIsolation(xapk: Boolean) { + val own = archive(xapk, apk(packageName, "config.resources", payload = true)) + assertTrue("Valid same-package archive", own.usesComposeMultiplatformResources()) + val mixed = archive(xapk, apk("com.example.unrelated", "config.resources", payload = true)) + assertFalse("Mixed-package archive", mixed.usesComposeMultiplatformResources()) + } + + @Test + fun archiveRequiresMatchingVersionAndUniqueSplitIdentity() { + for (split in listOf( + apk(packageName, "config.resources", version = 2, payload = true), + apk(packageName, "config.resources", majorVersion = 1, payload = true), + apk(packageName, payload = true) + )) { + assertFalse(archive(false, split).usesComposeMultiplatformResources()) + } + val base = write("base.apk", apk(packageName, provider = true)) + val split = apk(packageName, "config.resources", payload = true) + val info = packageInfo(base).apply { + applicationInfo!!.splitSourceDirs = arrayOf( + write("split_one.apk", split).path, + write("split_two.apk", split).path + ) + } + assertFalse(info.usesComposeMultiplatformResources()) + } + + @Test + fun frozenPackageRecoversResourceSplitWhenMetadataIsMissing() { + val base = write("base.apk", apk(packageName, provider = true)) + write("split_config.resources.apk", apk(packageName, "config.resources", payload = true)) + val info = packageInfo(base).apply { + applicationInfo!!.splitSourceDirs = null + } + assertFalse("Active packages use their declared split set", info.usesComposeMultiplatformResources()) + info.applicationInfo!!.enabled = false + assertTrue(info.usesComposeMultiplatformResources()) + ZipFileCompat(base).use { zip -> + assertTrue(info.usesComposeMultiplatformResources(zip)) + assertNotNull("Caller-owned ZIP must remain open", zip.getEntry("AndroidManifest.xml")) + } + info.applicationInfo!!.splitSourceDirs = arrayOf(File(directory, "missing.apk").path) + assertFalse("Recovery cannot hide a missing declared split", info.usesComposeMultiplatformResources()) + info.applicationInfo!!.splitSourceDirs = null + write("split_config.resources.apk", apk("com.example.unrelated", "config.resources", payload = true)) + assertFalse("Recovered splits must also belong to the package", info.usesComposeMultiplatformResources()) + } + + @Test + fun incompleteOrUnreadableSetCannotProducePositiveResult() { + val base = write("base.apk", apk(packageName, provider = true, payload = true)) + val info = packageInfo(base).apply { + applicationInfo!!.splitSourceDirs = arrayOf(File(directory, "missing.apk").path) + } + assertFalse(info.usesComposeMultiplatformResources()) + info.applicationInfo!!.splitSourceDirs = arrayOf(write("broken.apk", byteArrayOf(1, 2, 3)).path) + assertFalse(info.usesComposeMultiplatformResources()) + info.applicationInfo!!.splitSourceDirs = arrayOf(write("no-manifest.apk", zip(mapOf("asset" to byteArrayOf(1)))).path) + assertFalse(info.usesComposeMultiplatformResources()) + } + + private fun archive(xapk: Boolean, split: ByteArray): PackageInfo { + val baseName = if (xapk) "$packageName.apk" else "base.apk" + val splitName = if (xapk) "config.resources.apk" else "split_config.resources.apk" + val entries = linkedMapOf( + baseName to apk(packageName, provider = true), + splitName to split + ) + if (xapk) { + entries["manifest.json"] = """ + { + "xapk_version": 2, "package_name": "$packageName", "name": "CMP fixture", + "version_code": 1, "version_name": "1", "split_configs": ["config.resources"], + "split_apks": [ + {"file": "$baseName", "id": "base"}, + {"file": "$splitName", "id": "config.resources"} + ] + } + """.trimIndent().toByteArray() + } + val file = write("${UUID.randomUUID()}.${if (xapk) "xapk" else "apks"}", zip(entries)) + return checkNotNull(GetArchivePackageInfoUseCase()(file)) + } + + private fun packageInfo(base: File): PackageInfo { + return checkNotNull( + instrumentation.targetContext.packageManager.getPackageArchiveInfo(base.path, PackageManager.GET_PROVIDERS) + ).apply { + applicationInfo!!.sourceDir = base.path + applicationInfo!!.publicSourceDir = base.path + } + } + + private fun apk( + name: String, + split: String? = null, + version: Int = 1, + majorVersion: Int = 0, + provider: Boolean = false, + payload: Boolean = false, + applicationPackage: String? = null + ): ByteArray { + val android = "http://schemas.android.com/apk/res/android" + val writer = AxmlWriter() + writer.ns("android", android, 1) + val root = writer.child(null, "manifest") + root.attr(null, "package", -1, name, Res_value.newStringValue(name)) + root.attr(android, "versionCode", 0x0101021b, null, Res_value(Res_value.TYPE_INT_DEC, version, null, null)) + root.attr(android, "versionCodeMajor", 0x01010576, null, Res_value(Res_value.TYPE_INT_DEC, majorVersion, null, null)) + if (split != null) root.attr(null, "split", -1, split, Res_value.newStringValue(split)) + root.child(null, "uses-sdk").apply { + attr(android, "minSdkVersion", 0x0101020c, null, Res_value(Res_value.TYPE_INT_DEC, 24, null, null)) + attr(android, "targetSdkVersion", 0x01010270, null, Res_value(Res_value.TYPE_INT_DEC, 36, null, null)) + end() + } + root.child(null, "application").apply { + if (applicationPackage != null) { + attr(null, "package", -1, applicationPackage, Res_value.newStringValue(applicationPackage)) + } + if (provider) { + child(null, "provider").apply { + attr(android, "name", 0x01010003, null, Res_value(Res_value.TYPE_STRING, 0, "org.jetbrains.compose.resources.AndroidContextProvider", null)) + attr(android, "authorities", 0x01010018, null, Res_value(Res_value.TYPE_STRING, 0, "$name.resources", null)) + attr(android, "exported", 0x01010010, null, Res_value(Res_value.TYPE_INT_BOOLEAN, 0, null, null)) + end() + } + } + end() + } + root.end() + writer.end() + return zip( + buildMap { + put("AndroidManifest.xml", writer.toByteArray()) + if (payload) put("assets/composeResources/example/files/config.json", "{}".toByteArray()) + } + ) + } + + private fun zip(entries: Map): ByteArray { + return ByteArrayOutputStream().apply { + ZipOutputStream(this).use { output -> + entries.forEach { (name, bytes) -> + output.putNextEntry(ZipEntry(name)) + output.write(bytes) + output.closeEntry() + } + } + }.toByteArray() + } + + private fun write(name: String, bytes: ByteArray): File { + val file = File(directory, name) + val pipes = instrumentation.uiAutomation.executeShellCommandRw("tee ${file.path}") + ParcelFileDescriptor.AutoCloseOutputStream(pipes[1]).use { it.write(bytes) } + ParcelFileDescriptor.AutoCloseInputStream(pipes[0]).use { it.readBytes() } + shell("chmod 644 ${file.path}") + check(file.length() == bytes.size.toLong()) { "Fixture write failed: ${file.path}" } + return file + } + + private fun shell(command: String) { + ParcelFileDescriptor.AutoCloseInputStream( + instrumentation.uiAutomation.executeShellCommand(command) + ).use { it.readBytes() } + } +} diff --git a/app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/PackageFeaturesTest.kt b/app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/PackageFeaturesTest.kt index c0127e4d5c..706265c4e8 100644 --- a/app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/PackageFeaturesTest.kt +++ b/app/src/androidTest/kotlin/com/absinthe/libchecker/utils/extensions/PackageFeaturesTest.kt @@ -28,25 +28,38 @@ class PackageFeaturesTest { } @Test - fun isUseKmpChecksProviderAndReusesOpenedZipWithoutExtraDexOpens() { - val zip = TrackingZip(listOf("classes.dex")) + fun composeResourcesRequiresPayloadAndOwnRuntimeEvidenceAcrossApks() { + val baseZip = TrackingZip(listOf("classes.dex")) + val resourceSplitZip = TrackingZip(listOf("assets/composeResources/example.generated.resources/files/config.json")) val piWithProvider = packageInfo().apply { - packageName = "com.example.kmp" + packageName = "com.example.resources" providers = arrayOf( ProviderInfo().apply { name = "org.jetbrains.compose.resources.AndroidContextProvider" } ) } - assertTrue(piWithProvider.isUseKMP(openedZip = zip)) - assertEquals(0, zip.dexOpens) + assertTrue(piWithProvider.hasComposeMultiplatformResources(listOf(baseZip, resourceSplitZip))) + assertEquals(0, baseZip.dexOpens) + assertEquals(0, resourceSplitZip.dexOpens) + + assertFalse(piWithProvider.hasComposeMultiplatformResources(listOf(baseZip))) + assertFalse( + piWithProvider.hasComposeMultiplatformResources( + listOf(TrackingZip(listOf("assets/composeResources/", "assets/composeResources/example/"))) + ) + ) + assertFalse( + piWithProvider.hasComposeMultiplatformResources( + listOf(TrackingZip(listOf("assets/composeresources/example/files/config.json"))) + ) + ) val piWithoutProvider = packageInfo().apply { - packageName = "com.example.nonkmp" - providers = emptyArray() + packageName = "com.example.no-provider" + providers = null } - assertFalse(piWithoutProvider.isUseKMP(openedZip = zip)) - assertEquals(1, zip.dexOpens) + assertFalse(piWithoutProvider.hasComposeMultiplatformResources(listOf(resourceSplitZip))) } @Test diff --git a/app/src/main/kotlin/com/absinthe/libchecker/database/entity/Features.kt b/app/src/main/kotlin/com/absinthe/libchecker/database/entity/Features.kt index 6834b345d3..55c38a4676 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/database/entity/Features.kt +++ b/app/src/main/kotlin/com/absinthe/libchecker/database/entity/Features.kt @@ -8,7 +8,7 @@ object Features { const val PLAY_SIGNING = 1 shl 4 const val PWA = 1 shl 5 const val JETPACK_COMPOSE = 1 shl 6 - const val KMP = 1 shl 10 + const val COMPOSE_MULTIPLATFORM_RESOURCES = 1 shl 10 const val LIVE_UPDATE_NOTIFICATION = 1 shl 11 object Ext { diff --git a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCase.kt b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCase.kt index 345be233ba..c459d5e6fc 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCase.kt +++ b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCase.kt @@ -55,10 +55,10 @@ class BuildAppDetailFeatureItemUseCase { action = AppDetailFeatureAction.JetpackCompose(feature.version) ) - Features.KMP -> AppDetailFeatureItemData( + Features.COMPOSE_MULTIPLATFORM_RESOURCES -> AppDetailFeatureItemData( icon = resourceIcon(com.absinthe.lc.rulesbundle.R.drawable.ic_lib_jetbrain_kmp), - titleRes = R.string.jetbrain_kmp, - action = AppDetailFeatureAction.Kmp(feature.version) + titleRes = R.string.compose_multiplatform_resources, + action = AppDetailFeatureAction.ComposeMultiplatformResources(feature.version) ) Features.LIVE_UPDATE_NOTIFICATION -> AppDetailFeatureItemData( @@ -167,7 +167,7 @@ sealed interface AppDetailFeatureAction { data object PlaySigning : Dialog data object Pwa : Dialog data class JetpackCompose(val version: String?) : Dialog - data class Kmp(val version: String?) : Dialog + data class ComposeMultiplatformResources(val version: String?) : Dialog data object LiveUpdateNotification : Dialog data object AppProp : AppDetailFeatureAction data object InstallSource : AppDetailFeatureAction diff --git a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/FeatureDialogSpec.kt b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/FeatureDialogSpec.kt index e238754949..ae6baf0b52 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/FeatureDialogSpec.kt +++ b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/FeatureDialogSpec.kt @@ -73,10 +73,10 @@ fun AppDetailFeatureAction.Dialog.toDialogSpec(): FeatureDialogSpec { sourceUrl = "https://developer.$ANDROID_DEV_HOST/compose" ) - is AppDetailFeatureAction.Kmp -> versionDialogSpec( + is AppDetailFeatureAction.ComposeMultiplatformResources -> versionDialogSpec( iconRes = com.absinthe.lc.rulesbundle.R.drawable.ic_lib_jetbrain_kmp, - titleRes = R.string.jetbrain_kmp, - messageRes = R.string.jetbrain_compose_multiplatform_details, + titleRes = R.string.compose_multiplatform_resources, + messageRes = R.string.compose_multiplatform_resources_details, version = version, sourceUrl = "https://www.jetbrains.com/compose-multiplatform/" ) diff --git a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/GetAppDetailFeaturesUseCase.kt b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/GetAppDetailFeaturesUseCase.kt index ad373f704c..66cbe3df86 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/GetAppDetailFeaturesUseCase.kt +++ b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/feature/GetAppDetailFeaturesUseCase.kt @@ -24,7 +24,7 @@ import com.absinthe.libchecker.utils.extensions.hasXposedModuleMetadata import com.absinthe.libchecker.utils.extensions.isPWA import com.absinthe.libchecker.utils.extensions.isPageSizeCompat import com.absinthe.libchecker.utils.extensions.isPlayAppSigning -import com.absinthe.libchecker.utils.extensions.isUseKMP +import com.absinthe.libchecker.utils.extensions.usesComposeMultiplatformResources import java.io.File import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext @@ -67,8 +67,8 @@ class GetAppDetailFeaturesUseCase( var metadataEmitted = false var pageSizeCompatChecked = false var isPageSizeCompat = false - var kmpChecked = false - var isKmp = false + var composeResourcesChecked = false + var usesComposeResources = false val scannedMetadata = mutableListOf() if (feat == -1) { val sourceDir = packageInfo.applicationInfo?.sourceDir @@ -90,8 +90,8 @@ class GetAppDetailFeaturesUseCase( isPageSizeCompat = packageInfo.isPageSizeCompat(zip) pageSizeCompatChecked = true } - isKmp = (scannedFeatures and Features.KOTLIN_USED) > 0 && packageInfo.isUseKMP(openedZip = zip) - kmpChecked = true + usesComposeResources = packageInfo.usesComposeMultiplatformResources(openedBaseZip = zip) + composeResourcesChecked = true metadataEmitted = true } } @@ -134,8 +134,8 @@ class GetAppDetailFeaturesUseCase( isPageSizeCompat = packageInfo.isPageSizeCompat(zip) pageSizeCompatChecked = true } - isKmp = (feat and Features.KOTLIN_USED) > 0 && packageInfo.isUseKMP(openedZip = zip) - kmpChecked = true + usesComposeResources = packageInfo.usesComposeMultiplatformResources(openedBaseZip = zip) + composeResourcesChecked = true } } catch (e: CancellationException) { throw e @@ -179,13 +179,13 @@ class GetAppDetailFeaturesUseCase( emitFeature(VersionedFeature(Features.Ext.ELF_PAGE_SIZE_16KB_COMPAT)) } - val hasKmp = if (kmpChecked) { - isKmp + val hasComposeResources = if (composeResourcesChecked) { + usesComposeResources } else { - (feat and Features.KOTLIN_USED) > 0 && packageInfo.isUseKMP() + packageInfo.usesComposeMultiplatformResources() } - if (hasKmp) { - emitFeature(VersionedFeature(Features.KMP)) + if (hasComposeResources) { + emitFeature(VersionedFeature(Features.COMPOSE_MULTIPLATFORM_RESOURCES)) } return AppDetailFeatures(features, appIcons) diff --git a/app/src/main/kotlin/com/absinthe/libchecker/utils/extensions/PackageInfoExtensions.kt b/app/src/main/kotlin/com/absinthe/libchecker/utils/extensions/PackageInfoExtensions.kt index 37fc1eb59f..413782ee5e 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/utils/extensions/PackageInfoExtensions.kt +++ b/app/src/main/kotlin/com/absinthe/libchecker/utils/extensions/PackageInfoExtensions.kt @@ -56,6 +56,7 @@ import java.io.FilterInputStream import java.io.InputStream import java.text.DateFormat import java.util.Properties +import java.util.concurrent.CancellationException import okio.buffer import okio.source import timber.log.Timber @@ -367,8 +368,10 @@ fun ApplicationInfo.isUse32BitAbi(): Boolean { } private val COMPOSE_CLASS_PATTERN = "androidx.compose.*".toClassDefType() -private val JETBRAINS_COMPOSE_CLASS_PATTERN = "org.jetbrains.compose.*".toClassDefType() -private val JETBRAINS_COMPOSE_CLASS_PATTERNS = listOf(JETBRAINS_COMPOSE_CLASS_PATTERN) +private const val COMPOSE_RESOURCES_ASSET_PREFIX = "assets/composeResources/" +private const val COMPOSE_RESOURCES_PROVIDER = "org.jetbrains.compose.resources.AndroidContextProvider" +private val COMPOSE_RESOURCES_CLASS_PATTERN = "org.jetbrains.compose.resources.*".toClassDefType() +private val COMPOSE_RESOURCES_CLASS_PATTERNS = listOf(COMPOSE_RESOURCES_CLASS_PATTERN) private val KOTLIN_CLASS_PATTERN = "kotlin.*".toClassDefType() private val KOTLINX_CLASS_PATTERN = "kotlinx.*".toClassDefType() @@ -636,36 +639,67 @@ fun PackageInfo.is16KBAligned(libs: List? = null, isApk: Boolean } } -/** - * Check if an app is using Kotlin Multiplatform - * @return True if is using Kotlin Multiplatform - */ -private fun PackageInfo.hasKmpContextProvider(): Boolean { - if (packageName.isNullOrBlank()) return false - val resolvedProviders = providers ?: runCatching { - PackageUtils.getPackageInfo( - packageName, - PackageManager.GET_PROVIDERS - ) - }.getOrNull()?.providers - return resolvedProviders?.any { it.name == "org.jetbrains.compose.resources.AndroidContextProvider" } == true +fun PackageInfo.usesComposeMultiplatformResources(openedBaseZip: IZipFile? = null): Boolean { + val sourceDir = applicationInfo?.sourceDir ?: return false + val openedSplits = mutableListOf() + return try { + val apkFiles = buildList { + add(File(sourceDir)) + applicationInfo?.splitSourceDirs?.forEach { add(File(it)) } + PackageUtils.getSplitsSourceDir(this@usesComposeMultiplatformResources)?.forEach { add(File(it)) } + }.distinct() + if (apkFiles.any { !it.isFile || !it.canRead() }) return false + + val apkZips = apkFiles.map { apkFile -> + if (apkFile.path == sourceDir && openedBaseZip != null) { + openedBaseZip + } else { + ZipFileCompat(apkFile).also(openedSplits::add) + } + } + hasMatchingComposeResourcesApkSet(apkZips) && hasComposeMultiplatformResources(apkZips) + } catch (e: Throwable) { + if (e is CancellationException) throw e + false + } finally { + openedSplits.forEach(IZipFile::close) + } } -fun PackageInfo.isUseKMP(foundList: List? = null, openedZip: IZipFile? = null): Boolean { - if (hasKmpContextProvider()) { - return true +private fun PackageInfo.hasMatchingComposeResourcesApkSet(apkZips: List): Boolean { + val manifests = apkZips.map { zip -> + val entry = zip.getEntry("AndroidManifest.xml") ?: return false + val bytes = zip.getInputStream(entry).use(ManifestReader::getBytesFromInputStream) ?: return false + ManifestReader.getApkIdentity(bytes) } - val realFoundList = when { - foundList != null -> foundList - - openedZip != null -> PackageUtils.findDexClasses(openedZip, JETBRAINS_COMPOSE_CLASS_PATTERNS) + val base = manifests.first() + if (packageName.isNullOrBlank() || base["package"] != packageName || base.containsKey("split")) return false + val splitNames = mutableSetOf() + return manifests.all { manifest -> + manifest["package"] == packageName && + listOf("versionCode", "versionCodeMajor").all { attribute -> + java.lang.Long.decode(manifest[attribute] ?: "0") == + java.lang.Long.decode(base[attribute] ?: "0") + } + } && manifests.drop(1).all { manifest -> + val splitName = manifest["split"] + splitName.isNullOrBlank().not() && splitNames.add(splitName) + } +} - else -> { - val file = File(applicationInfo?.sourceDir ?: return false) - PackageUtils.findDexClasses(file, JETBRAINS_COMPOSE_CLASS_PATTERNS) +internal fun PackageInfo.hasComposeMultiplatformResources(apkZips: List): Boolean { + val hasResourcesPayload = apkZips.any { zip -> + zip.getZipEntries().asSequence().any { entry -> + !entry.isDirectory && entry.name.startsWith(COMPOSE_RESOURCES_ASSET_PREFIX) } } - return realFoundList.contains(JETBRAINS_COMPOSE_CLASS_PATTERN) + if (!hasResourcesPayload) return false + + val hasResourcesProvider = providers?.any { it.name == COMPOSE_RESOURCES_PROVIDER } == true + return hasResourcesProvider || apkZips.any { zip -> + PackageUtils.findDexClasses(zip, COMPOSE_RESOURCES_CLASS_PATTERNS) + .contains(COMPOSE_RESOURCES_CLASS_PATTERN) + } } fun PackageInfo.isArchivedPackage(): Boolean { diff --git a/app/src/main/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReader.java b/app/src/main/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReader.java index 191a1118ee..2d95d8d9d8 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReader.java +++ b/app/src/main/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReader.java @@ -45,6 +45,41 @@ public static Map getManifestProperties(File apk, String[] deman return new ManifestReader(apk, demands).properties; } + public static Map getApkIdentity(byte[] bytes) throws IOException { + if (bytes == null || bytes.length > MAX_MANIFEST_BYTES) { + throw new IOException("Invalid manifest size"); + } + Map identity = new ArrayMap<>(); + new AxmlReader(bytes).accept(new AxmlVisitor() { + private boolean visitedRoot; + + @Override + public NodeVisitor child(String ns, String name) { + if (visitedRoot || ns != null || !"manifest".equals(name)) { + throw new IllegalArgumentException("Invalid manifest root"); + } + visitedRoot = true; + return new NodeVisitor() { + @Override + public void attr(String ns, String name, int resourceId, String raw, Res_value value) { + boolean text = "package".equals(name) || "split".equals(name); + boolean version = "versionCode".equals(name) || "versionCodeMajor".equals(name); + if (!text && !version) return; + if (text ? ns != null : !"http://schemas.android.com/apk/res/android".equals(ns)) return; + if (text ? value.type != Res_value.TYPE_STRING + : value.type != Res_value.TYPE_INT_DEC && value.type != Res_value.TYPE_INT_HEX) { + throw new IllegalArgumentException("Invalid APK identity attribute"); + } + if (identity.put(name, value.toString()) != null) { + throw new IllegalArgumentException("Duplicate APK identity attribute"); + } + } + }; + } + }); + return identity; + } + public static byte[] getBytesFromInputStream(InputStream inputStream) { try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { if (IOUtils.copyLarge(inputStream, bos, 0, MAX_MANIFEST_BYTES) == MAX_MANIFEST_BYTES diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 252e900ded..bdb94967c8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -177,6 +177,7 @@ 渐进式 Web 应用程序 (PWA) 使用现代 API 构建和增强,以提供增强的功能、可靠性和可安装性,同时使用单个代码库在任何设备上访问任何人、任何地方。 Jetpack Compose 是用于构建原生 Android 界面的新工具包。它可简化并加快 Android 上的界面开发,使用更少的代码、强大的工具和直观的 Kotlin API,快速打造生动而精彩的应用。 Compose Multiplatform 是一个由 JetBrains 开发的现代化声明式和响应式 UI 框架,它提供了一种只需少量 Kotlin 代码即可构建用户界面的简单方法。它还允许您只需编写一次用户界面,即可在任何支持的平台——iOS、Android、桌面(Windows、macOS、Linux)和 Web——上运行。 + Compose Multiplatform Resources 为 Android 和其他平台提供共享资源。它可能由应用依赖项引入,并不一定表示应用本身是多平台应用。 android:multiarch 属性设为 true。]]> XML 详情 16 KB 页大小支持 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index add8822bda..fee96592d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -189,6 +189,7 @@ Progressive Web Apps (PWA) are built and enhanced with modern APIs to deliver enhanced capabilities, reliability, and installability while reaching anyone, anywhere, on any device with a single codebase. Jetpack Compose is Android’s modern toolkit for building native UI. It simplifies and accelerates UI development on Android. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs. Compose Multiplatform is a modern declarative and reactive UI framework developed by JetBrains that provides a simple way to build user interfaces with a small amount of Kotlin code. It also allows you to write your UI once and run it on any of the supported platforms – iOS, Android, desktop (Windows, macOS, Linux), and web. + Compose Multiplatform Resources provides shared resources for Android and other platforms. It can be included by an app dependency and does not necessarily mean the app itself is multiplatform. android:multiarch property set to true within its manifest to avoid potential errors.]]> XML Detail 16 KB page size support diff --git a/app/src/main/res/values/untranslatable.xml b/app/src/main/res/values/untranslatable.xml index 2a027f4f96..8956a6622c 100644 --- a/app/src/main/res/values/untranslatable.xml +++ b/app/src/main/res/values/untranslatable.xml @@ -14,7 +14,7 @@ Progressive Web Apps Jetpack Compose Compose - Compose Multiplatform + Compose Multiplatform Resources (Example) diff --git a/app/src/test/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCaseTest.kt b/app/src/test/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCaseTest.kt index 422d6c7777..b203f0b7bf 100644 --- a/app/src/test/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCaseTest.kt +++ b/app/src/test/kotlin/com/absinthe/libchecker/domain/app/detail/feature/BuildAppDetailFeatureItemUseCaseTest.kt @@ -8,6 +8,35 @@ import org.junit.Test class BuildAppDetailFeatureItemUseCaseTest { + @Test + fun composeFeaturesPreserveTheirOwnVersionThroughDialogMapping() { + val featureTitles = mapOf( + Features.JETPACK_COMPOSE to R.string.jetpack_compose, + Features.COMPOSE_MULTIPLATFORM_RESOURCES to R.string.compose_multiplatform_resources + ) + for ((featureType, titleRes) in featureTitles) { + for (version in listOf("1.8.2", null)) { + val item = BuildAppDetailFeatureItemUseCase()( + AppDetailFeatureItemRequest( + feature = VersionedFeature(featureType, version), + currentFeatureCount = 0, + apkAnalyticsMode = false, + canShowInstallSource = true, + canShowAppIcons = true, + appIcons = emptyList() + ) + ) + val spec = (item!!.action as AppDetailFeatureAction.Dialog).toDialogSpec() + + assertEquals(titleRes, spec.titleRes) + assertEquals( + listOf(FeatureDialogTitleEntry(FeatureDialogTitleLabel.Resource(titleRes), version)), + spec.titleEntries + ) + } + } + } + @Test fun resourceFeatureUsesExplicitIconModel() { val item = BuildAppDetailFeatureItemUseCase()( diff --git a/app/src/test/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReadersTest.kt b/app/src/test/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReadersTest.kt index e35998ebf5..cb39041147 100644 --- a/app/src/test/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReadersTest.kt +++ b/app/src/test/kotlin/com/absinthe/libchecker/utils/manifest/ManifestReadersTest.kt @@ -24,6 +24,33 @@ class ManifestReadersTest { @get:Rule val temporaryFolder = TemporaryFolder() + @Test + fun apkIdentityUsesOnlyRootAttributesInTheirExpectedNamespace() { + val android = "http://schemas.android.com/apk/res/android" + val writer = AxmlWriter() + writer.ns("android", android, 1) + val root = writer.child(null, "manifest") + root.attribute("package", "sample.app") + root.attribute("split", "config.resources") + root.attr(android, "versionCode", 0x0101021b, null, Res_value(Res_value.TYPE_INT_DEC, 7, null, null)) + root.attribute("versionCode", 99) + root.child(null, "application").apply { + attribute("package", "other.app") + attribute("split", "other.split") + attr(android, "versionCode", 0x0101021b, null, Res_value(Res_value.TYPE_INT_DEC, 99, null, null)) + end() + } + root.end() + writer.end() + assertEquals( + mapOf("package" to "sample.app", "split" to "config.resources", "versionCode" to "7"), + ManifestReader.getApkIdentity(writer.toByteArray()) + ) + assertThrows(Exception::class.java) { + ManifestReader.getApkIdentity(byteArrayOf(1, 2, 3)) + } + } + @Test fun stopsReadingAnUnboundedManifest() { var bytesRead = 0 From 36a44084aa7a9863077486691dc16a7555fb863f Mon Sep 17 00:00:00 2001 From: Absinthe Date: Tue, 20 Oct 2026 15:54:52 +0800 Subject: [PATCH 2/2] Stop host scrolling for popup --- ...tailsTitleAccessibilityInstrumentedTest.kt | 5 ++ .../home/RecentVisitsInstrumentedTest.kt | 59 +++++++++++++++++++ .../app/detail/ui/view/DetailsTitleView.kt | 4 +- .../domain/home/ui/view/RecentVisitsPopup.kt | 15 +++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleAccessibilityInstrumentedTest.kt b/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleAccessibilityInstrumentedTest.kt index f84f7ab718..471992243c 100644 --- a/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleAccessibilityInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleAccessibilityInstrumentedTest.kt @@ -62,6 +62,11 @@ class DetailsTitleAccessibilityInstrumentedTest { title.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), 0) title.layout(0, 0, title.measuredWidth, title.measuredHeight) assertTrue(badges.top >= title.extraInfoView.bottom) + if (direction == View.LAYOUT_DIRECTION_RTL) { + assertEquals(title.appNameView.right, badges.right) + } else { + assertEquals(title.appNameView.left, badges.left) + } for (index in abis.indices) { val badge = badges.getChildAt(index) val node = AccessibilityNodeInfoCompat.wrap(badge.createAccessibilityNodeInfo()) diff --git a/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/home/RecentVisitsInstrumentedTest.kt b/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/home/RecentVisitsInstrumentedTest.kt index 5bba72dedc..b96ca7b5c5 100644 --- a/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/home/RecentVisitsInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/com/absinthe/libchecker/domain/home/RecentVisitsInstrumentedTest.kt @@ -17,6 +17,7 @@ import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.core.content.edit import androidx.core.view.doOnLayout import androidx.recyclerview.widget.RecyclerView @@ -32,6 +33,7 @@ import com.absinthe.libchecker.domain.home.ui.MainActivity import com.absinthe.libchecker.domain.home.ui.view.RecentVisitGroup import com.absinthe.libchecker.domain.home.ui.view.RecentVisitItem import com.absinthe.libchecker.domain.home.ui.view.RecentVisitsPopup +import com.absinthe.libchecker.domain.snapshot.track.ui.TrackActivity import com.absinthe.libchecker.utils.OsUtils import com.google.android.material.navigation.NavigationBarView import kotlin.math.abs @@ -109,6 +111,63 @@ class RecentVisitsInstrumentedTest { if (OsUtils.atLeastT()) checkPopup(blurDesign = true) } + @Test + fun popupStopsHostFlingScrolling() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = instrumentation.startActivitySync( + Intent(instrumentation.targetContext, TrackActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + try { + lateinit var host: FrameLayout + lateinit var list: RecyclerView + lateinit var popup: RecentVisitsPopup + instrumentation.runOnMainSync { + host = FrameLayout(activity) + list = RecyclerView(activity).apply { + layoutManager = androidx.recyclerview.widget.LinearLayoutManager(activity) + adapter = object : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = object : RecyclerView.ViewHolder( + View(activity).apply { + layoutParams = ViewGroup.LayoutParams(100, 100) + } + ) {} + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {} + override fun getItemCount() = 100 + } + } + host.addView(list, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 200)) + activity.addContentView( + host, + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + ) + } + instrumentation.waitForIdleSync() + instrumentation.runOnMainSync { + list.isNestedScrollingEnabled = false + assertTrue("List fling should start", list.fling(0, 5000)) + assertEquals(RecyclerView.SCROLL_STATE_SETTLING, list.scrollState) + + val anchor = View(activity) + popup = RecentVisitsPopup( + host = host, + navigation = anchor, + anchor = anchor, + libraries = false, + onItemClick = {}, + onPin = {}, + onRemove = {} + ) + popup.show(null) + assertEquals(RecyclerView.SCROLL_STATE_IDLE, list.scrollState) + } + instrumentation.runOnMainSync { + popup.dismissImmediately() + } + } finally { + instrumentation.runOnMainSync { activity.finish() } + } + } + @Test fun sideNavigationKeepsTheTailOnItsIconWhileResizing() { val instrumentation = InstrumentationRegistry.getInstrumentation() diff --git a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleView.kt b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleView.kt index 661099685b..f0e9635d18 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleView.kt +++ b/app/src/main/kotlin/com/absinthe/libchecker/domain/app/detail/ui/view/DetailsTitleView.kt @@ -147,8 +147,6 @@ class DetailsTitleView( ViewGroup.LayoutParams.WRAP_CONTENT ).also { it.topMargin = 4.dp - it.marginStart = (-4).dp - it.bottomMargin = (-4).dp } overScrollMode = OVER_SCROLL_NEVER isHorizontalScrollBarEnabled = false @@ -217,6 +215,6 @@ class DetailsTitleView( packageNameView.layout(appNameXOffset, appNameView.bottom) versionInfoView.layout(appNameXOffset, packageNameView.bottom) extraInfoView.layout(appNameXOffset, versionInfoView.bottom) - abiLabelsFlexLayout.layout(appNameXOffset + abiLabelsFlexLayout.marginStart, extraInfoView.bottom + abiLabelsFlexLayout.marginTop) + abiLabelsFlexLayout.layout(appNameXOffset, extraInfoView.bottom + abiLabelsFlexLayout.marginTop) } } diff --git a/app/src/main/kotlin/com/absinthe/libchecker/domain/home/ui/view/RecentVisitsPopup.kt b/app/src/main/kotlin/com/absinthe/libchecker/domain/home/ui/view/RecentVisitsPopup.kt index 9a7368980f..07afdcac61 100644 --- a/app/src/main/kotlin/com/absinthe/libchecker/domain/home/ui/view/RecentVisitsPopup.kt +++ b/app/src/main/kotlin/com/absinthe/libchecker/domain/home/ui/view/RecentVisitsPopup.kt @@ -38,6 +38,7 @@ import androidx.appcompat.widget.AppCompatTextView import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.core.view.doOnLayout +import androidx.recyclerview.widget.RecyclerView import com.absinthe.libchecker.R import com.absinthe.libchecker.ui.animator.ParticleRemoveItemAnimator import com.absinthe.libchecker.ui.base.FIXED_BLUR_RADII @@ -160,6 +161,7 @@ class RecentVisitsPopup( } fun show(items: RecentVisitGroup?) { + stopScrolling(host) updateItems(items) showAtLocation(host, Gravity.NO_GRAVITY, 0, 0) overlay.doOnLayout { @@ -185,6 +187,7 @@ class RecentVisitsPopup( } fun prepareDrag(item: RecentVisitItem, source: View, touch: PointF): String { + stopScrolling(host) draggedItem = item cancelDrag = source::cancelDragAndDrop val location = IntArray(2).also(source::getLocationOnScreen) @@ -528,6 +531,18 @@ class RecentVisitsPopup( private fun dp(value: Int) = context.dpToDimension(value.toFloat()).roundToInt() + private fun stopScrolling(view: View) { + if (view is RecyclerView) { + view.stopScroll() + view.stopNestedScroll() + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + stopScrolling(view.getChildAt(i)) + } + } + } + private inner class BackdropView : FrameLayout(context) { private val contentClip = Path().apply { fillType = Path.FillType.EVEN_ODD } private val navigationCutout = Path()