Skip to content
Merged

Patch 2 #2237

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 @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<RecyclerView.ViewHolder>() {
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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, ByteArray>): 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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading