diff --git a/README.md b/README.md
index 1bae7b8c19..f677731d82 100644
--- a/README.md
+++ b/README.md
@@ -328,8 +328,9 @@ withdrawn before it reaches the host, the absence of any permission prompt, and
host failures.
`Scanner` (`scan`) opens the host's own QR and barcode viewfinder (RFC
-"Host-drawn scanner"). Both CLI host roles answer it from `TRUAPI_SCAN_TEXT`,
-or with a dismissal when it is unset, so the battery runs it. The playground's
+"Host-drawn scanner"). The Android app draws it. Both CLI host roles answer
+it from `TRUAPI_SCAN_TEXT`, or with a dismissal when it is unset, so the
+battery runs it. The playground's
Diagnosis skips it, since dot.li has no viewfinder and answers `Unsupported`.
To run the playground locally in a plain browser tab, against a signing host on
diff --git a/hosts/android/app/src/main/java/io/paritytech/polkadotapp/app/root/navigation/products/ProductsNavigator.kt b/hosts/android/app/src/main/java/io/paritytech/polkadotapp/app/root/navigation/products/ProductsNavigator.kt
index 71136fe16c..3c984066f8 100644
--- a/hosts/android/app/src/main/java/io/paritytech/polkadotapp/app/root/navigation/products/ProductsNavigator.kt
+++ b/hosts/android/app/src/main/java/io/paritytech/polkadotapp/app/root/navigation/products/ProductsNavigator.kt
@@ -106,6 +106,22 @@ class ProductsNavigator @Inject constructor(
performNavigation(R.id.action_global_to_truapiContactPickBottomSheet)
}
+ override suspend fun closeTrUAPIContactPick() = closeIfShowing(R.id.truapiContactPickBottomSheet)
+
+ override suspend fun openTrUAPIProductScan() = withContext(dispatchers.main) {
+ performNavigation(R.id.action_global_to_truapiProductScanBottomSheet)
+ }
+
+ override suspend fun closeTrUAPIProductScan() = closeIfShowing(R.id.truapiProductScanBottomSheet)
+
+ /**
+ * Closes the prompt only when it is on top, so a sheet over it stays. A prompt under another
+ * sheet closes itself when it is shown again, because its answer is already given.
+ */
+ private suspend fun closeIfShowing(destinationId: Int) = withContext(dispatchers.main) {
+ if (isCurrentDestination(destinationId)) back()
+ }
+
override fun openPocketAddCard(payload: PocketAddCardPayload) = performNavigation(
actionId = R.id.action_global_to_pocketAddCardBottomSheet,
args = payload.toPayloadBundle(),
diff --git a/hosts/android/app/src/main/res/navigation/root_nav_graph.xml b/hosts/android/app/src/main/res/navigation/root_nav_graph.xml
index 11f9109eec..73ca1f6b48 100644
--- a/hosts/android/app/src/main/res/navigation/root_nav_graph.xml
+++ b/hosts/android/app/src/main/res/navigation/root_nav_graph.xml
@@ -366,6 +366,14 @@
android:id="@+id/action_global_to_truapiContactPickBottomSheet"
app:destination="@id/truapiContactPickBottomSheet" />
+
+
+
+
diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/presentation/camera/QrCodeAnalyzer.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/presentation/camera/QrCodeAnalyzer.kt
index c34b664f34..9faae9ed88 100644
--- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/presentation/camera/QrCodeAnalyzer.kt
+++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/presentation/camera/QrCodeAnalyzer.kt
@@ -10,7 +10,15 @@ import com.google.mlkit.vision.barcode.ZoomSuggestionOptions
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
-class QrCodeAnalyzer(private val listener: (String) -> Unit) : ImageAnalysis.Analyzer, AutoCloseable {
+/** Decodes [formats] (ML Kit `Barcode.FORMAT_*`) and reports every code in a frame with its format. */
+class QrCodeAnalyzer(
+ private val formats: List,
+ private val listener: (codes: List>) -> Unit,
+) : ImageAnalysis.Analyzer, AutoCloseable {
+ constructor(listener: (String) -> Unit) : this(listOf(Barcode.FORMAT_QR_CODE), { codes ->
+ codes.firstOrNull()?.let { (_, text) -> listener(text) }
+ })
+
private var camera: Camera? = null
private var scanner: BarcodeScanner? = null
@@ -35,10 +43,8 @@ class QrCodeAnalyzer(private val listener: (String) -> Unit) : ImageAnalysis.Ana
scanner.process(image)
.addOnSuccessListener {
- val data = it.firstOrNull()?.rawValue
- if (data != null) {
- listener.invoke(data)
- }
+ val codes = it.mapNotNull { barcode -> barcode.rawValue?.let { text -> barcode.format to text } }
+ if (codes.isNotEmpty()) listener(codes)
}
.addOnCompleteListener {
imageProxy.close()
@@ -47,7 +53,7 @@ class QrCodeAnalyzer(private val listener: (String) -> Unit) : ImageAnalysis.Ana
private fun createScanner(): BarcodeScanner {
val optionsBuilder = BarcodeScannerOptions.Builder()
- .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
+ .setBarcodeFormats(formats.first(), *formats.drop(1).toIntArray())
zoomSuggestionOptions()?.let(optionsBuilder::setZoomSuggestionOptions)
diff --git a/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanning/compose/QrViewfinder.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/presentation/camera/compose/QrViewfinder.kt
similarity index 96%
rename from hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanning/compose/QrViewfinder.kt
rename to hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/presentation/camera/compose/QrViewfinder.kt
index 570a723654..3c3bc00f91 100644
--- a/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanning/compose/QrViewfinder.kt
+++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/presentation/camera/compose/QrViewfinder.kt
@@ -1,4 +1,4 @@
-package io.paritytech.polkadotapp.feature_scan_impl.presentation.scanning.compose
+package io.paritytech.polkadotapp.common.presentation.camera.compose
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.SurfaceRequest
@@ -25,13 +25,13 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import io.paritytech.polkadotapp.common.R as RCommon
-internal class PermissionMissingHint(
+class PermissionMissingHint(
val missing: StateFlow,
val content: @Composable (onRetry: () -> Unit) -> Unit,
)
@Composable
-internal fun QrViewfinder(
+fun QrViewfinder(
modifier: Modifier = Modifier,
surfaceRequestFlow: StateFlow,
invalidCodeEvent: SharedFlow,
diff --git a/hosts/android/feature/scan/impl/src/main/res/drawable/img_scanner_frame.png b/hosts/android/common/src/main/res/drawable/img_scanner_frame.png
similarity index 100%
rename from hosts/android/feature/scan/impl/src/main/res/drawable/img_scanner_frame.png
rename to hosts/android/common/src/main/res/drawable/img_scanner_frame.png
diff --git a/hosts/android/common/src/main/res/values/strings.xml b/hosts/android/common/src/main/res/values/strings.xml
index 2d18b8a5a7..e2f0f19dfc 100644
--- a/hosts/android/common/src/main/res/values/strings.xml
+++ b/hosts/android/common/src/main/res/values/strings.xml
@@ -1633,6 +1633,8 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc
%1$s learns only who you choose, and never your contact list.
Unnamed contact
Not now
+ Scan for %1$s
+ This code isn\'t for %1$s
Report app issue
What happened?
Tell us about the issue you encountered
diff --git a/hosts/android/feature/products/impl/build.gradle.kts b/hosts/android/feature/products/impl/build.gradle.kts
index 415da595a0..9a5c55f15f 100644
--- a/hosts/android/feature/products/impl/build.gradle.kts
+++ b/hosts/android/feature/products/impl/build.gradle.kts
@@ -71,6 +71,9 @@ dependencies {
implementation(libs.nova.substrate.serialization)
implementation(project(":bindings:truapi-host"))
+ // The app supplies ML Kit per flavor, as for :common.
+ compileOnly(libs.google.play.services.mlkit)
+ testImplementation(libs.google.play.services.mlkit)
implementation(project(":bindings:sr25519-vrf"))
implementation(project(":common"))
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/AppContactsHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/AppContactsHostBridge.kt
index 91c57f5c34..bdd33a59a0 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/AppContactsHostBridge.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/AppContactsHostBridge.kt
@@ -27,7 +27,7 @@ import javax.inject.Inject
*/
class AppContactsHostBridge @Inject constructor(
private val directory: ContactDirectory,
- private val pickLauncher: TrUAPIContactPickLauncher,
+ private val picks: TrUAPIContactPicks,
) : ContactsHostBridge {
private class HandleIndex(val handleKey: ByteArray, val accountsByHandle: Map)
@@ -87,7 +87,7 @@ class AppContactsHostBridge @Inject constructor(
if (options.isEmpty()) return HostContactPick.NoContacts
- val picked = pickLauncher.awaitPick(productId, options)
+ val picked = picks.ask(ContactPickRequest(productId, options))
?: return HostContactPick.Dismissed
return HostContactPick.Picked(picked.account)
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductScanFormats.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductScanFormats.kt
new file mode 100644
index 0000000000..7e8b3f0ff1
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductScanFormats.kt
@@ -0,0 +1,33 @@
+package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
+
+import com.google.mlkit.vision.barcode.common.Barcode
+import uniffi.truapi.CodeFormat
+
+/** Translates between the formats a product names and the ones ML Kit reads. */
+internal object ProductScanFormats {
+ private val mlKitFormats = mapOf(
+ CodeFormat.QR to listOf(Barcode.FORMAT_QR_CODE),
+ CodeFormat.AZTEC to listOf(Barcode.FORMAT_AZTEC),
+ CodeFormat.DATA_MATRIX to listOf(Barcode.FORMAT_DATA_MATRIX),
+ CodeFormat.PDF417 to listOf(Barcode.FORMAT_PDF417),
+ CodeFormat.EAN13 to listOf(Barcode.FORMAT_EAN_13, Barcode.FORMAT_UPC_A),
+ CodeFormat.EAN8 to listOf(Barcode.FORMAT_EAN_8),
+ CodeFormat.UPC_E to listOf(Barcode.FORMAT_UPC_E),
+ CodeFormat.CODE128 to listOf(Barcode.FORMAT_CODE_128),
+ CodeFormat.CODE39 to listOf(Barcode.FORMAT_CODE_39),
+ CodeFormat.CODE93 to listOf(Barcode.FORMAT_CODE_93),
+ CodeFormat.ITF to listOf(Barcode.FORMAT_ITF),
+ CodeFormat.CODABAR to listOf(Barcode.FORMAT_CODABAR),
+ )
+
+ fun mlKitFormats(formats: List): List = formats.flatMap(mlKitFormats::getValue)
+
+ /**
+ * The code as a product sees it, or null for a format no product can ask for. UPC-A is read
+ * as EAN-13 with a leading zero, which is how iOS reports it.
+ */
+ fun codeFor(mlKitFormat: Int, text: String): Pair? = when (mlKitFormat) {
+ Barcode.FORMAT_UPC_A -> CodeFormat.EAN13 to "0$text"
+ else -> mlKitFormats.entries.firstOrNull { mlKitFormat in it.value }?.let { it.key to text }
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIContactPick.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIContactPick.kt
index 47be9bf640..8959891584 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIContactPick.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIContactPick.kt
@@ -1,10 +1,6 @@
package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotManagement.ProductsRouter
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,87 +15,18 @@ class ContactPickOption(
val displayName: String?,
)
-/** One in-flight pick and the choice the core is waiting on. */
-class TrUAPIContactPickContext(
+/** What the contact picker shows: who is asking, and whom they may pick. */
+class ContactPickRequest(
val productId: String,
val options: List,
-) {
- private val choice = CompletableDeferred()
- private val shown = CompletableDeferred()
-
- /** Called by the sheet once it holds this context. */
- fun markShown() {
- shown.complete(Unit)
- }
-
- /** Whether the sheet appeared within [timeoutMs]. */
- suspend fun awaitShown(timeoutMs: Long): Boolean = withTimeoutOrNull(timeoutMs) { shown.await() } != null
-
- fun pick(option: ContactPickOption) {
- choice.complete(option)
- }
-
- /** Also the answer for a dismissed sheet, so an abandoned pick names nobody. */
- fun dismiss() {
- choice.complete(null)
- }
-
- suspend fun await(): ContactPickOption? = choice.await()
-}
-
-/**
- * Parks the in-flight pick for the sheet to pick up.
- *
- * Mirrors [TrUAPIConfirmationContextHolder], owner-guarded clear included: the
- * sheet's ViewModel is cleared after the dismiss animation, by which time the
- * holder may already carry the next request.
- */
-@Singleton
-class TrUAPIContactPickContextHolder @Inject constructor() {
- private var context: TrUAPIContactPickContext? = null
-
- fun set(context: TrUAPIContactPickContext) {
- this.context = context
- }
-
- fun get(): TrUAPIContactPickContext? = context
-
- fun clear(owner: TrUAPIContactPickContext) {
- if (context === owner) {
- context = null
- }
- }
-}
+)
-/** Opens the contact picker for a product and awaits the person the user chose. */
-// Singleton for the same reason the confirmation launcher is: the holder carries
-// one context, and every live tab's bridge shares it.
+/** Opens the contact picker for a product and waits for the person the user chose. */
@Singleton
-class TrUAPIContactPickLauncher @Inject constructor(
- private val holder: TrUAPIContactPickContextHolder,
+class TrUAPIContactPicks @Inject constructor(
private val productsRouter: ProductsRouter,
-) {
- private val oneAtATime = Mutex()
-
- suspend fun awaitPick(
- productId: String,
- options: List,
- ): ContactPickOption? = oneAtATime.withLock {
- val context = TrUAPIContactPickContext(productId, options)
- holder.set(context)
- productsRouter.openTrUAPIContactPick()
- // Navigation can fail silently (backgrounded, no controller, another
- // sheet on top), and then no ViewModel ever answers. Waiting on that
- // would hold the lock, and every later pick, forever. The context stays
- // in the holder so a sheet that does turn up late still finds one.
- if (!context.awaitShown(SHEET_SHOWN_TIMEOUT_MS)) {
- context.dismiss()
- return@withLock null
- }
- context.await()
- }
+) : TrUAPIPrompts(unanswered = null) {
+ override suspend fun open() = productsRouter.openTrUAPIContactPick()
- private companion object {
- const val SHEET_SHOWN_TIMEOUT_MS = 10_000L
- }
+ override suspend fun close() = productsRouter.closeTrUAPIContactPick()
}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt
index cf62665067..457281fdb7 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt
@@ -73,6 +73,7 @@ class TrUAPIHostRuntimeProvider @Inject constructor(
private val confirmationLauncher: TrUAPIConfirmationLauncher,
private val appLifecycleObserver: AppLifecycleObserver,
private val contactsBridge: AppContactsHostBridge,
+ private val scannerBridge: TrUAPIProductScans,
// Lazy: the supervisor boots workers on this runtime, and reports back through this bridge.
private val workerSupervisor: Lazy,
dispatchers: CoroutineDispatchers,
@@ -121,6 +122,7 @@ class TrUAPIHostRuntimeProvider @Inject constructor(
// Before any product execution opens, so a product never sees the
// window where the host lists no contacts.
runtime.setContacts(contactsBridge)
+ runtime.setScanner(scannerBridge)
observeContactRemovals(runtime)
chainProvider.attach(
onResponse = runtime::notifyChainResponse,
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIProductScan.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIProductScan.kt
new file mode 100644
index 0000000000..b1c980ebd1
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIProductScan.kt
@@ -0,0 +1,66 @@
+package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
+
+import androidx.lifecycle.Lifecycle
+import io.parity.truapi.ScannerHostBridge
+import io.paritytech.polkadotapp.common.presentation.resources.ContextManager
+import io.paritytech.polkadotapp.common.utils.CoroutineDispatchers
+import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotManagement.ProductsRouter
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import uniffi.truapi.HostScan
+import uniffi.truapi.HostScannerScanRequest
+import uniffi.truapi.ProductExecutionKind
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+/** What the viewfinder shows: who is asking, and what they accept. */
+class ProductScanRequest(
+ val productId: String,
+ val executionKind: ProductExecutionKind,
+ val request: HostScannerScanRequest,
+)
+
+/**
+ * Serves `scanner.scan`: opens the viewfinder for a product the user can see and waits for the
+ * code they scanned. A Worker has no page, so the app must be in front. The core already checked
+ * that its user tapped its card.
+ */
+@Singleton
+class TrUAPIProductScans @Inject constructor(
+ private val productsRouter: ProductsRouter,
+ private val visibleProducts: VisibleProducts,
+ private val contextManager: ContextManager,
+ private val dispatchers: CoroutineDispatchers,
+) : TrUAPIPrompts(unanswered = HostScan.Dismissed, notShown = HostScan.NotVisible),
+ ScannerHostBridge {
+ override suspend fun scanCode(
+ productId: String,
+ executionKind: ProductExecutionKind,
+ request: HostScannerScanRequest,
+ ): HostScan = ask(ProductScanRequest(productId, executionKind, request))
+
+ override suspend fun canShow(question: ProductScanRequest): Boolean = withContext(dispatchers.main) {
+ when (question.executionKind) {
+ ProductExecutionKind.WORKER ->
+ contextManager.getActivity()?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true
+ ProductExecutionKind.APP, ProductExecutionKind.WIDGET -> pageOnScreen(question.productId)
+ }
+ }
+
+ override suspend fun open() = productsRouter.openTrUAPIProductScan()
+
+ override suspend fun close() = productsRouter.closeTrUAPIProductScan()
+
+ // A host screen closing over the page, such as the last scanner, holds focus for a moment.
+ private suspend fun pageOnScreen(productId: String) = withTimeoutOrNull(FOCUS_RETURN_TIMEOUT) {
+ while (!visibleProducts.isOnScreen(productId)) delay(FOCUS_POLL_INTERVAL)
+ } != null
+
+ private companion object {
+ val FOCUS_RETURN_TIMEOUT = 1.seconds
+ val FOCUS_POLL_INTERVAL = 50.milliseconds
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIPrompts.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIPrompts.kt
new file mode 100644
index 0000000000..be81c1eb12
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIPrompts.kt
@@ -0,0 +1,76 @@
+package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+
+/** One host screen a product is waiting on: what it shows, and the answer it gives. */
+class TrUAPIPrompt(val question: Q, private val unanswered: A) {
+ private val answer = CompletableDeferred()
+ private val shown = CompletableDeferred()
+
+ /** Called by the screen once it holds this prompt. */
+ fun markShown() {
+ shown.complete(Unit)
+ }
+
+ /** The first answer wins. */
+ fun answer(value: A) {
+ answer.complete(value)
+ }
+
+ /** Also the answer for a screen that goes away unanswered. */
+ fun dismiss() = answer(unanswered)
+
+ /** True once the core has its answer. */
+ val isAnswered get() = answer.isCompleted
+
+ internal suspend fun awaitShown(timeoutMs: Long) = withTimeoutOrNull(timeoutMs) { shown.await() } != null
+
+ internal suspend fun await(): A = answer.await()
+}
+
+/**
+ * Shows one kind of host screen at a time and waits for its answer. The screen
+ * reads what to show from [current].
+ */
+abstract class TrUAPIPrompts(private val unanswered: A, private val notShown: A = unanswered) {
+ private val oneAtATime = Mutex()
+
+ /**
+ * The prompt the screen shows. A screen finding none (restored in a new process) or an
+ * answered one (shown after its prompt ended) has nothing to ask, and closes itself.
+ */
+ @Volatile
+ var current: TrUAPIPrompt? = null
+ private set
+
+ protected abstract suspend fun open()
+
+ protected abstract suspend fun close()
+
+ /** Whether the screen may open for [question], once the one before it has closed. */
+ protected open suspend fun canShow(question: Q): Boolean = true
+
+ suspend fun ask(question: Q): A = oneAtATime.withLock {
+ if (!canShow(question)) return@withLock notShown
+ val prompt = TrUAPIPrompt(question, unanswered).also { current = it }
+ try {
+ open()
+ // Navigation can fail silently, and then no screen would ever answer.
+ check(prompt.awaitShown(SHOWN_TIMEOUT_MS)) { "the host screen did not appear" }
+ prompt.await()
+ } catch (failure: Throwable) {
+ prompt.dismiss()
+ withContext(NonCancellable) { close() }
+ throw failure
+ }
+ }
+
+ private companion object {
+ const val SHOWN_TIMEOUT_MS = 10_000L
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt
index 64519382de..2b7366250c 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPISessionStarter.kt
@@ -26,6 +26,7 @@ class TrUAPISessionStarter @Inject constructor(
private val chainDirectory: TrUAPIChainDirectory,
private val dotNsTldProvider: DotNsTldProvider,
private val bootstrapInstaller: TrUAPIBootstrapInstaller,
+ private val visibleProducts: VisibleProducts,
) {
fun start(
provider: BrowserWebViewProvider,
@@ -62,6 +63,7 @@ class TrUAPISessionStarter @Inject constructor(
}
}
+ provider.addWebViewSetup { webView -> visibleProducts.track(productId.value, webView) }
val runtime = runtimeProvider.runtime().getOrElse { return Result.failure(it) }
// The bootstrap publishes the loopback port and its bearer token, so it goes to the
// product's own origin only. A wildcard would hand the bridge endpoint to any page the
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/VisibleProducts.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/VisibleProducts.kt
new file mode 100644
index 0000000000..96053c4754
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/VisibleProducts.kt
@@ -0,0 +1,27 @@
+package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
+
+import android.view.View
+import java.lang.ref.WeakReference
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Which products the user is looking at: those with a page shown in the focused window. A tab in
+ * the background, a page under a host sheet, or an app in the background is not on screen.
+ */
+@Singleton
+class VisibleProducts @Inject constructor() {
+ private val pages = mutableListOf>>()
+
+ fun track(productId: String, page: View) = synchronized(pages) {
+ pages.removeAll { it.second.get() == null }
+ pages += productId to WeakReference(page)
+ }
+
+ /** Reads view state, so call it on the main thread. */
+ fun isOnScreen(productId: String): Boolean = synchronized(pages) {
+ pages.any { (id, page) ->
+ id == productId && page.get()?.let { it.isAttachedToWindow && it.isShown && it.hasWindowFocus() } == true
+ }
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productBotManagement/ProductsRouter.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productBotManagement/ProductsRouter.kt
index 9765b6b705..9b5d4a62f8 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productBotManagement/ProductsRouter.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productBotManagement/ProductsRouter.kt
@@ -27,6 +27,12 @@ interface ProductsRouter : ReturnableRouter, SigningRouter {
/** Picker for the one contact a product asked the user to name. */
suspend fun openTrUAPIContactPick()
+ suspend fun closeTrUAPIContactPick()
+
+ /** Viewfinder for the one code a product asked the user to scan. */
+ suspend fun openTrUAPIProductScan()
+ suspend fun closeTrUAPIProductScan()
+
fun openProductSettings(productId: ProductId)
fun openProductPermissions(productId: ProductId)
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickContract.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickContract.kt
index 8a2390753b..6a8f38cf93 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickContract.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickContract.kt
@@ -10,6 +10,8 @@ interface TrUAPIContactPickContract {
fun onContactClicked(index: Int)
fun onDismissClicked()
+
+ fun onShown()
}
data class TrUAPIContactPickUiState(
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickViewModel.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickViewModel.kt
index f5e8c1f2f7..70cd038dd5 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickViewModel.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/TrUAPIContactPickViewModel.kt
@@ -6,8 +6,7 @@ import io.paritytech.polkadotapp.common.presentation.screens.BaseViewModel
import io.paritytech.polkadotapp.common.utils.inBackground
import io.paritytech.polkadotapp.common.utils.launchUnit
import io.paritytech.polkadotapp.common.utils.withLoading
-import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIContactPickContext
-import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIContactPickContextHolder
+import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIContactPicks
import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotManagement.ProductsRouter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,13 +19,17 @@ import javax.inject.Inject
@HiltViewModel
class TrUAPIContactPickViewModel @Inject constructor(
private val router: ProductsRouter,
- private val context: TrUAPIContactPickContext,
- private val holder: TrUAPIContactPickContextHolder,
+ picks: TrUAPIContactPicks,
) : BaseViewModel(), TrUAPIContactPickContract {
+ private val prompt = picks.current?.takeUnless { it.isAnswered }
private val choosing = MutableStateFlow(false)
init {
- context.markShown()
+ prompt?.markShown()
+ }
+
+ override fun onShown() {
+ if (prompt?.isAnswered != false) launchUnit { router.closeTrUAPIContactPick() }
}
override val state: StateFlow> =
@@ -36,30 +39,30 @@ class TrUAPIContactPickViewModel @Inject constructor(
.stateIn(this, SharingStarted.Eagerly, LoadingState.Loading)
override fun onContactClicked(index: Int) = answer {
- context.options.getOrNull(index)?.let(context::pick) ?: context.dismiss()
+ prompt?.let { it.answer(it.question.options.getOrNull(index)) }
}
- override fun onDismissClicked() = answer { context.dismiss() }
+ override fun onDismissClicked() = answer { prompt?.dismiss() }
override fun onCleared() {
// The core is still blocked if the sheet went away unanswered, so a
// dismissal has to resolve as naming nobody.
- context.dismiss()
- holder.clear(context)
+ prompt?.dismiss()
super.onCleared()
}
private fun toUiState() = TrUAPIContactPickUiState(
- productId = context.productId,
- contacts = context.options.mapIndexed { index, option ->
+ productId = prompt?.question?.productId.orEmpty(),
+ contacts = prompt?.question?.options.orEmpty().mapIndexed { index, option ->
TrUAPIContactPickRow(index = index, name = option.displayName)
}.toImmutableList(),
)
private fun answer(choose: () -> Unit) = launchUnit {
- if (choosing.value) return@launchUnit
- choosing.value = true
- choose()
- router.back()
+ if (!choosing.value) {
+ choosing.value = true
+ choose()
+ }
+ router.closeTrUAPIContactPick()
}
}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/compose/TrUAPIContactPickScreen.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/compose/TrUAPIContactPickScreen.kt
index 60fdf973bd..b61ae29612 100644
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/compose/TrUAPIContactPickScreen.kt
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/compose/TrUAPIContactPickScreen.kt
@@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.paritytech.polkadotapp.common.presentation.loading.onLoaded
import io.paritytech.polkadotapp.design.components.bottomsheet.NovaBottomSheetSurface
@@ -27,6 +29,7 @@ import io.paritytech.polkadotapp.common.R as RCommon
@Composable
fun TrUAPIContactPickScreen(contract: TrUAPIContactPickContract) {
val state by contract.state.collectAsStateWithLifecycle()
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { contract.onShown() }
state.onLoaded { data ->
TrUAPIContactPickScreenInternal(
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/di/TrUAPIContactPickModule.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/di/TrUAPIContactPickModule.kt
deleted file mode 100644
index fa5dcea6dd..0000000000
--- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiContactPick/di/TrUAPIContactPickModule.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package io.paritytech.polkadotapp.feature_products_impl.presentation.truapiContactPick.di
-
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.components.ViewModelComponent
-import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIContactPickContext
-import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIContactPickContextHolder
-
-@Module
-@InstallIn(ViewModelComponent::class)
-class TrUAPIContactPickModule {
- @Provides
- fun provideTrUAPIContactPickContext(
- holder: TrUAPIContactPickContextHolder,
- ): TrUAPIContactPickContext {
- return requireNotNull(holder.get()) {
- "TrUAPIContactPickContext is not set."
- }
- }
-}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanBottomSheet.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanBottomSheet.kt
new file mode 100644
index 0000000000..9db6bdbf9c
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanBottomSheet.kt
@@ -0,0 +1,29 @@
+package io.paritytech.polkadotapp.feature_products_impl.presentation.truapiProductScan
+
+import android.view.View
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import androidx.compose.runtime.Composable
+import androidx.fragment.app.viewModels
+import dagger.hilt.android.AndroidEntryPoint
+import io.paritytech.polkadotapp.common.presentation.screens.BaseComposeBottomSheet
+import io.paritytech.polkadotapp.feature_products_impl.presentation.truapiProductScan.compose.TrUAPIProductScanScreen
+
+/**
+ * A full-height sheet rather than a screen: opening a screen would close any sheet under it,
+ * including the product sheet whose product asked to scan.
+ */
+@AndroidEntryPoint
+class TrUAPIProductScanBottomSheet : BaseComposeBottomSheet() {
+ override val viewModel: TrUAPIProductScanViewModel by viewModels()
+
+ @Composable
+ override fun Screen() = TrUAPIProductScanScreen(viewModel)
+
+ override fun onStart() {
+ super.onStart()
+ bottomSheetBehavior?.isDraggable = false
+ val content = view ?: return
+ content.layoutParams.height = MATCH_PARENT
+ (content.parent as? View)?.layoutParams?.height = MATCH_PARENT
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanViewModel.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanViewModel.kt
new file mode 100644
index 0000000000..653051638b
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanViewModel.kt
@@ -0,0 +1,101 @@
+package io.paritytech.polkadotapp.feature_products_impl.presentation.truapiProductScan
+
+import android.Manifest
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceRequest
+import androidx.lifecycle.LifecycleOwner
+import dagger.hilt.android.lifecycle.HiltViewModel
+import io.paritytech.polkadotapp.common.presentation.camera.CameraQrReader
+import io.paritytech.polkadotapp.common.presentation.camera.QrCodeAnalyzer
+import io.paritytech.polkadotapp.common.presentation.screens.BaseViewModel
+import io.paritytech.polkadotapp.common.utils.launchUnit
+import io.paritytech.polkadotapp.common.utils.permissions.PermissionAsker
+import io.paritytech.polkadotapp.common.utils.permissions.PermissionResult
+import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.ProductScanFormats
+import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIProductScans
+import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotManagement.ProductsRouter
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import uniffi.truapi.HostScan
+import uniffi.truapi.ScanFilter
+import uniffi.truapi.ScanVerdict
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+
+@HiltViewModel
+class TrUAPIProductScanViewModel @Inject constructor(
+ private val router: ProductsRouter,
+ scans: TrUAPIProductScans,
+ private val permissionAsker: PermissionAsker,
+ private val cameraQrReader: CameraQrReader,
+) : BaseViewModel() {
+ private val prompt = scans.current?.takeUnless { it.isAnswered }
+ private val filter = prompt?.let { ScanFilter(it.question.request) }
+ private val answered = AtomicBoolean(prompt == null)
+
+ val productId = prompt?.question?.productId.orEmpty()
+ val hint = prompt?.question?.request?.hint
+ val surfaceRequest = MutableStateFlow(null)
+ val cameraPermissionDenied = MutableStateFlow(false)
+ val notForThisProduct = MutableSharedFlow(extraBufferCapacity = 1)
+
+ init {
+ prompt?.markShown()
+ }
+
+ fun onShown() {
+ if (prompt?.isAnswered != false) close()
+ }
+
+ suspend fun bindToCamera(lifecycleOwner: LifecycleOwner) {
+ val prompt = prompt ?: return
+ when (permissionAsker.askPermission(Manifest.permission.CAMERA)) {
+ PermissionResult.GRANTED -> Unit
+ // Android asks again next time, so there is nothing to explain.
+ PermissionResult.DENIED -> return answer(HostScan.CameraUnavailable)
+ // The dialog tells the user how to turn the camera on. Closing it answers.
+ PermissionResult.DENIED_FOREVER -> {
+ cameraPermissionDenied.value = true
+ return
+ }
+ }
+ val formats = ProductScanFormats.mlKitFormats(prompt.question.request.formats)
+ try {
+ cameraQrReader.bind(
+ preview = Preview.Builder().build().apply { setSurfaceProvider { surfaceRequest.value = it } },
+ lifecycleOwner = lifecycleOwner,
+ qrCodeAnalyzer = QrCodeAnalyzer(formats) { codes -> codes.forEach { (format, text) -> onCode(format, text) } },
+ )
+ } finally {
+ surfaceRequest.value = null
+ }
+ }
+
+ fun onCode(mlKitFormat: Int, text: String) {
+ val filter = filter?.takeUnless { answered.get() } ?: return
+ val (format, code) = ProductScanFormats.codeFor(mlKitFormat, text) ?: return
+ when (filter.observe(format, code)) {
+ ScanVerdict.ACCEPT -> answer(HostScan.Scanned(code, format))
+ ScanVerdict.NOT_FOR_THIS_PRODUCT -> notForThisProduct.tryEmit(Unit)
+ ScanVerdict.IGNORE -> Unit
+ }
+ }
+
+ fun onPermissionAlertClosed() = answer(HostScan.CameraUnavailable)
+
+ fun onCloseClicked() = answer(HostScan.Dismissed)
+
+ override fun onCleared() {
+ answered.set(true)
+ prompt?.dismiss()
+ filter?.close()
+ super.onCleared()
+ }
+
+ private fun answer(scan: HostScan) {
+ if (answered.compareAndSet(false, true)) prompt?.answer(scan)
+ close()
+ }
+
+ private fun close() = launchUnit { router.closeTrUAPIProductScan() }
+}
diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/compose/TrUAPIProductScanScreen.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/compose/TrUAPIProductScanScreen.kt
new file mode 100644
index 0000000000..c034cf3010
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/compose/TrUAPIProductScanScreen.kt
@@ -0,0 +1,126 @@
+package io.paritytech.polkadotapp.feature_products_impl.presentation.truapiProductScan.compose
+
+import androidx.activity.compose.BackHandler
+import androidx.camera.viewfinder.core.ImplementationMode
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import io.paritytech.polkadotapp.common.presentation.camera.compose.QrViewfinder
+import io.paritytech.polkadotapp.design.components.icon.NovaIcon
+import io.paritytech.polkadotapp.design.components.icon.NovaIcons
+import io.paritytech.polkadotapp.design.components.icon.vectors.Close
+import io.paritytech.polkadotapp.design.components.text.NovaText
+import io.paritytech.polkadotapp.design.theme.PolkadotTheme
+import io.paritytech.polkadotapp.feature_products_impl.presentation.truapiProductScan.TrUAPIProductScanViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import io.paritytech.polkadotapp.common.R as RCommon
+
+private const val NOT_FOR_THIS_PRODUCT_SHOWN_MS = 2_000L
+
+@Composable
+fun TrUAPIProductScanScreen(viewModel: TrUAPIProductScanViewModel) {
+ BackHandler(onBack = viewModel::onCloseClicked)
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onShown() }
+
+ var notForThisProduct by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ viewModel.notForThisProduct.collect {
+ notForThisProduct = true
+ delay(NOT_FOR_THIS_PRODUCT_SHOWN_MS)
+ notForThisProduct = false
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
+ QrViewfinder(
+ modifier = Modifier.fillMaxSize(),
+ surfaceRequestFlow = viewModel.surfaceRequest,
+ invalidCodeEvent = remember { MutableSharedFlow() },
+ cameraPermissionDeniedFlow = viewModel.cameraPermissionDenied,
+ cameraActive = true,
+ bindToCamera = viewModel::bindToCamera,
+ onInvalidCodeAlertClosed = {},
+ onPermissionAlertClosed = viewModel::onPermissionAlertClosed,
+ implementationMode = ImplementationMode.EXTERNAL,
+ permissionMissingHint = null,
+ )
+
+ Image(
+ modifier = Modifier.fillMaxSize(),
+ painter = painterResource(RCommon.drawable.img_scanner_frame),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ )
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ .systemBarsPadding()
+ .padding(horizontal = PolkadotTheme.spacings.extraLargeIncreased),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(PolkadotTheme.spacings.small),
+ ) {
+ NovaText(
+ text = stringResource(RCommon.string.truapi_product_scan_title, viewModel.productId),
+ color = PolkadotTheme.colors.fg.staticWhite,
+ style = PolkadotTheme.typography.title.medium,
+ textAlign = TextAlign.Center,
+ )
+ viewModel.hint?.let { hint ->
+ NovaText(
+ text = hint,
+ color = PolkadotTheme.colors.fg.staticWhite,
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ if (notForThisProduct) {
+ NovaText(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .systemBarsPadding()
+ .padding(PolkadotTheme.spacings.extraLargeIncreased),
+ text = stringResource(RCommon.string.truapi_product_scan_not_for_product, viewModel.productId),
+ color = PolkadotTheme.colors.fg.staticWhite,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ IconButton(
+ modifier = Modifier
+ .systemBarsPadding()
+ .padding(horizontal = PolkadotTheme.spacings.small, vertical = PolkadotTheme.spacings.smallIncreased),
+ onClick = viewModel::onCloseClicked,
+ ) {
+ NovaIcon(imageVector = NovaIcons.Close, tint = PolkadotTheme.colors.fg.staticWhite)
+ }
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductScanFormatsTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductScanFormatsTest.kt
new file mode 100644
index 0000000000..0e9cfb2827
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductScanFormatsTest.kt
@@ -0,0 +1,26 @@
+package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
+
+import com.google.mlkit.vision.barcode.common.Barcode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import uniffi.truapi.CodeFormat
+
+class ProductScanFormatsTest {
+ @Test
+ fun `every format a product can ask for maps to ML Kit and back`() {
+ for (format in CodeFormat.entries) {
+ val read = ProductScanFormats.mlKitFormats(listOf(format)).map { ProductScanFormats.codeFor(it, "1") }
+ assertEquals("$format", read.map { it?.first }.distinct(), listOf(format))
+ }
+ assertNull(ProductScanFormats.codeFor(Barcode.FORMAT_UNKNOWN, "1"))
+ }
+
+ @Test
+ fun `UPC-A is read as EAN-13 with a leading zero, as on iOS`() {
+ assertEquals(
+ CodeFormat.EAN13 to "0012345678905",
+ ProductScanFormats.codeFor(Barcode.FORMAT_UPC_A, "012345678905"),
+ )
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt
index 448fbaa427..202a1041e1 100644
--- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt
+++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt
@@ -58,6 +58,7 @@ class TrUAPIHostRuntimeProviderTest {
confirmationLauncher = mockk(relaxed = true),
appLifecycleObserver = mockk { every { subscribe() } returns emptyFlow() },
contactsBridge = mockk { every { contactRemovals() } returns emptyFlow() },
+ scannerBridge = mockk(),
workerSupervisor = { mockk(relaxed = true) },
dispatchers = testDispatchers(),
)
diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIProductScansTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIProductScansTest.kt
new file mode 100644
index 0000000000..4db74e927c
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIProductScansTest.kt
@@ -0,0 +1,86 @@
+package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
+
+import androidx.activity.ComponentActivity
+import androidx.lifecycle.Lifecycle
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.paritytech.polkadotapp.common.presentation.resources.ContextManager
+import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotManagement.ProductsRouter
+import io.paritytech.polkadotapp.test_shared.TestCoroutineDispatchers
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import uniffi.truapi.CodeFormat
+import uniffi.truapi.HostScan
+import uniffi.truapi.HostScannerScanRequest
+import uniffi.truapi.ProductExecutionKind
+
+class TrUAPIProductScansTest {
+ private val request = HostScannerScanRequest(listOf(CodeFormat.QR), null, null)
+ private val router = mockk(relaxUnitFun = true)
+ private val visible = mockk { every { isOnScreen(any()) } returns false }
+ private val contextManager = mockk { every { getActivity() } returns null }
+
+ private fun TestScope.scansOnTestClock() =
+ TrUAPIProductScans(router, visible, contextManager, TestCoroutineDispatchers(UnconfinedTestDispatcher(testScheduler)))
+
+ private suspend fun TrUAPIProductScans.scan(kind: ProductExecutionKind) = scanCode("greenmarket.dot", kind, request)
+
+ private fun appInFront() {
+ val resumed = mockk { every { lifecycle.currentState } returns Lifecycle.State.RESUMED }
+ every { contextManager.getActivity() } returns resumed
+ }
+
+ @Test
+ fun `a product the user cannot see gets NotVisible and no viewfinder`() = runTest {
+ val scans = scansOnTestClock()
+ assertEquals(HostScan.NotVisible, scans.scan(ProductExecutionKind.APP))
+ assertEquals(HostScan.NotVisible, scans.scan(ProductExecutionKind.WORKER))
+
+ appInFront()
+ assertEquals(HostScan.NotVisible, scans.scan(ProductExecutionKind.APP))
+ coVerify(exactly = 0) { router.openTrUAPIProductScan() }
+ }
+
+ @Test
+ fun `a scan waiting behind another is checked when its turn comes`() = runTest {
+ // By then the user may have left the page that asked.
+ val scans = scansOnTestClock()
+ appInFront()
+ every { visible.isOnScreen("greenmarket.dot") } returns true
+ val first = async(start = CoroutineStart.UNDISPATCHED) { scans.scan(ProductExecutionKind.WORKER) }
+ val firstPrompt = checkNotNull(scans.current).also { it.markShown() }
+ val waiting = async(start = CoroutineStart.UNDISPATCHED) { scans.scan(ProductExecutionKind.APP) }
+
+ every { visible.isOnScreen("greenmarket.dot") } returns false
+ firstPrompt.answer(HostScan.Dismissed)
+
+ assertEquals(HostScan.Dismissed, first.await())
+ assertEquals(HostScan.NotVisible, waiting.await())
+ coVerify(exactly = 1) { router.openTrUAPIProductScan() }
+ }
+
+ @Test
+ fun `a page getting focus back from the closing scanner can scan again`() = runTest {
+ // The product hears the last answer before the closing sheet hands focus back to its page.
+ val scans = scansOnTestClock()
+ every { visible.isOnScreen("greenmarket.dot") } returnsMany listOf(false, false, true)
+ val scanned = async(start = CoroutineStart.UNDISPATCHED) { scans.scan(ProductExecutionKind.APP) }
+
+ advanceTimeBy(200)
+ runCurrent()
+ checkNotNull(scans.current).apply {
+ markShown()
+ answer(HostScan.Dismissed)
+ }
+
+ assertEquals(HostScan.Dismissed, scanned.await())
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIPromptsTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIPromptsTest.kt
new file mode 100644
index 0000000000..0ef8c10f78
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIPromptsTest.kt
@@ -0,0 +1,64 @@
+package io.paritytech.polkadotapp.feature_products_impl.domain.truapi
+
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class TrUAPIPromptsTest {
+ /** Records what the prompt asked the navigation to do. */
+ private class RecordingPrompts : TrUAPIPrompts(unanswered = "dismissed") {
+ val events = mutableListOf()
+ override suspend fun open() {
+ events += "open"
+ }
+ override suspend fun close() {
+ events += "close"
+ }
+ }
+
+ private fun RecordingPrompts.shown() = checkNotNull(current).also { it.markShown() }
+
+ @Test
+ fun `the screen's first answer is the one the core gets`() = runTest {
+ val prompts = RecordingPrompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("question") }
+ val prompt = prompts.shown()
+ prompt.answer("picked")
+ prompt.answer("too late")
+
+ assertEquals("picked", asked.await())
+ assertEquals(listOf("open"), prompts.events)
+ }
+
+ @Test
+ fun `a screen that never appears fails the call instead of holding the core`() = runTest {
+ // The user dismissed nothing, so the product hears of a host failure.
+ val prompts = RecordingPrompts()
+
+ assertTrue(runCatching { prompts.ask("question") }.isFailure)
+ assertEquals(listOf("open", "close"), prompts.events)
+ }
+
+ @Test
+ fun `a cancelled prompt closes its screen and lets the next one open`() = runTest {
+ // The product withdrew the call, so nothing may stay on screen for it.
+ val prompts = RecordingPrompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("first") }
+ prompts.shown()
+ asked.cancel()
+ advanceUntilIdle()
+
+ val next = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("second") }
+ prompts.shown().answer("picked")
+
+ assertEquals("picked", next.await())
+ assertEquals(listOf("open", "close", "open"), prompts.events)
+ assertTrue(asked.isCancelled)
+ }
+}
diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanViewModelTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanViewModelTest.kt
new file mode 100644
index 0000000000..2a236e107b
--- /dev/null
+++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/presentation/truapiProductScan/TrUAPIProductScanViewModelTest.kt
@@ -0,0 +1,73 @@
+package io.paritytech.polkadotapp.feature_products_impl.presentation.truapiProductScan
+
+import com.google.mlkit.vision.barcode.common.Barcode
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.ProductScanRequest
+import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIProductScans
+import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIPrompt
+import io.paritytech.polkadotapp.feature_products_impl.presentation.productBotManagement.ProductsRouter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import uniffi.truapi.CodeFormat
+import uniffi.truapi.HostScan
+import uniffi.truapi.HostScannerScanRequest
+import uniffi.truapi.ProductExecutionKind
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class TrUAPIProductScanViewModelTest {
+ private val router = mockk(relaxed = true)
+ private val receipt = "https://greenmarket.example/r/BAG6"
+
+ @Before
+ fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
+
+ @After
+ fun tearDown() = Dispatchers.resetMain()
+
+ private fun scan(): Pair, TrUAPIProductScanViewModel> {
+ val request = HostScannerScanRequest(listOf(CodeFormat.QR), "https://greenmarket.example/r/", null)
+ val prompt = TrUAPIPrompt(ProductScanRequest("greenmarket.dot", ProductExecutionKind.APP, request), HostScan.Dismissed as HostScan)
+ val scans = mockk { every { current } returns prompt }
+ return prompt to TrUAPIProductScanViewModel(router, scans, mockk(), mockk())
+ }
+
+ @Test
+ fun `a wrong code only shows the message and the matching one answers once`() = runTest {
+ // The camera keeps reading after a match, and the product gets one answer.
+ val (prompt, viewModel) = scan()
+ val messages = mutableListOf()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.notForThisProduct.collect(messages::add)
+ }
+
+ viewModel.onCode(Barcode.FORMAT_QR_CODE, "https://elsewhere.example")
+ viewModel.onCode(Barcode.FORMAT_QR_CODE, receipt)
+ viewModel.onCode(Barcode.FORMAT_QR_CODE, receipt)
+
+ assertEquals(1, messages.size)
+ assertEquals(HostScan.Scanned(receipt, CodeFormat.QR), prompt.await())
+ coVerify(exactly = 1) { router.closeTrUAPIProductScan() }
+ }
+
+ @Test
+ fun `closing or a refused camera answers without a code`() = runTest {
+ val (closed, closing) = scan()
+ closing.onCloseClicked()
+ assertEquals(HostScan.Dismissed, closed.await())
+
+ val (refused, refusing) = scan()
+ refusing.onPermissionAlertClosed()
+ assertEquals(HostScan.CameraUnavailable, refused.await())
+ }
+}
diff --git a/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanPanel/compose/EmbeddedQrScanner.kt b/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanPanel/compose/EmbeddedQrScanner.kt
index 74f51f3ff7..f031536213 100644
--- a/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanPanel/compose/EmbeddedQrScanner.kt
+++ b/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanPanel/compose/EmbeddedQrScanner.kt
@@ -12,12 +12,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import io.paritytech.polkadotapp.common.presentation.camera.compose.PermissionMissingHint
+import io.paritytech.polkadotapp.common.presentation.camera.compose.QrViewfinder
import io.paritytech.polkadotapp.design.components.text.NovaText
import io.paritytech.polkadotapp.design.theme.PolkadotTheme
import io.paritytech.polkadotapp.design.utils.collectAsEffect
import io.paritytech.polkadotapp.feature_scan_impl.presentation.scanPanel.ScanPanelViewModel
-import io.paritytech.polkadotapp.feature_scan_impl.presentation.scanning.compose.PermissionMissingHint
-import io.paritytech.polkadotapp.feature_scan_impl.presentation.scanning.compose.QrViewfinder
import io.paritytech.polkadotapp.common.R as RCommon
@Composable
diff --git a/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanning/compose/QrScannerScreen.kt b/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanning/compose/QrScannerScreen.kt
index b89bfc619d..da5ccc722a 100644
--- a/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanning/compose/QrScannerScreen.kt
+++ b/hosts/android/feature/scan/impl/src/main/kotlin/io/paritytech/polkadotapp/feature_scan_impl/presentation/scanning/compose/QrScannerScreen.kt
@@ -18,12 +18,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.LifecycleOwner
+import io.paritytech.polkadotapp.common.presentation.camera.compose.QrViewfinder
import io.paritytech.polkadotapp.design.components.icon.NovaIcon
import io.paritytech.polkadotapp.design.components.icon.NovaIcons
import io.paritytech.polkadotapp.design.components.icon.vectors.Close
import io.paritytech.polkadotapp.design.components.text.NovaText
import io.paritytech.polkadotapp.design.theme.PolkadotTheme
-import io.paritytech.polkadotapp.feature_scan_impl.R
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import io.paritytech.polkadotapp.common.R as RCommon
@@ -58,7 +58,7 @@ internal fun QrScannerScreen(
Image(
modifier = Modifier.fillMaxSize(),
- painter = painterResource(R.drawable.img_scanner_frame),
+ painter = painterResource(RCommon.drawable.img_scanner_frame),
contentScale = ContentScale.Crop,
contentDescription = "image_overlay"
)