diff --git a/AGENTS.md b/AGENTS.md index 3d2355a..3f34373 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -592,7 +592,7 @@ Android apps Websites relay and would only slow down every other delivery too), `UnlockNotifications.kt`, `UnlockActivity.kt` (always acts on the board's *current* request, not the notification's) and `UnlockEnrolActivity.kt` (enrolment key in memory only; `configChanges` - so a rotation cannot lose it). + so a rotation cannot lose it; see the enrol-invite paragraph below for its primary scan flow). Metadata rules the code must keep (design section 6): the lock subscription has no filter but the kind; the relay client has no signer (no NIP-42 answer with a stable key); every delivery is @@ -600,11 +600,42 @@ Android apps Websites only the keep-alive's scheduled pings); a relay Cambium has never spoken to before does not see its first connection from this phone land at the same moment the board's broadcast changed (`RelayGate`'s jitter). + + Enrol-invite (spec v1): Sapwood shows an invite QR, this phone scans it, reversing the earlier + phone-shows-a-QR direction (kept as `UnlockEnrolActivity`'s secondary "Show a code instead"). + `InviteUri.kt` parses `heartwood-unlock:invite?v=1&k=...&r=...&x=...&relay=...` as strictly as + `EnrolmentCode.parse` (single-valued fields refuse a repeat, `x` must be in the future and no + more than an hour ahead, every relay is `wss://` or `ws://localhost` for a test bench, + deduplicated). `EnrolInviteScan.kt` is `pairing/QrPairingScan.kt`'s reverse-direction sibling: a + `bunker://`/`nostrconnect://` link or another phone's own `heartwood-unlock:enrol?...` code both + get a distinct wrong-direction message, since both are the opposite direction from an invite. + `InviteReply.kt` is deliberately thin: `InviteReplyBuilder.tags(invite)` is the reply event's + exactly-two tags (`h`/`expiration`), pure Kotlin, nothing cryptographic -- production + (`signer/UnlockRelay.kt`'s `inviteReplyEvent`, below) and `EnrolInviteVectorTest` both call this + one function rather than keeping two copies of the shape that could drift apart. All the actual + cryptography for the reply -- the fresh throwaway key, NIP-44 v2 encrypting + `EnrolmentCode.encode()` to the invite's `inv` key, signing -- runs entirely through rust-nostr in + production, the same as `deliveryEvent` below; nothing hand-rolled reaches `app/src/main`. + + The shared Sapwood/Cambium test vector (`test/fixtures/enrol-invite-v1.json` in Sapwood, copied + byte-for-byte into `src/test/resources/enrol-invite-v1.json` here) still has to be checked byte + for byte on the host JVM, where rust-nostr's native code cannot load at all (see + `pairing/BunkerUri.kt`'s class doc) and its Kotlin bindings expose no way to pin the NIP-44 nonce + regardless. `EnrolInviteVectorTest` holds it to `InviteReplyBuilder.tags` for the tag values, and + to a **test-only** reference NIP-44 v2 (`src/test/kotlin/.../Nip44.kt`) and just enough secp256k1 + (`Secp256k1.kt`, naive affine double-and-add, no attempt at constant time or speed) for the + content: decrypting the vector's content to its plaintext, and, given its fixed throwaway secret + and nonce, reproducing its exact ciphertext. Both files live under `app/src/test`, are never + reachable from `app/src/main`, and exist purely to check the wire format independently of + rust-nostr -- production never uses them. - `signer/UnlockRelay.kt` -- the rust-nostr half of phone unlock: throwaway-key delivery events, - opening the enrolment hand-off (NIP-44), and `RelayWatch`, one signer-less `Client` per purpose - (the unfiltered 24135 listener; the enrolment screen's rendezvous subscription). Native calls - follow `RustNostrHeartwoodClient`'s rule: `NonCancellable` on IO, and the notification loop is - ended by `shutdown()`, never by cancelling the coroutine inside it. + opening the enrolment hand-off (NIP-44), building and signing an enrol-invite reply + (`inviteReplyEvent`: `Keys.generate()`, `nip44Encrypt` with a random nonce, `InviteReplyBuilder. + tags` turned into real `Tag`s, `signWithKeys` -- see above), and `RelayWatch`, one signer-less + `Client` per purpose (the unfiltered 24135 listener; the enrolment screen's rendezvous + subscription, now also used to publish the invite reply). Native calls follow + `RustNostrHeartwoodClient`'s rule: `NonCancellable` on IO, and the notification loop is ended by + `shutdown()`, never by cancelling the coroutine inside it. ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index f64048b..a4d3315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Adding a phone now scans Sapwood's own invite QR instead of the other way round: "Add a phone" + in Sapwood shows a one-off invite, and this phone's "Scan Sapwood's code" reads it, builds its + usual enrolment, seals it with NIP-44 to a fresh throwaway key, and publishes the reply once to + the invite's relays, with a Retry if no relay accepts it. From there it is exactly as before: + waiting for the board's hand-off, then the five request words, then the check code. Scanning a + bunker link or another phone's own enrol code here gets a wrong-direction message; "Show a code + instead" keeps the original phone-shows-a-QR flow for a Sapwood with no camera-visible screen. + ## 0.6.0 (2026-09-25) - Enrolling this phone for unlock over the relay. A Heartwood on 0.18.0-beta.19 or later shows a diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt b/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt index a5f61a2..7912b6a 100644 --- a/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt +++ b/app/src/main/kotlin/dev/forgesworn/cambium/signer/UnlockRelay.kt @@ -1,6 +1,9 @@ package dev.forgesworn.cambium.signer import android.util.Log +import dev.forgesworn.cambium.toHex +import dev.forgesworn.cambium.unlock.InviteReplyBuilder +import dev.forgesworn.cambium.unlock.InviteUri import dev.forgesworn.cambium.unlock.PhoneUnlock import dev.forgesworn.cambium.unlock.RawAnnouncement import kotlinx.coroutines.CoroutineScope @@ -64,6 +67,22 @@ object UnlockNostr { .tags(listOf(Tag.publicKey(board))) .signWithKeys(throwaway) } + + /** + * Builds and signs the enrol-invite reply to [invite] (spec v1): a fresh throwaway key, + * NIP-44 v2 content (a random nonce, entirely rust-nostr's own) sealing [enrolmentCode] to + * the invite's `inv` key, and exactly the two tags [InviteReplyBuilder.tags] says + * (`h`/`expiration`) -- the same pure shape `EnrolInviteVectorTest` checks against the shared + * Sapwood/Cambium vector. Everything cryptographic here is rust-nostr, never the hand-rolled, + * test-only NIP-44/secp256k1 that vector test uses to check the wire format independently. + */ + fun inviteReplyEvent(invite: InviteUri, enrolmentCode: String): Event { + val throwaway = Keys.generate() + val inviter = PublicKey.parse(invite.inviterPubkeyHex) + val content = nip44Encrypt(throwaway.secretKey(), inviter, enrolmentCode, Nip44Version.V2) + val tags = InviteReplyBuilder.tags(invite).map { (name, value) -> Tag.parse(listOf(name, value)) } + return EventBuilder(Kind(PhoneUnlock.HANDOFF_KIND.toUShort()), content).tags(tags).signWithKeys(throwaway) + } } /** diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScan.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScan.kt new file mode 100644 index 0000000..d76b967 --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/EnrolInviteScan.kt @@ -0,0 +1,52 @@ +package dev.forgesworn.cambium.unlock + +/** + * Turns a raw QR scan result into an invite decision, the reverse-direction sibling of + * [dev.forgesworn.cambium.pairing.QrPairingScan]: pure Kotlin, no Android or zxing, so the zxing + * call itself stays in [UnlockEnrolActivity]. + */ +sealed interface EnrolInviteScanResult { + data class Accepted(val invite: InviteUri) : EnrolInviteScanResult + data class Rejected(val message: String) : EnrolInviteScanResult + /** The user backed out of the scanner: not an error, nothing should be shown. */ + data object Cancelled : EnrolInviteScanResult +} + +object EnrolInviteScan { + + const val NOT_AN_INVITE = "That QR is not a Sapwood invite." + const val WRONG_DIRECTION_BUNKER = + "That link is for pairing an app with Sapwood, not an unlock invite: go to Sapwood's \"Add a phone\" step and scan that code instead." + const val WRONG_DIRECTION_ENROL_CODE = + "That is this phone's own unlock code, not Sapwood's invite: on Sapwood, click \"Add a phone\" and scan the code it shows instead." + + private const val BUNKER_SCHEME = "bunker://" + private const val NOSTRCONNECT_SCHEME = "nostrconnect://" + private const val ENROL_PREFIX = "${EnrolmentCode.SCHEME}:enrol?" + + /** + * [rawContents] is the scanning library's result content: `null` means the user cancelled the + * scan, an empty/blank string means a QR was read but encoded nothing usable. + */ + fun evaluate(rawContents: String?, nowSecs: Long = System.currentTimeMillis() / 1000): EnrolInviteScanResult { + if (rawContents == null) return EnrolInviteScanResult.Cancelled + + val trimmed = rawContents.trim() + if (trimmed.isEmpty()) return EnrolInviteScanResult.Rejected(NOT_AN_INVITE) + + if (trimmed.startsWith(BUNKER_SCHEME, ignoreCase = true) || trimmed.startsWith(NOSTRCONNECT_SCHEME, ignoreCase = true)) { + // Sapwood's own "Connect an app" bunker link (or its client-initiated sibling): the + // pairing direction, not the unlock-invite direction this screen expects. + return EnrolInviteScanResult.Rejected(WRONG_DIRECTION_BUNKER) + } + + if (trimmed.startsWith(ENROL_PREFIX, ignoreCase = true)) { + // This phone's own "heartwood-unlock:enrol?..." code, or another phone's: the + // opposite direction from Sapwood's invite. + return EnrolInviteScanResult.Rejected(WRONG_DIRECTION_ENROL_CODE) + } + + val invite = InviteUri.parse(trimmed, nowSecs) ?: return EnrolInviteScanResult.Rejected(NOT_AN_INVITE) + return EnrolInviteScanResult.Accepted(invite) + } +} diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt new file mode 100644 index 0000000..cd692f4 --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteReply.kt @@ -0,0 +1,21 @@ +package dev.forgesworn.cambium.unlock + +/** + * The shaping of an enrol-invite reply event (spec v1) that has nothing to do with cryptography: + * exactly two tags, `h` (the invite's own rendezvous `ri`) and `expiration` (NIP-40, the invite's + * own `x`), and nothing else. Pure Kotlin, no rust-nostr, shared between the production builder + * (`signer/UnlockRelay.kt`'s `UnlockNostr.inviteReplyEvent`, which turns these into real `Tag`s, + * NIP-44-encrypts the plaintext, and signs, all through rust-nostr) and `EnrolInviteVectorTest` + * (which checks the same tag values against the shared Sapwood/Cambium vector), so both are held + * to one copy of this shape rather than two that could quietly drift apart. + * + * The actual encryption is deliberately not here: rust-nostr's Kotlin bindings expose no way to + * pin the NIP-44 nonce and cannot load on the host JVM at all (native code per ABI -- see + * `pairing/BunkerUri.kt`'s class doc), so production and the vector test cannot share that step. + * The vector test instead holds a test-only reference NIP-44 v2 implementation + * (`src/test/kotlin/.../Nip44.kt`, `Secp256k1.kt`) to the vector directly. + */ +object InviteReplyBuilder { + fun tags(invite: InviteUri): List> = + listOf("h" to invite.rendezvous, "expiration" to invite.expiresAtSecs.toString()) +} diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt new file mode 100644 index 0000000..595e252 --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/InviteUri.kt @@ -0,0 +1,58 @@ +package dev.forgesworn.cambium.unlock + +import java.net.URLDecoder + +/** + * Sapwood's invite (enrol-invite spec v1), reversed from [EnrolmentCode]'s own QR: Sapwood shows + * this, Cambium scans it. + * + * ``` + * heartwood-unlock:invite?v=1&k=&r=&x=&relay=[&relay=...] + * ``` + * + * [inviterPubkeyHex] (`inv`) is Sapwood's one-off key, kept only in page memory; [rendezvous] (`ri`) + * is the one-off `h` tag Cambium's reply is addressed to; [expiresAtSecs] (`x`) is when Sapwood + * stops listening; [relays] are where it is listening. Parsing is deliberately as strict as + * [EnrolmentCode.parse]: a repeated single-valued parameter, or a relay that is not `wss://` (or + * `ws://localhost` for a test bench) refuses the whole code, rather than guessing which value was + * meant. + */ +data class InviteUri( + val inviterPubkeyHex: String, + val rendezvous: String, + val expiresAtSecs: Long, + val relays: List, +) { + companion object { + const val SCHEME = "heartwood-unlock" + private val HEX64 = Regex("^[0-9a-f]{64}$") + private val HEX32 = Regex("^[0-9a-f]{32}$") + /** Sapwood generates a fresh invite every 10 minutes; an hour is a generous clock-skew margin. */ + const val MAX_FUTURE_SECS = 3600L + + /** + * Parses [text] as an invite, or null for anything malformed, expired, or too far in the + * future. [nowSecs] is injectable so a test does not need to race a wall clock. + */ + fun parse(text: String, nowSecs: Long = System.currentTimeMillis() / 1000): InviteUri? { + val prefix = "$SCHEME:invite?" + if (!text.startsWith(prefix)) return null + val params = text.removePrefix(prefix).split('&').mapNotNull { pair -> + val eq = pair.indexOf('=') + if (eq <= 0) null else pair.substring(0, eq) to runCatching { URLDecoder.decode(pair.substring(eq + 1), "UTF-8") }.getOrNull() + } + fun one(name: String) = params.singleOrNull { it.first == name }?.second + if (one("v") != "1") return null + val k = one("k")?.takeIf { HEX64.matches(it) } ?: return null + val r = one("r")?.takeIf { HEX32.matches(it) } ?: return null + val x = one("x")?.toLongOrNull() ?: return null + if (x <= nowSecs || x > nowSecs + MAX_FUTURE_SECS) return null + val relays = params.filter { it.first == "relay" }.map { it.second ?: return null }.distinct() + if (relays.isEmpty() || relays.any { !isInviteRelayUrl(it) }) return null + return InviteUri(k, r, x, relays) + } + } +} + +private fun isInviteRelayUrl(url: String): Boolean = + (url.startsWith("wss://") || url.startsWith("ws://localhost")) && url.length in 7..256 && url.none { it.isWhitespace() } diff --git a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt index bf404df..9a147f4 100644 --- a/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt +++ b/app/src/main/kotlin/dev/forgesworn/cambium/unlock/UnlockEnrolActivity.kt @@ -20,6 +20,9 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.zxing.BarcodeFormat import com.journeyapps.barcodescanner.BarcodeEncoder +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanIntentResult +import com.journeyapps.barcodescanner.ScanOptions import dev.forgesworn.cambium.R import dev.forgesworn.cambium.databinding.ActivityUnlockEnrolBinding import dev.forgesworn.cambium.pairing.Pairing @@ -33,34 +36,47 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import rust.nostr.sdk.Event import java.security.SecureRandom import javax.crypto.Cipher /** * Makes this phone an unlock phone for the board behind one paired identity. * - * 1. Shows an [EnrolmentCode]: a one-off enrolment pubkey, a one-off rendezvous tag and the - * relays to meet on. Its secret half stays in this activity's memory and nowhere else. - * 2. Sapwood (or the bench script) enrols the pubkey on the board, which needs a press there, - * and publishes the board's sealed answer tagged with the rendezvous tag. - * 3. Cambium opens it, and the owner's screen lock (PIN or strong biometric) seals the slot secret under a new Keystore key - * ([SlotSecretVault]). Only then is the enrolment stored, the listener started, and the - * keep-alive switched on, so the phone hears the board after a power cut. + * The primary path (enrol-invite spec v1) reverses the optical step: Sapwood shows an invite QR, + * this phone scans it. + * + * 1. "Scan Sapwood's code" scans and strictly parses an [InviteUri]. A bunker link or another + * phone's own enrolment code gets a helpful wrong-direction message ([EnrolInviteScan]). + * 2. Cambium then builds its [EnrolmentCode] exactly as before (a one-off enrolment pubkey, a + * one-off rendezvous tag, this phone's relays), seals it to the invite's `inv` key + * ([InviteReplyBuilder]) and publishes the reply once to the invite's relays. Sending/sent/ + * failed shows on screen, with a Retry that republishes the exact same signed event. + * 3. From there it is exactly the old flow: waits for the board's hand-off (now on its own relays + * plus the invite's relays), shows the five request words, then the check code, then the + * fingerprint that seals the slot secret under a new Keystore key ([SlotSecretVault]). Only + * then is the enrolment stored, the listener started, and the keep-alive switched on, so the + * phone hears the board after a power cut. + * + * "Show a code instead" keeps the original direction (this phone shows a QR, Sapwood scans it) as + * a secondary option, for a Sapwood with no camera-visible screen or an older build. * * Nothing secret is ever on screen or passes through Sapwood. If the owner leaves before the * fingerprint, or waits more than [PENDING_TIMEOUT_MILLIS], the secret is dropped and the screen * says which board record to revoke. * - * The hand-off is not signed by anything the phone already trusts, so anyone who saw the code - * could race in an answer of their own. Two guards: the screen shows the board record number for - * the owner to compare with what Sapwood (or the bench script) reports, and a second, different - * answer arriving before the fingerprint blocks the enrolment outright. + * The hand-off is not signed by anything the phone already trusts, so anyone who saw the code (or + * the invite) could race in an answer of their own. Three guards: the owner compares the five + * request words between this phone and Sapwood (or the board's own card) before confirming, the + * board shows the same five words before its button press, and a second, different answer arriving + * before the fingerprint blocks the enrolment outright. * Declared with `configChanges` in the manifest so a rotation does not lose the enrolment key. */ class UnlockEnrolActivity : AppCompatActivity() { private lateinit var binding: ActivityUnlockEnrolBinding private lateinit var pairing: Pairing + private lateinit var relays: List private var enrolSecretHex: String? = null private var watch: RelayWatch? = null private var pending: HandOff? = null @@ -71,7 +87,15 @@ class UnlockEnrolActivity : AppCompatActivity() { private var conflicted = false @Volatile private var destroyed = false + /** Held for Retry: the exact signed event, and the invite's relays it targets. */ + private var pendingInviteReply: Event? = null + private var pendingInviteRelays: List = emptyList() + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + private val scanLauncher = registerForActivityResult(ScanContract()) { result -> onInviteScanResult(result) } + private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) launchScanner() else binding.enrolStatus.text = getString(R.string.enrol_camera_denied) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -80,6 +104,9 @@ class UnlockEnrolActivity : AppCompatActivity() { binding.enrolDoneButton.setOnClickListener { finish() } binding.enrolConfirmButton.setOnClickListener { promptSeal() } binding.enrolBatteryButton.setOnClickListener { requestBatteryExemption() } + binding.enrolScanButton.setOnClickListener { onScanClicked() } + binding.enrolShowCodeButton.setOnClickListener { onShowCodeClicked() } + binding.enrolRetryButton.setOnClickListener { publishInviteReply() } val signer = intent.getStringExtra(EXTRA_SIGNER_PUBKEY) pairing = PairingStore(this).pairings().firstOrNull { it.signerPubkeyHex == signer } ?: return finish() @@ -89,38 +116,17 @@ class UnlockEnrolActivity : AppCompatActivity() { showOnly(getString(R.string.enrol_needs_biometric)) return } - val relays = pairing.relays.map { it.trimEnd('/') }.filter(::isRelayUrl).distinct() + relays = pairing.relays.map { it.trimEnd('/') }.filter(::isRelayUrl).distinct() if (relays.isEmpty()) { showOnly(getString(R.string.enrol_no_relays)) return } - val (secretHex, pubkeyHex) = UnlockNostr.newEnrolmentKey() - enrolSecretHex = secretHex - val rendezvous = ByteArray(16).also { SecureRandom().nextBytes(it) }.toHex() - val code = EnrolmentCode(pubkeyHex, rendezvous, EnrolmentCode.fitLabel(Build.MODEL), relays).encode() - binding.enrolCode.text = code - binding.enrolWords.text = requestWords(pubkeyHex).orEmpty() - binding.enrolQr.setImageBitmap(BarcodeEncoder().encodeBitmap(code, BarcodeFormat.QR_CODE, 720, 720)) - binding.enrolCopyButton.setOnClickListener { - getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("Heartwood unlock enrolment", code)) - } - binding.enrolStatus.text = getString(R.string.enrol_waiting) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } - - // Not lifecycleScope: a start cancelled half way would leave a relay client nobody can - // stop. The watch is recorded, or stopped at once if the screen closed meanwhile. - CoroutineScope(Dispatchers.IO).launch { - val started = RelayWatch.handOff(relays, rendezvous) { raw -> runOnUiThread { onHandOff(raw) } } - runOnUiThread { - if (destroyed) CoroutineScope(Dispatchers.IO).launch { started.stop() } else watch = started - } - } } override fun onDestroy() { @@ -137,6 +143,135 @@ class UnlockEnrolActivity : AppCompatActivity() { super.onDestroy() } + // -- Secondary path: this phone shows a QR, Sapwood scans it (unchanged from before this spec). -- + + private fun onShowCodeClicked() { + binding.enrolScanButton.isVisible = false + binding.enrolShowCodeButton.isVisible = false + binding.enrolBody.isVisible = false + binding.enrolShowCodeBody.isVisible = true + + val code = buildOwnEnrolment() + val text = code.encode() + binding.enrolCode.text = text + binding.enrolCode.isVisible = true + binding.enrolWords.text = requestWords(code.enrolPubkeyHex).orEmpty() + binding.enrolWords.isVisible = true + binding.enrolWordsHint.isVisible = true + binding.enrolQr.setImageBitmap(BarcodeEncoder().encodeBitmap(text, BarcodeFormat.QR_CODE, 720, 720)) + binding.enrolQr.isVisible = true + binding.enrolCopyButton.setOnClickListener { + getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("Heartwood unlock enrolment", text)) + } + binding.enrolCopyButton.isVisible = true + binding.enrolStatus.text = getString(R.string.enrol_waiting) + + startHandOffWatch(relays, code.rendezvous) + } + + // -- Primary path: Sapwood shows an invite, this phone scans it (enrol-invite spec v1). -- + + private fun onScanClicked() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + launchScanner() + } else { + cameraPermission.launch(Manifest.permission.CAMERA) + } + } + + private fun launchScanner() { + val options = ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(getString(R.string.qr_scan_prompt)) + setBeepEnabled(false) + setOrientationLocked(true) + } + scanLauncher.launch(options) + } + + private fun onInviteScanResult(result: ScanIntentResult) { + when (val evaluated = EnrolInviteScan.evaluate(result.contents)) { + is EnrolInviteScanResult.Accepted -> onInviteAccepted(evaluated.invite) + is EnrolInviteScanResult.Rejected -> binding.enrolStatus.text = evaluated.message + EnrolInviteScanResult.Cancelled -> Unit + } + } + + private fun onInviteAccepted(invite: InviteUri) { + binding.enrolScanButton.isVisible = false + binding.enrolShowCodeButton.isVisible = false + binding.enrolBody.isVisible = false + + val code = buildOwnEnrolment() + binding.enrolWords.text = requestWords(code.enrolPubkeyHex).orEmpty() + binding.enrolWords.isVisible = true + binding.enrolWordsHint.isVisible = true + + val event = runCatching { UnlockNostr.inviteReplyEvent(invite, code.encode()) }.getOrElse { + binding.enrolStatus.text = getString(R.string.enrol_send_failed) + return + } + pendingInviteReply = event + pendingInviteRelays = invite.relays + binding.enrolStatus.text = getString(R.string.enrol_sending) + + val combined = (relays + invite.relays).distinct() + // Not lifecycleScope: a start cancelled half way would leave a relay client nobody can + // stop. The watch is recorded, or stopped at once if the screen closed meanwhile. The + // publish itself waits for this same connect, so the reply is never lost to the race + // between "connected" and "about to publish". + CoroutineScope(Dispatchers.IO).launch { + val started = RelayWatch.handOff(combined, code.rendezvous) { raw -> runOnUiThread { onHandOff(raw) } } + // Views and `watch` belong to the UI thread, as in startHandOffWatch. + runOnUiThread { + if (destroyed) { + CoroutineScope(Dispatchers.IO).launch { started.stop() } + } else { + watch = started + publishInviteReply() + } + } + } + } + + /** Publishes [pendingInviteReply] to [pendingInviteRelays]. Retry calls this again, unchanged. */ + private fun publishInviteReply() { + val event = pendingInviteReply ?: return + val current = watch ?: return + binding.enrolRetryButton.isVisible = false + binding.enrolStatus.text = getString(R.string.enrol_sending) + CoroutineScope(Dispatchers.IO).launch { + val ok = current.publish(event, pendingInviteRelays) + runOnUiThread { + if (destroyed) return@runOnUiThread + if (ok) { + binding.enrolStatus.text = getString(R.string.enrol_sent) + } else { + binding.enrolStatus.text = getString(R.string.enrol_send_failed) + binding.enrolRetryButton.isVisible = true + } + } + } + } + + // -- Shared by both paths. -- + + private fun buildOwnEnrolment(): EnrolmentCode { + val (secretHex, pubkeyHex) = UnlockNostr.newEnrolmentKey() + enrolSecretHex = secretHex + val rendezvous = ByteArray(16).also { SecureRandom().nextBytes(it) }.toHex() + return EnrolmentCode(pubkeyHex, rendezvous, EnrolmentCode.fitLabel(Build.MODEL), relays) + } + + private fun startHandOffWatch(onRelays: List, rendezvous: String) { + CoroutineScope(Dispatchers.IO).launch { + val started = RelayWatch.handOff(onRelays, rendezvous) { raw -> runOnUiThread { onHandOff(raw) } } + runOnUiThread { + if (destroyed) CoroutineScope(Dispatchers.IO).launch { started.stop() } else watch = started + } + } + } + private fun onHandOff(raw: RawAnnouncement) { if (conflicted || isFinishing) return val secret = enrolSecretHex ?: return @@ -181,6 +316,7 @@ class UnlockEnrolActivity : AppCompatActivity() { binding.enrolWords.isVisible = false binding.enrolWordsHint.isVisible = false binding.enrolCopyButton.isVisible = false + binding.enrolRetryButton.isVisible = false binding.enrolStatus.text = getString(R.string.enrol_received) binding.enrolCheckCode.text = checkCode(envelope.ephemeralPubkeyHex).orEmpty() binding.enrolCheckCode.isVisible = true @@ -278,6 +414,9 @@ class UnlockEnrolActivity : AppCompatActivity() { private fun showOnly(message: String) { binding.enrolBody.isVisible = false + binding.enrolScanButton.isVisible = false + binding.enrolShowCodeButton.isVisible = false + binding.enrolShowCodeBody.isVisible = false binding.enrolQr.isVisible = false binding.enrolCode.isVisible = false binding.enrolWords.isVisible = false diff --git a/app/src/main/res/layout/activity_unlock_enrol.xml b/app/src/main/res/layout/activity_unlock_enrol.xml index 9d0531c..6c20b18 100644 --- a/app/src/main/res/layout/activity_unlock_enrol.xml +++ b/app/src/main/res/layout/activity_unlock_enrol.xml @@ -30,6 +30,34 @@ android:text="@string/enrol_body" android:textColor="@color/on_background_muted" /> +