Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3c4bfd4
refactor(android): share the QR viewfinder and let the analyzer read …
valentinfernandez1 Oct 7, 2026
b16cce7
fix(android): close the contact picker when the product cancels
valentinfernandez1 Oct 7, 2026
d3f4b24
feat(android): draw the host scanner for products
valentinfernandez1 Oct 7, 2026
b5aaea3
fix(android): close host prompts robustly and read every code in a frame
valentinfernandez1 Oct 7, 2026
8b7d34d
fix(android): keep the scanner a sheet and close prompts only when on…
valentinfernandez1 Oct 7, 2026
2ca17c6
docs(android): explain the prompt lifecycle once
valentinfernandez1 Oct 7, 2026
4c9e709
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
0561423
fix(android): keep the screen behind the scanner hidden before the ca…
valentinfernandez1 Oct 8, 2026
902e4ae
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
3d6a002
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
63e0acc
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
09146d1
fix(android): answer NotVisible to a Worker scan while the app is in …
valentinfernandez1 Oct 8, 2026
f10cd66
fix(android): check a scan's visibility when its turn comes
valentinfernandez1 Oct 8, 2026
3a8d00e
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
56489e5
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
8b530b1
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
488e380
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
4cf90a3
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 8, 2026
b321475
fix(android): let a page scan again while the last scanner is closing
valentinfernandez1 Oct 8, 2026
55083f5
Merge branch 'feat/host-scanner-core' into feat/host-scanner-android
valentinfernandez1 Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,14 @@
android:id="@+id/action_global_to_truapiContactPickBottomSheet"
app:destination="@id/truapiContactPickBottomSheet" />

<dialog
android:id="@+id/truapiProductScanBottomSheet"
android:name="io.paritytech.polkadotapp.feature_products_impl.presentation.truapiProductScan.TrUAPIProductScanBottomSheet" />

<action
android:id="@+id/action_global_to_truapiProductScanBottomSheet"
app:destination="@id/truapiProductScanBottomSheet" />

<action
android:id="@+id/action_global_to_resourceAllocationRequestBottomSheet"
app:destination="@id/resourceAllocationRequestBottomSheet" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>,
private val listener: (codes: List<Pair<Int, String>>) -> 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

Expand All @@ -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()
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Boolean>,
val content: @Composable (onRetry: () -> Unit) -> Unit,
)

@Composable
internal fun QrViewfinder(
fun QrViewfinder(
modifier: Modifier = Modifier,
surfaceRequestFlow: StateFlow<SurfaceRequest?>,
invalidCodeEvent: SharedFlow<Unit>,
Expand Down
2 changes: 2 additions & 0 deletions hosts/android/common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,8 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc
<string name="truapi_contact_pick_body">%1$s learns only who you choose, and never your contact list.</string>
<string name="truapi_contact_pick_unnamed">Unnamed contact</string>
<string name="truapi_contact_pick_dismiss">Not now</string>
<string name="truapi_product_scan_title">Scan for %1$s</string>
<string name="truapi_product_scan_not_for_product">This code isn\'t for %1$s</string>
<string name="debug_report_title">Report app issue</string>
<string name="debug_report_what_happened">What happened?</string>
<string name="debug_report_placeholder">Tell us about the issue you encountered</string>
Expand Down
3 changes: 3 additions & 0 deletions hosts/android/feature/products/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataByteArray, ByteArray>)

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CodeFormat>): List<Int> = 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<CodeFormat, String>? = when (mlKitFormat) {
Barcode.FORMAT_UPC_A -> CodeFormat.EAN13 to "0$text"
else -> mlKitFormats.entries.firstOrNull { mlKitFormat in it.value }?.let { it.key to text }
}
}
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<ContactPickOption>,
) {
private val choice = CompletableDeferred<ContactPickOption?>()
private val shown = CompletableDeferred<Unit>()

/** 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>,
): 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<ContactPickRequest, ContactPickOption?>(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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrUAPIWorkerSupervisor>,
dispatchers: CoroutineDispatchers,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProductScanRequest, HostScan>(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) {
Comment thread
valentinfernandez1 marked this conversation as resolved.
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
}
}
Loading
Loading