diff --git a/src/apps/mobile/android/app/build.gradle.kts b/src/apps/mobile/android/app/build.gradle.kts index fd5dec0842..fdc0014bc0 100644 --- a/src/apps/mobile/android/app/build.gradle.kts +++ b/src/apps/mobile/android/app/build.gradle.kts @@ -76,7 +76,11 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.google.code.scanner) + implementation("androidx.camera:camera-camera2:1.4.2") + implementation("androidx.camera:camera-lifecycle:1.4.2") + implementation("androidx.camera:camera-view:1.4.2") + // Bundle QR recognition so pairing also works without Play Services downloads. + implementation("com.google.mlkit:barcode-scanning:17.3.0") implementation(libs.androidx.window) testImplementation("junit:junit:4.13.2") diff --git a/src/apps/mobile/android/app/src/androidTest/assets/pairing-qr.png b/src/apps/mobile/android/app/src/androidTest/assets/pairing-qr.png new file mode 100644 index 0000000000..2e7307b542 Binary files /dev/null and b/src/apps/mobile/android/app/src/androidTest/assets/pairing-qr.png differ diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConnectionSheetChromeTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConnectionSheetChromeTest.kt new file mode 100644 index 0000000000..50ffb5590b --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConnectionSheetChromeTest.kt @@ -0,0 +1,84 @@ +package com.openbitfun.mobile.app + +import androidx.compose.foundation.layout.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.unit.dp +import com.openbitfun.mobile.app.ui.common.connectionSheetTextStyle +import com.openbitfun.mobile.app.ui.common.ConnectionSheetHeader +import com.openbitfun.mobile.app.ui.common.ConnectionSheetFooter +import com.openbitfun.mobile.app.ui.theme.OpenBitFunTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ConnectionSheetChromeTest { + @get:Rule val composeRule = createComposeRule() + + @Test fun closeAndManualActionsKeepHarmonyGeometryAndRemainClickable() { + var closed = false + var manual = false + composeRule.setContent { + OpenBitFunTheme(dark = false) { + Column(Modifier.requiredSize(360.dp, 700.dp)) { + ConnectionSheetHeader { closed = true } + Spacer(Modifier.weight(1f)) + ConnectionSheetFooter("Pair manually", onClick = { manual = true }) + } + } + } + composeRule.onNodeWithContentDescription( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.common_close)) + .assertWidthIsEqualTo(48.dp).assertHeightIsEqualTo(48.dp).performClick() + composeRule.onNodeWithText("Pair manually").assertHeightIsEqualTo(48.dp) + .assertWidthIsEqualTo(320.dp).performClick() + composeRule.runOnIdle { assertTrue(closed); assertTrue(manual) } + } + @Test fun chineseSheetTextUsesTheReferenceLineBoxes() { + composeRule.setContent { + OpenBitFunTheme(dark = false) { + Column { + androidx.compose.material3.Text("扫描桌面端二维码", + style = androidx.compose.material3.MaterialTheme.typography.displayMedium.connectionSheetTextStyle()) + androidx.compose.material3.Text("第一行说明\n第二行说明", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium.connectionSheetTextStyle()) + } + } + } + composeRule.onNodeWithText("扫描桌面端二维码").assertIsDisplayed().assertHeightIsEqualTo(28.dp) + val body = composeRule.onNodeWithText("第一行说明\n第二行说明").assertIsDisplayed() + // Android rounds the first/last font baselines independently; preserve + // the two 21dp lines within one layout unit without padding the glyphs. + val bounds = body.getUnclippedBoundsInRoot() + assertTrue(kotlin.math.abs((bounds.bottom - bounds.top).value - 42f) <= 1f) + } + + @Test fun compactLoginKeepsContentGeometryAtPhoneAndWideWidths() { + var width by androidx.compose.runtime.mutableStateOf(386.dp) + composeRule.setContent { + OpenBitFunTheme(dark = false) { + com.openbitfun.mobile.app.ui.account.AccountLoginPage( + state = com.openbitfun.mobile.core.feature.account.AccountUiState.SignedOut, + onBack = {}, onLogin = {}, + modifier = Modifier.requiredWidth(width).testTag("login-panel")) + } + } + val context = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + for (panelWidth in listOf(386.dp, 560.dp)) { + composeRule.runOnIdle { width = panelWidth } + val panel = composeRule.onNodeWithTag("login-panel").assertHeightIsEqualTo(280.dp) + .assertWidthIsEqualTo(panelWidth).getUnclippedBoundsInRoot() + val labels = composeRule.onAllNodesWithText(context.getString(R.string.account_login_title)) + val title = labels[0].getUnclippedBoundsInRoot() + val action = labels[1].getUnclippedBoundsInRoot() + assertTrue(kotlin.math.abs((title.top - panel.top).value - 56f) < 1f) + assertTrue(kotlin.math.abs((action.top - panel.top).value - 208f) < 1f) + labels[1].assertHeightIsEqualTo(48.dp).assertWidthIsEqualTo(panelWidth - 40.dp) + } + } + +} diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/InlineQrDecoderTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/InlineQrDecoderTest.kt new file mode 100644 index 0000000000..39d39f2d27 --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/InlineQrDecoderTest.kt @@ -0,0 +1,34 @@ +package com.openbitfun.mobile.app + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.tasks.Tasks +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import org.junit.Assert.* +import org.junit.Test +import java.util.concurrent.TimeUnit + +class InlineQrDecoderTest { + @Test fun bundledDecoderReadsQrAndIgnoresBlankFrames() { + val scanner = BarcodeScanning.getClient(BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()) + val bitmap = InstrumentationRegistry.getInstrumentation().context.assets.open("pairing-qr.png") + .use { BitmapFactory.decodeStream(it) } + val blank = Bitmap.createBitmap(320, 320, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) } + try { + val codes = Tasks.await(scanner.process(InputImage.fromBitmap(bitmap, 0)), 20, TimeUnit.SECONDS) + assertEquals(listOf("openbitfun-qr-decoder-fixture"), codes.map { it.rawValue }) + val empty = Tasks.await(scanner.process(InputImage.fromBitmap(blank, 0)), 20, TimeUnit.SECONDS) + assertTrue(empty.isEmpty()) + } finally { + scanner.close() + bitmap.recycle() + blank.recycle() + } + } +} diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt index 0c8263b411..ab6e79f760 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt @@ -82,6 +82,7 @@ class MobilePreviewStatesVisualTest { fun circleStatesHaveStableTouchTargetsAndMoreActionIsSafe() { composeRule.setContent { MobilePreviewCircleStates() } composeRule.onNodeWithTag(MOBILE_PREVIEW_CIRCLE_STATES_TEST_TAG).assertIsDisplayed() + // The shared control_touch_size contract is 48dp on all three platforms. listOf( MOBILE_PREVIEW_CIRCLE_SIDEBAR_TEST_TAG, MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG, @@ -89,8 +90,8 @@ class MobilePreviewStatesVisualTest { ).forEach { tag -> composeRule.onNodeWithTag(tag) .assertIsDisplayed() - .assertWidthIsEqualTo(44.dp) - .assertHeightIsEqualTo(44.dp) + .assertWidthIsEqualTo(48.dp) + .assertHeightIsEqualTo(48.dp) } composeRule.onNodeWithContentDescription("More actions").performClick() composeRule.onNodeWithTag(MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG).assertIsDisplayed() diff --git a/src/apps/mobile/android/app/src/main/AndroidManifest.xml b/src/apps/mobile/android/app/src/main/AndroidManifest.xml index 0b7609a584..6e6f48f512 100644 --- a/src/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/src/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + + + -