From 406f8df09ccc57a62b7853da443c9452f85a47ff Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 16:43:56 -0400 Subject: [PATCH 1/5] feat(device-link): add the seed vault and LAN link protocol New KMP module :apps:flipcash:shared:device-link. Everything is in commonMain except the OS keyring vault. - SeedVault: interface for the stored account seed. KeyringSeedVault (jvmMain) backs it with Keychain, Credential Manager or Secret Service through java-keyring; those are native APIs with no common surface. Android keeps AuthManager for now. - LinkInvite: the QR payload: host, port, the desktop's one-time X25519 key, and a 16-byte one-time secret. - LinkCrypto: libsodium kx (phone is client, desktop server). The transcript binds both public keys and the secret; the six-digit code is a keyed hash of the transcript under the phone-to-desktop key, and the seed is sealed with XChaCha20-Poly1305 with the transcript as AD. - DesktopLinkHost / PhoneLinker: one connection over ktor-network sockets. The phone sends the sealed seed only from Pending.confirm(), after the user says the codes match. ktor-network is a second networking stack next to OkHttp/gRPC; it keeps the sockets in common code instead of an expect/actual per platform. Tests are jvmTest, not commonTest: the Android libsodium binding loads its .so from an APK, so the protocol can't run as a host test. LinkProtocolTest runs both sides over a loopback socket (7 cases, including a swapped phone key, a wrong secret, tampered ciphertext and cancel). KeyringSeedVaultTest is opt-in (-Pflipcash.keyringTest) and writes a random value under a test-only service name, then deletes it. --- .../shared/device-link/build.gradle.kts | 41 +++++ .../app/devicelink/DesktopLinkHost.kt | 88 +++++++++++ .../flipcash/app/devicelink/LinkChannel.kt | 43 ++++++ .../com/flipcash/app/devicelink/LinkCrypto.kt | 115 ++++++++++++++ .../com/flipcash/app/devicelink/LinkInvite.kt | 41 +++++ .../flipcash/app/devicelink/PhoneLinker.kt | 63 ++++++++ .../com/flipcash/app/devicelink/SeedVault.kt | 11 ++ .../app/devicelink/KeyringSeedVault.kt | 38 +++++ .../app/devicelink/KeyringSeedVaultTest.kt | 29 ++++ .../app/devicelink/LinkProtocolTest.kt | 143 ++++++++++++++++++ gradle/libs.versions.toml | 6 + settings.gradle.kts | 1 + 12 files changed, 619 insertions(+) create mode 100644 apps/flipcash/shared/device-link/build.gradle.kts create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/SeedVault.kt create mode 100644 apps/flipcash/shared/device-link/src/jvmMain/kotlin/com/flipcash/app/devicelink/KeyringSeedVault.kt create mode 100644 apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/KeyringSeedVaultTest.kt create mode 100644 apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt diff --git a/apps/flipcash/shared/device-link/build.gradle.kts b/apps/flipcash/shared/device-link/build.gradle.kts new file mode 100644 index 0000000000..d8dc6fe88c --- /dev/null +++ b/apps/flipcash/shared/device-link/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.flipcash.kmp.app.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +// Link a desktop to a phone over the LAN. The protocol, crypto, state and screens are common; the +// platform pieces are the seed store (jvmMain: OS keyring) and the camera, which the Android app +// passes into the phone screen. +kotlin { + android { + namespace = "${Gradle.flipcashNamespace}.shared.devicelink" + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.sodium.bindings.kmp) + implementation(libs.ktor.network) + implementation(libs.qrose) + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.ui) + implementation(libs.jetbrains.compose.material) + implementation(project(":ui:theme")) + } + jvmMain.dependencies { + implementation(libs.java.keyring) + } + // jvmTest, not commonTest: libsodium's Android binding loads its .so from an APK, so the + // protocol can't run in an Android host test (the same reason chat-cipher has no host test). + jvmTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +tasks.withType().configureEach { + if (providers.gradleProperty("flipcash.keyringTest").isPresent) systemProperty("flipcash.keyringTest", "true") +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt new file mode 100644 index 0000000000..b416752b56 --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.devicelink + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.io.encoding.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * The desktop side of a link: listens on [host], shows the invite, takes one connection, and + * emits the seed once the phone sends it sealed under the session key. Each collection is one + * attempt with fresh keys; the listener closes after the first connection. + */ +class DesktopLinkHost( + private val host: String, + private val bindHost: String = "0.0.0.0", + private val timeout: Duration = 5.minutes, +) { + sealed interface State { + data class Waiting(val invite: String) : State + data class Confirm(val code: String) : State + /** [entropyB64] is the seed; the caller stores it and signs in. */ + class Linked(val entropyB64: String) : State + data class Failed(val reason: String) : State + } + + fun link(): Flow = flow { + val desktop = LinkCrypto.ephemeral() + val secret = LinkCrypto.secret() + val selector = SelectorManager(Dispatchers.Default) + var session: LinkCrypto.Session? = null + try { + val server = aSocket(selector).tcp().bind(InetSocketAddress(bindHost, 0)) + val port = (server.localAddress as InetSocketAddress).port + emit(State.Waiting(LinkInvite(host, port, desktop.publicKey, secret).encode())) + + val socket = withTimeoutOrNull(timeout) { server.accept() } + server.close() + if (socket == null) return@flow emit(State.Failed("Timed out waiting for the phone")) + val channel = LinkChannel(socket) + try { + val hello = channel.receive() + if (hello == null || hello.first != Frame.HELLO || hello.second.size != 1 + LinkCrypto.KEY_BYTES || + hello.second[0] != Frame.PROTOCOL_VERSION + ) { + return@flow emit(State.Failed("The phone sent an unexpected message")) + } + val phoneKey = hello.second.copyOfRange(1, hello.second.size) + val s = LinkCrypto.desktopSession(desktop, phoneKey, secret).also { session = it } + emit(State.Confirm(LinkCrypto.shortCode(s))) + + val reply = withTimeoutOrNull(timeout) { channel.receive() } + when { + reply == null -> emit(State.Failed("The phone disconnected")) + reply.first == Frame.CANCEL -> emit(State.Failed("Cancelled on the phone")) + reply.first != Frame.SEED -> emit(State.Failed("The phone sent an unexpected message")) + else -> { + val entropy = LinkCrypto.open(s.phoneToDesktop, reply.second, s.transcript) + if (entropy == null || entropy.size !in ENTROPY_SIZES) { + emit(State.Failed("Couldn't verify the phone's message")) + } else { + channel.send(Frame.ACK, LinkCrypto.seal(s.desktopToPhone, ACK_BODY, s.transcript)) + val b64 = Base64.encode(entropy) + entropy.fill(0) + emit(State.Linked(b64)) + } + } + } + } finally { + channel.close() + } + } finally { + desktop.wipe() + session?.wipe() + selector.close() + } + } + + internal companion object { + val ENTROPY_SIZES = setOf(16, 32) + val ACK_BODY = "linked".encodeToByteArray() + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt new file mode 100644 index 0000000000..974d9b8c3a --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt @@ -0,0 +1,43 @@ +package com.flipcash.app.devicelink + +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.readByteArray +import io.ktor.utils.io.readInt +import io.ktor.utils.io.writeByteArray +import io.ktor.utils.io.writeInt + +/** Length-prefixed frames over a TCP socket. The first byte of each frame is its [Frame] type. */ +internal class LinkChannel(private val socket: Socket) { + private val input = socket.openReadChannel() + private val output = socket.openWriteChannel(autoFlush = false) + + suspend fun send(type: Byte, body: ByteArray = ByteArray(0)) { + output.writeInt(body.size + 1) + output.writeByteArray(byteArrayOf(type) + body) + output.flush() + } + + /** The next frame, or null when the peer closed the connection or sent something malformed. */ + suspend fun receive(): Pair? = runCatching { + val size = input.readInt() + if (size !in 1..MAX_FRAME) return null + val frame = input.readByteArray(size) + frame[0] to frame.copyOfRange(1, frame.size) + }.getOrNull() + + fun close() = socket.close() + + private companion object { + const val MAX_FRAME = 4096 + } +} + +internal object Frame { + const val HELLO: Byte = 1 + const val SEED: Byte = 2 + const val ACK: Byte = 3 + const val CANCEL: Byte = 4 + const val PROTOCOL_VERSION: Byte = 1 +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt new file mode 100644 index 0000000000..6663337bce --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt @@ -0,0 +1,115 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + +package com.flipcash.app.devicelink + +import com.ionspin.kotlin.crypto.LibsodiumInitializer +import com.ionspin.kotlin.crypto.aead.AuthenticatedEncryptionWithAssociatedData +import com.ionspin.kotlin.crypto.generichash.GenericHash +import com.ionspin.kotlin.crypto.keyexchange.KeyExchange +import com.ionspin.kotlin.crypto.util.LibsodiumRandom + +/** + * Keys and codes for one link. The phone is libsodium's kx client and the desktop its server. + * + * The transcript binds both public keys and the QR secret. The short code is a keyed hash of the + * transcript under the phone-to-desktop session key, so it can only match when both sides hold the + * same key: an attacker who swaps the phone's public key on its way to the desktop ends up with a + * different key and can't predict the phone's code, so grinding keys for a collision doesn't help. + */ +internal object LinkCrypto { + const val KEY_BYTES = 32 + const val SECRET_BYTES = 16 + private const val NONCE_BYTES = 24 + private val LABEL = "flipcash-link-v1".encodeToByteArray() + + class Ephemeral(val publicKey: ByteArray, val secretKey: ByteArray) { + fun wipe() = secretKey.fill(0) + } + + class Session( + val phoneToDesktop: ByteArray, + val desktopToPhone: ByteArray, + val transcript: ByteArray, + ) { + fun wipe() { + phoneToDesktop.fill(0) + desktopToPhone.fill(0) + } + } + + fun ephemeral(): Ephemeral { + ensureSodium() + val pair = KeyExchange.keypair() + return Ephemeral(pair.publicKey.toByteArray(), pair.secretKey.toByteArray()) + } + + fun secret(): ByteArray { + ensureSodium() + return LibsodiumRandom.buf(SECRET_BYTES).toByteArray() + } + + fun phoneSession(phone: Ephemeral, desktopKey: ByteArray, secret: ByteArray): Session { + ensureSodium() + val keys = KeyExchange.clientSessionKeys( + phone.publicKey.toUByteArray(), phone.secretKey.toUByteArray(), desktopKey.toUByteArray(), + ) + return Session( + phoneToDesktop = keys.sendKey.toByteArray(), + desktopToPhone = keys.receiveKey.toByteArray(), + transcript = transcript(desktopKey, phone.publicKey, secret), + ) + } + + fun desktopSession(desktop: Ephemeral, phoneKey: ByteArray, secret: ByteArray): Session { + ensureSodium() + val keys = KeyExchange.serverSessionKeys( + desktop.publicKey.toUByteArray(), desktop.secretKey.toUByteArray(), phoneKey.toUByteArray(), + ) + return Session( + phoneToDesktop = keys.receiveKey.toByteArray(), + desktopToPhone = keys.sendKey.toByteArray(), + transcript = transcript(desktop.publicKey, phoneKey, secret), + ) + } + + /** Six digits both screens show; the user confirms they match on the phone before the seed is sent. */ + fun shortCode(session: Session): String { + ensureSodium() + val h = GenericHash.genericHash( + session.transcript.toUByteArray(), 32, session.phoneToDesktop.toUByteArray(), + ) + val n = ((h[0].toLong() shl 24) or (h[1].toLong() shl 16) or (h[2].toLong() shl 8) or h[3].toLong()) + return (n % 1_000_000).toString().padStart(6, '0') + } + + fun seal(key: ByteArray, plaintext: ByteArray, transcript: ByteArray): ByteArray { + ensureSodium() + val nonce = LibsodiumRandom.buf(NONCE_BYTES) + val ct = AuthenticatedEncryptionWithAssociatedData.xChaCha20Poly1305IetfEncrypt( + plaintext.toUByteArray(), transcript.toUByteArray(), nonce, key.toUByteArray(), + ) + return nonce.toByteArray() + ct.toByteArray() + } + + /** Null when the ciphertext was tampered with or sealed under another key or transcript. */ + fun open(key: ByteArray, sealed: ByteArray, transcript: ByteArray): ByteArray? { + if (sealed.size <= NONCE_BYTES) return null + ensureSodium() + return runCatching { + AuthenticatedEncryptionWithAssociatedData.xChaCha20Poly1305IetfDecrypt( + sealed.copyOfRange(NONCE_BYTES, sealed.size).toUByteArray(), + transcript.toUByteArray(), + sealed.copyOfRange(0, NONCE_BYTES).toUByteArray(), + key.toUByteArray(), + ).toByteArray() + }.getOrNull() + } + + private fun transcript(desktopKey: ByteArray, phoneKey: ByteArray, secret: ByteArray) = + LABEL + desktopKey + phoneKey + secret + + private fun ensureSodium() { + // Synchronous on JVM/Android/Native, as in chat-cipher. + if (!LibsodiumInitializer.isInitialized()) LibsodiumInitializer.initializeWithCallback { } + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt new file mode 100644 index 0000000000..d924be8dae --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt @@ -0,0 +1,41 @@ +package com.flipcash.app.devicelink + +import kotlin.io.encoding.Base64 + +/** + * What the desktop's QR code carries: where to connect, the desktop's one-time public key, and a + * one-time secret. Only a device that scanned the code knows the secret, and it is bound into every + * key and code derived after, so a device on the LAN that finds the port without the QR can't link. + */ +class LinkInvite( + val host: String, + val port: Int, + val publicKey: ByteArray, + val secret: ByteArray, +) { + fun encode(): String = + "$SCHEME:$VERSION?h=$host&p=$port&k=${b64.encode(publicKey)}&s=${b64.encode(secret)}" + + companion object { + private const val SCHEME = "flipcash-link" + private const val VERSION = 1 + private val b64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + + fun parse(text: String): LinkInvite? { + val prefix = "$SCHEME:$VERSION?" + if (!text.startsWith(prefix)) return null + val params = text.removePrefix(prefix).split('&').mapNotNull { + val i = it.indexOf('=') + if (i <= 0) null else it.substring(0, i) to it.substring(i + 1) + }.toMap() + return runCatching { + LinkInvite( + host = params.getValue("h").takeIf { it.isNotBlank() }!!, + port = params.getValue("p").toInt().takeIf { it in 1..65535 }!!, + publicKey = b64.decode(params.getValue("k")).takeIf { it.size == LinkCrypto.KEY_BYTES }!!, + secret = b64.decode(params.getValue("s")).takeIf { it.size == LinkCrypto.SECRET_BYTES }!!, + ) + }.getOrNull() + } + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt new file mode 100644 index 0000000000..2f385fa06b --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.devicelink + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.io.encoding.Base64 +import kotlin.time.Duration.Companion.seconds + +/** + * The phone side of a link. [connect] dials the invite's address and returns the code to compare; + * the seed only leaves the phone through [Pending.confirm], after the user says the codes match. + */ +object PhoneLinker { + suspend fun connect(invite: LinkInvite): Result { + val selector = SelectorManager(Dispatchers.Default) + val phone = LinkCrypto.ephemeral() + return runCatching { + val socket = withTimeoutOrNull(10.seconds) { + aSocket(selector).tcp().connect(InetSocketAddress(invite.host, invite.port)) + } ?: error("Couldn't reach the desktop at ${invite.host}") + val channel = LinkChannel(socket) + channel.send(Frame.HELLO, byteArrayOf(Frame.PROTOCOL_VERSION) + phone.publicKey) + val session = LinkCrypto.phoneSession(phone, invite.publicKey, invite.secret) + Pending(channel, session, selector) + }.onFailure { selector.close() } + .also { phone.wipe() } + } + + class Pending internal constructor( + private val channel: LinkChannel, + private val session: LinkCrypto.Session, + private val selector: SelectorManager, + ) { + val code: String = LinkCrypto.shortCode(session) + + /** Sends the seed and waits for the desktop to confirm it opened it. */ + suspend fun confirm(entropyB64: String): Result = runCatching { + val entropy = Base64.decode(entropyB64) + try { + channel.send(Frame.SEED, LinkCrypto.seal(session.phoneToDesktop, entropy, session.transcript)) + } finally { + entropy.fill(0) + } + val ack = withTimeoutOrNull(10.seconds) { channel.receive() } + val body = ack?.takeIf { it.first == Frame.ACK } + ?.let { LinkCrypto.open(session.desktopToPhone, it.second, session.transcript) } + check(body?.contentEquals(DesktopLinkHost.ACK_BODY) == true) { "The desktop didn't confirm the link" } + }.also { close() } + + suspend fun cancel() { + runCatching { channel.send(Frame.CANCEL) } + close() + } + + private fun close() { + session.wipe() + channel.close() + selector.close() + } + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/SeedVault.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/SeedVault.kt new file mode 100644 index 0000000000..2f57d182a8 --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/SeedVault.kt @@ -0,0 +1,11 @@ +package com.flipcash.app.devicelink + +/** + * Where a signed-in device keeps its account seed (base64 entropy, as `UserManager.establish` takes + * it). Desktop backs it with the OS keyring; Android still keeps the seed in `AuthManager`. + */ +interface SeedVault { + suspend fun read(): String? + suspend fun write(entropyB64: String) + suspend fun clear() +} diff --git a/apps/flipcash/shared/device-link/src/jvmMain/kotlin/com/flipcash/app/devicelink/KeyringSeedVault.kt b/apps/flipcash/shared/device-link/src/jvmMain/kotlin/com/flipcash/app/devicelink/KeyringSeedVault.kt new file mode 100644 index 0000000000..849c7f0e9e --- /dev/null +++ b/apps/flipcash/shared/device-link/src/jvmMain/kotlin/com/flipcash/app/devicelink/KeyringSeedVault.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.devicelink + +import com.github.javakeyring.Keyring +import com.github.javakeyring.PasswordAccessException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * [SeedVault] in the OS keyring: Keychain on macOS, Credential Manager on Windows, Secret Service on + * Linux, through one library. jvmMain because each of those is a native API with no common surface. + */ +class KeyringSeedVault( + private val service: String = "com.flipcash.desktop", + private val account: String = "seed", +) : SeedVault { + override suspend fun read(): String? = withContext(Dispatchers.IO) { + Keyring.create().use { keyring -> + try { + keyring.getPassword(service, account) + } catch (_: PasswordAccessException) { + null + } + } + } + + override suspend fun write(entropyB64: String) = withContext(Dispatchers.IO) { + Keyring.create().use { it.setPassword(service, account, entropyB64) } + } + + override suspend fun clear() = withContext(Dispatchers.IO) { + Keyring.create().use { keyring -> + try { + keyring.deletePassword(service, account) + } catch (_: PasswordAccessException) { + } + } + } +} diff --git a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/KeyringSeedVaultTest.kt b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/KeyringSeedVaultTest.kt new file mode 100644 index 0000000000..7b6078ab96 --- /dev/null +++ b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/KeyringSeedVaultTest.kt @@ -0,0 +1,29 @@ +package com.flipcash.app.devicelink + +import kotlinx.coroutines.runBlocking +import kotlin.io.encoding.Base64 +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Writes a random throwaway value to the real OS keyring under a test-only service name and deletes + * it. Opt-in (`-Pflipcash.keyringTest`), because CI machines may have no unlocked keyring and a + * locked one can block on a prompt. + */ +class KeyringSeedVaultTest { + @Test + fun `round-trips through the OS keyring`() = runBlocking { + if (System.getProperty("flipcash.keyringTest") != "true") return@runBlocking + val vault = KeyringSeedVault(service = "com.flipcash.desktop.test", account = "throwaway") + val value = Base64.encode(Random.nextBytes(16)) + try { + vault.write(value) + assertEquals(value, vault.read()) + } finally { + vault.clear() + } + assertNull(vault.read()) + } +} diff --git a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt new file mode 100644 index 0000000000..78ef00f92b --- /dev/null +++ b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt @@ -0,0 +1,143 @@ +package com.flipcash.app.devicelink + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.io.encoding.Base64 +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Both sides of a link over a real loopback socket. The seed is random bytes, never a real account's. */ +class LinkProtocolTest { + private val seed = Base64.encode(Random.nextBytes(16)) + + /** Runs the desktop side, hands its invite to [phone], and returns every state the desktop emitted. */ + private fun runLink(phone: suspend (LinkInvite) -> Unit): List = runBlocking { + withTimeout(20_000) { + val states = mutableListOf() + val invites = Channel(1) + val desktop = launch(Dispatchers.Default) { + DesktopLinkHost(host = "127.0.0.1", bindHost = "127.0.0.1").link() + .onEach { states += it; if (it is DesktopLinkHost.State.Waiting) invites.send(it.invite) } + .collect() + } + phone(LinkInvite.parse(invites.receive())!!) + desktop.join() + states + } + } + + @Test + fun `codes match and the seed arrives`() { + var phoneCode: String? = null + val states = runLink { invite -> + val pending = PhoneLinker.connect(invite).getOrThrow() + phoneCode = pending.code + pending.confirm(seed).getOrThrow() + } + val confirm = states.filterIsInstance().single() + assertEquals(phoneCode, confirm.code) + assertTrue(confirm.code.matches(Regex("\\d{6}"))) + assertEquals(seed, assertIs(states.last()).entropyB64) + } + + @Test + fun `cancelling on the phone sends nothing`() { + val states = runLink { invite -> PhoneLinker.connect(invite).getOrThrow().cancel() } + assertEquals(DesktopLinkHost.State.Failed("Cancelled on the phone"), states.last()) + } + + @Test + fun `a swapped phone key changes the desktop's code`() { + // A man in the middle replaces the phone's key on the way to the desktop with one of its own. + var phoneCode: String? = null + val states = runLink { invite -> + val phone = LinkCrypto.ephemeral() + phoneCode = LinkCrypto.shortCode(LinkCrypto.phoneSession(phone, invite.publicKey, invite.secret)) + val attacker = LinkCrypto.ephemeral() + val selector = SelectorManager(Dispatchers.Default) + val channel = LinkChannel(aSocket(selector).tcp().connect(InetSocketAddress(invite.host, invite.port))) + channel.send(Frame.HELLO, byteArrayOf(Frame.PROTOCOL_VERSION) + attacker.publicKey) + // The phone, having seen a matching-looking prompt, would seal under its own session. + val session = LinkCrypto.phoneSession(phone, invite.publicKey, invite.secret) + channel.send(Frame.SEED, LinkCrypto.seal(session.phoneToDesktop, Base64.decode(seed), session.transcript)) + channel.receive() + channel.close() + selector.close() + } + assertNotEquals(phoneCode, states.filterIsInstance().single().code) + assertEquals(DesktopLinkHost.State.Failed("Couldn't verify the phone's message"), states.last()) + } + + @Test + fun `tampered seed is rejected`() { + val states = runLink { invite -> + val phone = LinkCrypto.ephemeral() + val session = LinkCrypto.phoneSession(phone, invite.publicKey, invite.secret) + val selector = SelectorManager(Dispatchers.Default) + val channel = LinkChannel(aSocket(selector).tcp().connect(InetSocketAddress(invite.host, invite.port))) + channel.send(Frame.HELLO, byteArrayOf(Frame.PROTOCOL_VERSION) + phone.publicKey) + val sealed = LinkCrypto.seal(session.phoneToDesktop, Base64.decode(seed), session.transcript) + sealed[sealed.size - 1] = (sealed.last().toInt() xor 1).toByte() + channel.send(Frame.SEED, sealed) + channel.receive() + channel.close() + selector.close() + } + assertEquals(DesktopLinkHost.State.Failed("Couldn't verify the phone's message"), states.last()) + } + + @Test + fun `a device without the QR secret can't link`() { + val states = runLink { invite -> + val pending = PhoneLinker.connect( + LinkInvite(invite.host, invite.port, invite.publicKey, LinkCrypto.secret()), + ).getOrThrow() + assertTrue(pending.confirm(seed).isFailure) + } + assertIs(states.last()) + } + + @Test + fun `invite round-trips and rejects junk`() { + val invite = LinkInvite("192.168.1.20", 50123, ByteArray(32) { it.toByte() }, ByteArray(16) { 7 }) + val parsed = LinkInvite.parse(invite.encode())!! + assertEquals(invite.host, parsed.host) + assertEquals(invite.port, parsed.port) + assertContentEquals(invite.publicKey, parsed.publicKey) + assertContentEquals(invite.secret, parsed.secret) + assertNull(LinkInvite.parse("https://example.com")) + assertNull(LinkInvite.parse(invite.encode().replace("p=50123", "p=0"))) + } + + @Test + fun `vault round-trips through the fake`() = runBlocking { + val vault = InMemorySeedVault() + assertNull(vault.read()) + vault.write(seed) + assertEquals(seed, vault.read()) + vault.clear() + assertNull(vault.read()) + } +} + +internal class InMemorySeedVault : SeedVault { + private var value: String? = null + override suspend fun read() = value + override suspend fun write(entropyB64: String) { value = entropyB64 } + override suspend fun clear() { value = null } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fec7c51eff..70cb1c5766 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -85,6 +85,9 @@ flipcash2-client-protocol = "0.20.0" # because it loads its metadata from the AAR's assets rather than from a static instance. lib-phone-number-port = "9.0.40" zxing = "3.5.4" +ktor = "3.4.1" +java-keyring = "1.0.4" +qrose = "1.3.1" androidx-benchmark-macro = "1.5.0" androidx-profileinstaller = "1.4.1" @@ -282,6 +285,9 @@ lib-phone-number-port = { module = "io.michaelrocks:libphonenumber-android", ver # QR / Scanning zxing = { module = "com.google.zxing:core", version.ref = "zxing" } +ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" } +java-keyring = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keyring" } +qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" } # Logging / Error Tracking timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 98137caca5..567d7bca20 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -120,6 +120,7 @@ include( ":apps:flipcash:core-ui", ":apps:flipcash:card-expand", ":apps:flipcash:startup-contract", + ":apps:flipcash:shared:device-link", ":libs:test-utils", // shared flipcash coordinators/controllers/viewmodels/services ":apps:flipcash:shared:amount-entry", From b3866b27a98b07a8b742c5dfdcbc3256e230b803 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 16:48:25 -0400 Subject: [PATCH 2/5] feat(desktop): sign in from the linked seed or show the link screen At launch the desktop signs in with the dev phrase if one is set, else with the seed a phone linked into the Keychain. With neither, it shows DesktopLinkScreen; when the phone sends the seed, the desktop writes it to the vault and signs in. The dev phrase is never written to the vault. The screens (DesktopLinkScreen, PhoneLinkScreen) and the phone's state holder (PhoneLinkController) are in device-link's commonMain. The phone screen takes the camera as a composable slot. PhoneLinkController reads the seed only after the user confirms the codes match; a new loopback test checks that. Two pieces stay in the desktop app (JVM-only): - DesktopSession: FlipcashDatabase.init takes a java.io.File. - lanAddress(): picks the address the phone dials from java.net.NetworkInterface. Only the desktop needs it. `snapshot -Pscreen=link` renders the link screen without signing in. --- apps/desktop/build.gradle.kts | 10 ++- .../kotlin/com/flipcash/desktop/DesktopApp.kt | 68 +++++++++++++++ .../com/flipcash/desktop/DesktopGraph.kt | 1 + .../com/flipcash/desktop/DesktopModule.kt | 6 ++ .../com/flipcash/desktop/DesktopSession.kt | 64 ++++++++++++++ .../kotlin/com/flipcash/desktop/DevSession.kt | 45 ---------- .../main/kotlin/com/flipcash/desktop/Main.kt | 6 +- .../com/flipcash/desktop/SnapshotMain.kt | 8 +- .../app/devicelink/PhoneLinkController.kt | 67 +++++++++++++++ .../app/devicelink/ui/DesktopLinkScreen.kt | 83 +++++++++++++++++++ .../app/devicelink/ui/PhoneLinkScreen.kt | 81 ++++++++++++++++++ .../app/devicelink/LinkProtocolTest.kt | 21 +++++ 12 files changed, 406 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopSession.kt delete mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/DevSession.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/DesktopLinkScreen.kt create mode 100644 apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/PhoneLinkScreen.kt diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 9fd564a8f6..280c819fd3 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(project(":libs:locale:public")) implementation(project(":libs:network:connectivity:public")) implementation(project(":libs:encryption:chat-cipher")) + implementation(project(":apps:flipcash:shared:device-link")) implementation(libs.kotlinx.coroutines.swing) implementation(libs.dagger) implementation(libs.grpc.okhttp) @@ -59,7 +60,7 @@ tasks.withType().configureEach { systemProperty("opencode.processLifecycle.autoStart", "false") } -// Dev-only sign-in phrase (see DevSession), forwarded only when local.properties has one. It goes in +// Dev-only sign-in phrase (see DesktopSession), forwarded only when local.properties has one. It goes in // the environment, not a -D flag, so it stays out of the process's command line. val devMnemonic = tryReadProperty(rootProject.rootDir, "flipcash.test.mnemonic", "") .takeIf { it.isNotBlank() } @@ -77,10 +78,13 @@ compose.desktop { tasks.register("snapshot") { group = "application" - description = "Renders the wallet screen off-screen to a PNG (-Pout=)." + description = "Renders the wallet (or, with -Pscreen=link, the link screen) off-screen to a PNG (-Pout=)." classpath = sourceSets["main"].runtimeClasspath mainClass.set("com.flipcash.desktop.SnapshotMainKt") - args(providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("wallet.png").get().asFile.path)) + args( + providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("wallet.png").get().asFile.path), + providers.gradleProperty("screen").getOrElse("wallet"), + ) } tasks.withType().configureEach { diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt new file mode 100644 index 0000000000..41200a7782 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt @@ -0,0 +1,68 @@ +package com.flipcash.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.flipcash.app.devicelink.DesktopLinkHost +import com.flipcash.app.devicelink.ui.DesktopLinkScreen +import com.flipcash.app.theme.FlipcashTheme +import com.getcode.theme.CodeTheme +import java.net.Inet4Address +import java.net.NetworkInterface + +/** The wallet when signed in; otherwise the link screen, which stores the phone's seed and signs in. */ +@Composable +fun DesktopApp(graph: DesktopGraph, signedIn: Boolean) { + var linked by remember { mutableStateOf(signedIn) } + if (linked) { + DesktopWalletContent(graph) + } else { + DesktopLinkContent(graph, onSignedIn = { linked = true }) + } +} + +@Composable +fun DesktopLinkContent(graph: DesktopGraph, onSignedIn: () -> Unit) { + var attempt by remember { mutableIntStateOf(0) } + var state by remember { mutableStateOf(null) } + LaunchedEffect(attempt) { + val host = lanAddress() + if (host == null) { + state = DesktopLinkHost.State.Failed("This computer isn't on a local network") + return@LaunchedEffect + } + DesktopLinkHost(host).link().collect { next -> + state = next + if (next is DesktopLinkHost.State.Linked) { + graph.seedVault.write(next.entropyB64) + graph.signIn(next.entropyB64) + .onSuccess { onSignedIn() } + .onFailure { state = DesktopLinkHost.State.Failed("Linked, but sign-in failed: ${it.message}") } + } + } + } + FlipcashTheme { + Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) { + DesktopLinkScreen(state, onRetry = { attempt++ }) + } + } +} + +/** + * The address the phone dials: the first site-local IPv4 address on an interface that is up. The + * one piece of linking that stays in the desktop app, since the phone never needs its own address. + */ +internal fun lanAddress(): String? = NetworkInterface.getNetworkInterfaces().asSequence() + .filter { it.isUp && !it.isLoopback && !it.isVirtual } + .flatMap { it.inetAddresses.asSequence() } + .filterIsInstance() + .firstOrNull { it.isSiteLocalAddress } + ?.hostAddress diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt index 42fa4573bd..43b60dd831 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt @@ -39,6 +39,7 @@ interface DesktopGraph { val analytics: FlipcashAnalytics val userManager: com.flipcash.services.user.UserManager val accountController: com.flipcash.services.controllers.AccountController + val seedVault: com.flipcash.app.devicelink.SeedVault // Android constructs this eagerly in FlipcashApp.onCreate; it is what turns Events.OnLoggedIn into // SessionListener.onUserLoggedIn calls. diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt index f984eb8a38..a276992220 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt @@ -5,6 +5,8 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.TipHistorySource import com.flipcash.app.core.storage.DirectoryPreferencesFiles import com.flipcash.app.core.storage.PreferencesFiles +import com.flipcash.app.devicelink.KeyringSeedVault +import com.flipcash.app.devicelink.SeedVault import com.flipcash.app.funding.PurchaseMethod import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.funding.PurchaseMethodMetadata @@ -49,6 +51,10 @@ object DesktopModule { File(System.getProperty("user.home"), ".flipcash").apply { mkdirs() } } + @Provides + @Singleton + fun seedVault(): SeedVault = KeyringSeedVault() + @Provides @Singleton fun dispatchers(): DispatcherProvider = object : DispatcherProvider { diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopSession.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopSession.kt new file mode 100644 index 0000000000..5c7254ecb9 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopSession.kt @@ -0,0 +1,64 @@ +package com.flipcash.desktop + +import com.flipcash.app.devicelink.SeedVault +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.init +import com.flipcash.services.controllers.AccountController +import com.flipcash.services.user.AuthState +import com.flipcash.services.user.UserManager +import com.getcode.crypt.MnemonicPhrase +import java.io.File + +/** + * Signs the desktop in from a seed: the one a phone linked into the [SeedVault], or, for development, + * a recovery phrase in `FLIPCASH_DEV_MNEMONIC` (set by Gradle from `flipcash.test.mnemonic` in + * local.properties, only when present). The dev phrase is never written to the vault or logged. + * + * Kept in the desktop app: [FlipcashDatabase.init] takes a [File], and the app module is JVM-only. + */ +internal object DesktopSession { + private const val VARIABLE = "FLIPCASH_DEV_MNEMONIC" + + /** The dev phrase's entropy, or null when none is configured. */ + fun devEntropy(): String? { + val words = System.getenv(VARIABLE)?.trim()?.split(Regex("\\s+"))?.takeIf { it.size > 1 } ?: return null + return MnemonicPhrase.newInstance(words)?.getBase64EncodedEntropy() + ?: run { println("dev session: $VARIABLE is not a 12 or 24 word phrase"); null } + } + + /** The same steps `AuthManager.login` walks for an account that has finished onboarding. */ + suspend fun signIn( + entropyB64: String, + userManager: UserManager, + accountController: AccountController, + dataDir: File, + ): Result { + userManager.establish(entropyB64) + return accountController.login().map { accountId -> + FlipcashDatabase.init(dataDir, entropyB64) + userManager.set(accountId = accountId) + userManager.set(AuthState.Ready) + } + } +} + +internal suspend fun DesktopGraph.signIn(entropyB64: String): Result = + DesktopSession.signIn(entropyB64, userManager, accountController, DesktopModule.appDir) + +/** + * Signs in at launch with the dev phrase if one is configured, else with the seed a phone linked + * earlier. Returns false when neither is there, and the app shows the link screen. Run [startup] first. + */ +internal suspend fun DesktopGraph.restoreSession(): Boolean { + DesktopSession.devEntropy()?.let { entropy -> + return signIn(entropy) + .onSuccess { println("session: signed in (dev phrase)") } + .onFailure { println("session: dev sign-in failed (${it.message})") } + .isSuccess + } + val stored = seedVault.read() ?: return false.also { println("session: not linked") } + return signIn(stored) + .onSuccess { println("session: signed in (linked seed)") } + .onFailure { println("session: linked sign-in failed (${it.message})") } + .isSuccess +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DevSession.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DevSession.kt deleted file mode 100644 index 5663043352..0000000000 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DevSession.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flipcash.desktop - -import com.flipcash.app.persistence.FlipcashDatabase -import com.flipcash.app.persistence.init -import com.flipcash.services.controllers.AccountController -import com.flipcash.services.user.AuthState -import com.flipcash.services.user.UserManager -import com.getcode.crypt.MnemonicPhrase -import java.io.File - -/** - * Development-only sign-in until the link-from-phone flow lands: reads a recovery phrase from the - * `FLIPCASH_DEV_MNEMONIC` environment variable (set by Gradle from `flipcash.test.mnemonic` in - * local.properties, only when present) and walks the same steps `AuthManager.login` does for an - * account that has finished onboarding. The phrase is never logged. - */ -internal object DevSession { - private const val VARIABLE = "FLIPCASH_DEV_MNEMONIC" - - suspend fun signIn( - userManager: UserManager, - accountController: AccountController, - dataDir: File, - ): Result { - val words = System.getenv(VARIABLE)?.trim()?.split(Regex("\\s+")) - ?.takeIf { it.size > 1 } - ?: return Result.failure(IllegalStateException("$VARIABLE not set")) - val entropy = MnemonicPhrase.newInstance(words)?.getBase64EncodedEntropy() - ?: return Result.failure(IllegalArgumentException("$VARIABLE is not a 12 or 24 word phrase")) - - userManager.establish(entropy) - return accountController.login().map { accountId -> - FlipcashDatabase.init(dataDir, entropy) - userManager.set(accountId = accountId) - userManager.set(AuthState.Ready) - } - } -} - -/** Signs in with the dev phrase when one is configured; otherwise the wallet stays signed out. Run [startup] first. */ -internal suspend fun DesktopGraph.devSignIn() { - DevSession.signIn(userManager, accountController, DesktopModule.appDir) - .onSuccess { println("dev session: signed in") } - .onFailure { println("dev session: not signed in (${it.message})") } -} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index df61615149..8bcda37187 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -11,9 +11,9 @@ import java.lang.management.ManagementFactory fun main() { val graph = createGraph() - runBlocking { + val signedIn = runBlocking { graph.startup() - graph.devSignIn() + graph.restoreSession() } application { Window( @@ -21,7 +21,7 @@ fun main() { title = "Flipcash", state = rememberWindowState(width = 420.dp, height = 860.dp), ) { - DesktopWalletContent(graph) + DesktopApp(graph, signedIn) LaunchedEffect(Unit) { val sinceJvmStart = System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().startTime diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt index fa8d2dac4b..3f30a1de54 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt @@ -11,17 +11,19 @@ import org.jetbrains.skia.EncodedImageFormat /** * Renders the wallet screen off-screen through the same graph as [main] and writes a PNG. - * Usage: `./gradlew :apps:desktop:snapshot -Pout=`. + * Usage: `./gradlew :apps:desktop:snapshot -Pout=`; add `-Pscreen=link` for the link screen, + * which skips sign-in. */ fun main(args: Array) { val out = File(args.firstOrNull() ?: "wallet.png") + val linkScreen = args.getOrNull(1) == "link" val graph = createGraph() runBlocking { graph.startup() - graph.devSignIn() + if (!linkScreen) graph.restoreSession() } ImageComposeScene(width = 840, height = 1720, density = Density(2f)) { - DesktopWalletContent(graph) + if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopWalletContent(graph) }.use { scene -> var nanos = 0L repeat(80) { diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt new file mode 100644 index 0000000000..42a0118e16 --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.devicelink + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * The phone's link flow as state: scan, compare codes, send. [seed] supplies the signed-in + * account's base64 entropy and is read only after the user confirms the codes match. + */ +class PhoneLinkController( + private val scope: CoroutineScope, + private val seed: suspend () -> String?, +) { + sealed interface State { + data object Scanning : State + data object Connecting : State + data class Confirm(val code: String) : State + data object Sending : State + data object Linked : State + data class Failed(val reason: String) : State + } + + private val _state = MutableStateFlow(State.Scanning) + val state: StateFlow = _state.asStateFlow() + private var pending: PhoneLinker.Pending? = null + + fun onScanned(text: String) { + if (_state.value != State.Scanning) return + val invite = LinkInvite.parse(text) ?: return + _state.value = State.Connecting + scope.launch { + PhoneLinker.connect(invite) + .onSuccess { + pending = it + _state.value = State.Confirm(it.code) + } + .onFailure { _state.value = State.Failed(it.message ?: "Couldn't reach the desktop") } + } + } + + fun confirm() { + val p = pending ?: return + pending = null + _state.value = State.Sending + scope.launch { + val entropy = seed() + if (entropy == null) { + p.cancel() + _state.value = State.Failed("This phone isn't signed in") + return@launch + } + p.confirm(entropy) + .onSuccess { _state.value = State.Linked } + .onFailure { _state.value = State.Failed(it.message ?: "The link failed") } + } + } + + fun cancel() { + val p = pending + pending = null + scope.launch { p?.cancel() } + _state.value = State.Scanning + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/DesktopLinkScreen.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/DesktopLinkScreen.kt new file mode 100644 index 0000000000..0f94f2d2b7 --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/DesktopLinkScreen.kt @@ -0,0 +1,83 @@ +package com.flipcash.app.devicelink.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flipcash.app.devicelink.DesktopLinkHost +import com.getcode.theme.CodeTheme +import io.github.alexzhirkevich.qrose.rememberQrCodePainter + +/** What the desktop shows while linking: the QR code, then the code to compare with the phone. */ +@Composable +fun DesktopLinkScreen(state: DesktopLinkHost.State?, onRetry: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + when (state) { + null, is DesktopLinkHost.State.Linked -> CircularProgressIndicator(color = CodeTheme.colors.textMain) + is DesktopLinkHost.State.Waiting -> { + Title("Link this computer") + Body("On your phone, open Settings, tap Link a Desktop, and scan this code.") + Spacer(Modifier.height(24.dp)) + Image( + painter = rememberQrCodePainter(state.invite), + contentDescription = "Link code", + modifier = Modifier + .size(240.dp) + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(12.dp), + ) + } + is DesktopLinkHost.State.Confirm -> { + Title("Check the code") + Body("Confirm on your phone that it shows the same code.") + Spacer(Modifier.height(24.dp)) + Code(state.code) + } + is DesktopLinkHost.State.Failed -> { + Title("Couldn't link") + Body(state.reason) + Spacer(Modifier.height(24.dp)) + Button(onClick = onRetry) { Text("Try again") } + } + } + } +} + +@Composable +internal fun Title(text: String) { + Text(text, style = CodeTheme.typography.displaySmall, color = CodeTheme.colors.textMain, textAlign = TextAlign.Center) + Spacer(Modifier.height(12.dp)) +} + +@Composable +internal fun Body(text: String) { + Text(text, style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center) +} + +@Composable +internal fun Code(code: String) { + Text( + "${code.take(3)} ${code.drop(3)}", + style = CodeTheme.typography.displayLarge, + color = CodeTheme.colors.textMain, + ) +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/PhoneLinkScreen.kt b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/PhoneLinkScreen.kt new file mode 100644 index 0000000000..5d4a1daaeb --- /dev/null +++ b/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/ui/PhoneLinkScreen.kt @@ -0,0 +1,81 @@ +package com.flipcash.app.devicelink.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.flipcash.app.devicelink.PhoneLinkController +import com.getcode.theme.CodeTheme + +/** + * The phone's "Link a Desktop" screen. [scanner] is the platform camera: it calls its argument with + * each QR payload it reads. Everything else here is common. + */ +@Composable +fun PhoneLinkScreen( + controller: PhoneLinkController, + scanner: @Composable (onScanned: (String) -> Unit) -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val state by controller.state.collectAsState() + when (val s = state) { + PhoneLinkController.State.Scanning -> Box(modifier.fillMaxSize()) { + scanner(controller::onScanned) + Column(Modifier.align(Alignment.TopCenter).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Title("Link a Desktop") + Body("Scan the code shown on your computer.") + } + } + else -> Column( + modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + when (s) { + PhoneLinkController.State.Connecting, PhoneLinkController.State.Sending -> + CircularProgressIndicator(color = CodeTheme.colors.textMain) + is PhoneLinkController.State.Confirm -> { + Title("Does your computer show this code?") + Body("Only confirm if the codes match. Confirming gives the computer full access to this account.") + Spacer(Modifier.height(24.dp)) + Code(s.code) + Spacer(Modifier.height(32.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + OutlinedButton(onClick = controller::cancel) { Text("They don't match") } + Spacer(Modifier.width(12.dp)) + Button(onClick = controller::confirm) { Text("Codes match") } + } + } + PhoneLinkController.State.Linked -> { + Title("Desktop linked") + Spacer(Modifier.height(24.dp)) + Button(onClick = onDone) { Text("Done") } + } + is PhoneLinkController.State.Failed -> { + Title("Couldn't link") + Body(s.reason) + Spacer(Modifier.height(24.dp)) + Button(onClick = controller::cancel) { Text("Scan again") } + } + PhoneLinkController.State.Scanning -> Unit + } + } + } +} diff --git a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt index 78ef00f92b..b7c19b74b8 100644 --- a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt +++ b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -133,6 +134,26 @@ class LinkProtocolTest { vault.clear() assertNull(vault.read()) } + + @Test + fun `the phone controller reads the seed only after confirm`() { + var seedReads = 0 + val states = runLink { invite -> + kotlinx.coroutines.coroutineScope { + val controller = PhoneLinkController(this) { seedReads++; seed } + controller.onScanned(invite.encode()) + val confirm = withTimeout(10_000) { + controller.state.first { it is PhoneLinkController.State.Confirm } + } + assertIs(confirm) + assertEquals(0, seedReads) + controller.confirm() + withTimeout(10_000) { controller.state.first { it == PhoneLinkController.State.Linked } } + } + } + assertEquals(1, seedReads) + assertEquals(seed, assertIs(states.last()).entropyB64) + } } internal class InMemorySeedVault : SeedVault { From 0be26c7f7a221e4e131161c09c8e0f9d418f9f71 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 16:52:39 -0400 Subject: [PATCH 3/5] feat(myaccount): add Link a Desktop behind the LinkDesktop flag Settings > Advanced shows Link a Desktop when FeatureFlag.LinkDesktop is on (default off, Internal track). The screen is device-link's common PhoneLinkScreen and PhoneLinkController; LinkDesktopScreen only adds the Android pieces: a Hilt view model that reads the entropy from UserManager on confirm, the camera permission, and the ML Kit CodeScanner from ui:scanner. It lives in features/myaccount rather than device-link's androidMain because the scanner, the permission helpers and the settings navigation are all Android-only modules that myaccount already sits on. --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 2 + .../features/myaccount/build.gradle.kts | 3 ++ .../app/myaccount/LinkDesktopScreen.kt | 49 +++++++++++++++++++ .../linkdesktop/LinkDesktopViewModel.kt | 20 ++++++++ .../internal/settings/SettingsMenuItems.kt | 12 +++++ .../internal/settings/SettingsViewModel.kt | 2 +- .../flipcash/app/featureflags/FeatureFlag.kt | 12 +++++ 8 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/LinkDesktopScreen.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/linkdesktop/LinkDesktopViewModel.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 76d35f1a3f..4d247dd7a8 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -20,6 +20,7 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.devicelogs.DeviceLogsScreen +import com.flipcash.app.myaccount.LinkDesktopScreen import com.flipcash.app.backupkey.BackupKeyScreen import com.flipcash.app.balance.WalletScreen import com.flipcash.app.cash.CashScreen @@ -219,6 +220,7 @@ internal fun appEntryProvider( annotatedEntry { TrustedWebsitesScreen() } annotatedEntry { BackupKeyScreen() } annotatedEntry { DeviceLogsScreen() } + annotatedEntry { LinkDesktopScreen() } annotatedEntry { AccountSelectionScreen() } annotatedEntry { UserFlagsScreen() } diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt index 52b268b1d9..02fef9e993 100644 --- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt @@ -332,6 +332,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object DeviceLogs : Menu @Serializable + data object LinkDesktop : Menu + @Serializable data object AccountSelection : Menu @Serializable data object UserProfile : Menu diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index 781f5d7348..ea775c6bae 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:contacts")) + implementation(project(":apps:flipcash:shared:device-link")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:menu")) @@ -36,4 +37,6 @@ dependencies { implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) implementation(project(":ui:biometrics")) + implementation(project(":ui:scanner")) + implementation(project(":libs:code-detection")) } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/LinkDesktopScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/LinkDesktopScreen.kt new file mode 100644 index 0000000000..c96fccbf2f --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/LinkDesktopScreen.kt @@ -0,0 +1,49 @@ +package com.flipcash.app.myaccount + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.flipcash.app.devicelink.ui.PhoneLinkScreen +import com.flipcash.app.myaccount.internal.linkdesktop.LinkDesktopViewModel +import com.getcode.libs.code.detection.CodeScanResult +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.scanner.CodeScanner +import com.getcode.ui.scanner.views.CameraDisabledView +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.rememberCameraPermission + +/** + * Android entry for linking a desktop. The flow and screens are common (device-link); this file + * only supplies what is Android-specific: the Hilt view model, the camera permission and the ML Kit + * scanner. + */ +@Composable +fun LinkDesktopScreen() { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + PhoneLinkScreen( + controller = viewModel.controller, + scanner = { onScanned -> QrScanner(onScanned) }, + onDone = { navigator.pop() }, + ) +} + +@Composable +private fun QrScanner(onScanned: (String) -> Unit) { + val camera = rememberCameraPermission() + if (camera.status != PermissionResult.Granted) { + CameraDisabledView(modifier = Modifier.fillMaxSize()) { camera.launch() } + return + } + CodeScanner( + scanningEnabled = true, + cameraGesturesEnabled = false, + modifier = Modifier.fillMaxSize(), + onPinchStateChanged = { _, _ -> }, + onPreviewStateChanged = { }, + onCodeScanned = { result -> + if (result is CodeScanResult.QrCode) result.results.forEach(onScanned) + }, + ) +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/linkdesktop/LinkDesktopViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/linkdesktop/LinkDesktopViewModel.kt new file mode 100644 index 0000000000..5989b04096 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/linkdesktop/LinkDesktopViewModel.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.myaccount.internal.linkdesktop + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.devicelink.PhoneLinkController +import com.flipcash.services.user.UserManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** Holds the common [PhoneLinkController] for the screen's lifetime; the seed is read on confirm. */ +@HiltViewModel +internal class LinkDesktopViewModel @Inject constructor( + userManager: UserManager, +) : ViewModel() { + val controller = PhoneLinkController(viewModelScope) { userManager.state.value.entropy } + + override fun onCleared() { + controller.cancel() + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt index 9e88254eb9..08ea883be5 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt @@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Computer import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Palette @@ -14,6 +15,7 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.menu.FullMenuItem import com.flipcash.app.menu.StaffMenuItem import com.flipcash.core.R as CoreR @@ -74,6 +76,16 @@ internal data object DeviceLogs : FullMenuItem() { override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.DeviceLogs) } +internal data object LinkDesktop : FullMenuItem( + featureFlag = FeatureFlag.LinkDesktop, +) { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Computer) + override val name: String + @Composable get() = "Link a Desktop" + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.LinkDesktop) +} + internal data object BetaFlags : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Filled.Science) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt index 1be724447e..82397364da 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -289,7 +289,7 @@ internal class SettingsViewModel @Inject constructor( SettingsSection(R.string.title_settingsSectionPrivacy, listOf(Blocklist, TrustedWebsites)), SettingsSection( R.string.title_advancedFeatures, - listOf(/* BillCustomizer, */ DeviceLogs, BetaFlags, SwitchAccount), + listOf(/* BillCustomizer, */ DeviceLogs, LinkDesktop, BetaFlags, SwitchAccount), ), SettingsSection( R.string.title_settingsSectionAccount, diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 00e330d8dc..d1cee81833 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -88,6 +88,16 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + /** Settings > Link a Desktop: hands this account's seed to a desktop over the LAN. */ + @FeatureFlagMarker + data object LinkDesktop: FeatureFlag { + override val key: String = "link_desktop_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -107,6 +117,7 @@ val FeatureFlag<*>.title: String FeatureFlag.BillTextures -> "Bill Textures" FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" + FeatureFlag.LinkDesktop -> "Link a Desktop" } val FeatureFlag<*>.message: String @@ -117,4 +128,5 @@ val FeatureFlag<*>.message: String FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" + FeatureFlag.LinkDesktop -> "When enabled, Settings shows Link a Desktop, which sends this account to the Flipcash desktop app over your local network" } From b30bed1e0a74c2a3182c0e3cf0449dac3b9cd264 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 16:54:12 -0400 Subject: [PATCH 4/5] refactor(login): take the onboarding purchase step from the app features/login imported two internal symbols from features/purchase (PurchaseAccountScreenContent and PurchaseAccountViewModel) to show the registration purchase inside onboarding. OnboardingFlowScreen now takes an OnboardingPurchaseStep composable, the app passes purchase's new OnboardingPurchaseStep, and login no longer depends on the purchase module. Both modules stay src/main: neither is KMP yet, and converting them is outside this cut. --- .../ui/navigation/AppScreenContent.kt | 7 +++- apps/flipcash/features/login/build.gradle.kts | 1 - .../app/login/OnboardingFlowScreen.kt | 36 +++++++++---------- .../app/purchase/PurchaseAccountScreen.kt | 26 ++++++++++++++ 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 4d247dd7a8..8ed8e6f0f5 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -21,6 +21,7 @@ import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.devicelogs.DeviceLogsScreen import com.flipcash.app.myaccount.LinkDesktopScreen +import com.flipcash.app.purchase.OnboardingPurchaseStep import com.flipcash.app.backupkey.BackupKeyScreen import com.flipcash.app.balance.WalletScreen import com.flipcash.app.cash.CashScreen @@ -103,7 +104,11 @@ internal fun appEntryProvider( // Onboarding flow annotatedEntry { key -> - OnboardingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + OnboardingFlowScreen( + route = key, + resultStateRegistry = resultStateRegistry, + purchaseStep = { onAccountCreated -> OnboardingPurchaseStep(onAccountCreated) }, + ) } // Main diff --git a/apps/flipcash/features/login/build.gradle.kts b/apps/flipcash/features/login/build.gradle.kts index 0fffe64486..328ab6e8fa 100644 --- a/apps/flipcash/features/login/build.gradle.kts +++ b/apps/flipcash/features/login/build.gradle.kts @@ -21,7 +21,6 @@ dependencies { implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:permissions")) implementation(project(":apps:flipcash:shared:userflags")) - implementation(project(":apps:flipcash:features:purchase")) implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 489a50c9e3..a5dff56979 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -50,8 +50,6 @@ import com.flipcash.app.permissions.asContactAccessHandle import com.flipcash.app.permissions.internal.contacts.ContactScreenContent import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent -import com.flipcash.app.purchase.internal.PurchaseAccountScreenContent -import com.flipcash.app.purchase.internal.PurchaseAccountViewModel import com.flipcash.features.login.R import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager @@ -118,17 +116,24 @@ import kotlin.time.Duration.Companion.milliseconds fun OnboardingFlowScreen( route: AppRoute.OnboardingFlow, resultStateRegistry: NavResultStateRegistry, + purchaseStep: OnboardingPurchaseStep, ) { when { route.phase == AppRoute.OnboardingFlow.Phase.Permissions -> - PermissionsPhaseFlowHost(route, resultStateRegistry) + PermissionsPhaseFlowHost(route, resultStateRegistry, purchaseStep) route.resumeAt == AppRoute.OnboardingFlow.ResumePoint.PostAccessKey -> CompleteExistingUserOnboarding() else -> - AccountPhaseFlowHost(route, resultStateRegistry) + AccountPhaseFlowHost(route, resultStateRegistry, purchaseStep) } } +/** + * The registration purchase screen, supplied by the app so this module doesn't depend on + * features/purchase. It calls `onAccountCreated` once the account is paid for. + */ +typealias OnboardingPurchaseStep = @Composable (onAccountCreated: () -> Unit) -> Unit + @Composable private fun CompleteExistingUserOnboarding() { val navigator = LocalCodeNavigator.current @@ -147,6 +152,7 @@ private fun CompleteExistingUserOnboarding() { private fun PermissionsPhaseFlowHost( route: AppRoute.OnboardingFlow, resultStateRegistry: NavResultStateRegistry, + purchaseStep: OnboardingPurchaseStep, ) { val outerNavigator = LocalCodeNavigator.current val checker = LocalPermissionChecker.current @@ -208,7 +214,7 @@ private fun PermissionsPhaseFlowHost( FlowExitReason.Canceled -> Unit } }, - entryProvider = onboardingEntryProvider(route), + entryProvider = onboardingEntryProvider(route, purchaseStep), ) } @@ -216,6 +222,7 @@ private fun PermissionsPhaseFlowHost( private fun AccountPhaseFlowHost( route: AppRoute.OnboardingFlow, resultStateRegistry: NavResultStateRegistry, + purchaseStep: OnboardingPurchaseStep, ) { val outerNavigator = LocalCodeNavigator.current @@ -235,7 +242,7 @@ private fun AccountPhaseFlowHost( FlowExitReason.Canceled -> Unit } }, - entryProvider = onboardingEntryProvider(route), + entryProvider = onboardingEntryProvider(route, purchaseStep), ) } @@ -294,6 +301,7 @@ private fun FlowNavigator.proceedToNameOrPermi private fun onboardingEntryProvider( route: AppRoute.OnboardingFlow, + purchaseStep: OnboardingPurchaseStep, ): (NavKey) -> NavEntry = entryProvider { // The pre-login landing; flows/screenshots anchor on `login_screen` rather than // the step-derived `start_screen`. @@ -313,7 +321,7 @@ private fun onboardingEntryProvider( PhotoAccessKeyScreen() } annotatedEntry { - PurchaseStepContent() + PurchaseStepContent(purchaseStep) } annotatedEntry { ContactPermissionStepContent() @@ -539,25 +547,15 @@ private fun AccessKeyStepContent() { } @Composable -private fun PurchaseStepContent() { - val viewModel = hiltViewModel() +private fun PurchaseStepContent(purchaseStep: OnboardingPurchaseStep) { val flowNavigator = rememberFlowNavigator() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() val userManager = LocalUserManager.current - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { flowNavigator.proceedToNameOrPermissions(userManager) } - .launchIn(this) - } - Column { AppBarWithTitle() - PurchaseAccountScreenContent(state, viewModel::dispatchEvent) + purchaseStep { flowNavigator.proceedToNameOrPermissions(userManager) } } - BackHandler { /* swallow back during onboarding permissions */ } } diff --git a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt index 5ae4a70f4f..afccad1cd3 100644 --- a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt +++ b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt @@ -2,6 +2,15 @@ package com.flipcash.app.purchase import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.purchase.internal.PurchaseAccountScreenContent +import com.flipcash.app.purchase.internal.PurchaseAccountViewModel +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.purchase.internal.PurchaseAccountScreen import com.getcode.navigation.core.LocalCodeNavigator @@ -25,3 +34,20 @@ fun PurchaseAccountScreen( PurchaseAccountScreen(hiltViewModel()) } } + +/** The purchase step inside onboarding; the app passes it to OnboardingFlowScreen. */ +@Composable +fun OnboardingPurchaseStep(onAccountCreated: () -> Unit) { + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val currentOnAccountCreated by rememberUpdatedState(onAccountCreated) + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { currentOnAccountCreated() } + .launchIn(this) + } + + PurchaseAccountScreenContent(state, viewModel::dispatchEvent) +} From d816647ec8a926620edbcd081c455860d3bec014 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 16:59:16 -0400 Subject: [PATCH 5/5] test(login): mock Base64.encode as well as encodeToString encodeBase64 now goes through android.util.Base64.encode (the shared base64Encode actual from the desktop spike's jvm target). The login view model tests only mocked encodeToString, so the Android stub returned null and the wrong-entropy-size test never saw its error. It passes on code/cash. --- .../flipcash/app/login/router/LoginViewModelCreateAccountTest.kt | 1 + .../com/flipcash/app/login/router/LoginViewModelErrorTest.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt index 4460ca1a51..6c89de23b3 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt @@ -58,6 +58,7 @@ class LoginViewModelCreateAccountTest { BottomBarManager.clear() mockkStatic(android.util.Base64::class) every { android.util.Base64.encodeToString(any(), any()) } answers { java.util.Base64.getEncoder().encodeToString(firstArg()) } + every { android.util.Base64.encode(any(), any()) } answers { java.util.Base64.getEncoder().encode(firstArg()) } // LoginViewModel.init checks the stored account list on construction. whenever(authManager.accounts).thenReturn(accountStore) } diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt index 9af3cb41df..d47817f103 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt @@ -54,6 +54,7 @@ class LoginViewModelErrorTest { // android.util.Base64 is stubbed in unit tests; mock it so encodeBase64() doesn't NPE mockkStatic(android.util.Base64::class) every { android.util.Base64.encodeToString(any(), any()) } answers { java.util.Base64.getEncoder().encodeToString(firstArg()) } + every { android.util.Base64.encode(any(), any()) } answers { java.util.Base64.getEncoder().encode(firstArg()) } // LoginViewModel.init checks the stored account list on construction. whenever(authManager.accounts).thenReturn(accountStore) }