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
6 changes: 5 additions & 1 deletion src/apps/mobile/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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)
}
}

}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,16 @@ 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,
MOBILE_PREVIEW_CIRCLE_BACK_TEST_TAG,
).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()
Expand Down
6 changes: 3 additions & 3 deletions src/apps/mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />

<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenBitFun">
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />
<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.openbitfun.mobile.app.infrastructure.camera

import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
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

/** App-owned camera surface. Its lifetime ends when hidden by manual pairing. */
@androidx.annotation.OptIn(ExperimentalGetImage::class)
@Composable
internal fun InlineQrScanner(
modifier: Modifier,
paused: Boolean = false,
onCode: (String) -> Unit,
onReady: () -> Unit,
onPermissionDenied: () -> Unit,
onUnavailable: () -> Unit,
) {
val context = LocalContext.current
val owner = LocalLifecycleOwner.current
val ready by rememberUpdatedState(onReady)
val detected by rememberUpdatedState(onCode)
val denied by rememberUpdatedState(onPermissionDenied)
val unavailable by rememberUpdatedState(onUnavailable)
var granted by remember { mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
) }
DisposableEffect(owner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
}
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
granted = it
if (!it) denied()
}
LaunchedEffect(Unit) { if (!granted) permission.launch(Manifest.permission.CAMERA) }
val view = remember(context) { PreviewView(context).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
} }
AndroidView(factory = { view }, modifier = modifier)
if (granted && !paused) {
DisposableEffect(view, owner) {
val executor = ContextCompat.getMainExecutor(context)
val future = ProcessCameraProvider.getInstance(context)
val scanner = BarcodeScanning.getClient(BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE).build())
val preview = Preview.Builder().build().also { it.setSurfaceProvider(view.surfaceProvider) }
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build()
var disposed = false
var delivered = false
var failed = false
var provider: ProcessCameraProvider? = null
analysis.setAnalyzer(executor) { proxy ->
val image = proxy.image
if (disposed || delivered || failed || image == null) {
proxy.close()
} else {
scanner.process(InputImage.fromMediaImage(image, proxy.imageInfo.rotationDegrees))
.addOnSuccessListener(executor) { codes ->
val value = codes.firstNotNullOfOrNull { it.rawValue?.takeIf(String::isNotBlank) }
if (!disposed && !delivered && value != null) {
delivered = true
detected(value)
}
}
.addOnFailureListener(executor) {
if (!disposed && !failed) { failed = true; unavailable() }
}
.addOnCompleteListener { proxy.close() }
}
}
future.addListener({
if (!disposed) {
try {
provider = future.get()
provider!!.bindToLifecycle(owner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
ready()
} catch (_: Exception) {
if (!failed) { failed = true; unavailable() }
}
}
}, executor)
onDispose {
disposed = true
analysis.clearAnalyzer()
provider?.unbind(preview, analysis)
scanner.close()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ internal class AppShellState(
remoteSessionId: String? = null,
remoteCreating: Boolean = false,
remoteScanRequested: Boolean = false,
remoteConnectOpen: Boolean = false,
) {
internal var surface: MobileSurface by mutableStateOf(surface)
private set
Expand Down Expand Up @@ -85,24 +86,35 @@ internal class AppShellState(
internal var remoteScanRequested: Boolean by mutableStateOf(remoteScanRequested)
private set

internal var remoteConnectOpen: Boolean by mutableStateOf(remoteConnectOpen)
private set

internal fun closeRemoteConnect() {
remoteConnectOpen = false
remoteScanRequested = false
}

internal fun show(next: MobileSurface) {
surface = next
}

internal fun openRemoteSession(sessionId: String) {
closeRemoteConnect()
surface = MobileSurface.REMOTE
remoteCreating = false
remoteSessionId = sessionId
}

internal fun createRemoteSession() {
closeRemoteConnect()
surface = MobileSurface.REMOTE
remoteScanRequested = false
remoteCreating = true
remoteSessionId = null
}

internal fun closeRemoteSession() {
closeRemoteConnect()
remoteCreating = false
remoteSessionId = null
}
Expand All @@ -115,13 +127,15 @@ internal class AppShellState(
* [openRemoteScanner] remains for entry points whose whole job is to scan.
*/
internal fun openRemoteConnect() {
remoteConnectOpen = true
surface = MobileSurface.REMOTE
remoteCreating = false
remoteSessionId = null
remoteScanRequested = false
}

internal fun openRemoteScanner() {
remoteConnectOpen = true
surface = MobileSurface.REMOTE
remoteCreating = false
remoteSessionId = null
Expand Down Expand Up @@ -189,6 +203,7 @@ internal class AppShellState(
it.remoteSessionId,
it.remoteCreating,
it.remoteScanRequested,
it.remoteConnectOpen,
)
},
restore = {
Expand All @@ -203,6 +218,8 @@ internal class AppShellState(
remoteSessionId = it.getOrNull(7) as String?,
remoteCreating = it.getOrNull(8) as? Boolean ?: false,
remoteScanRequested = it.getOrNull(9) as? Boolean ?: false,
remoteConnectOpen = it.getOrNull(10) as? Boolean
?: (it.getOrNull(9) as? Boolean ?: false),
)
},
)
Expand Down
Loading