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" )