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/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..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 @@ -20,6 +20,8 @@ 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.purchase.OnboardingPurchaseStep import com.flipcash.app.backupkey.BackupKeyScreen import com.flipcash.app.balance.WalletScreen import com.flipcash.app.cash.CashScreen @@ -102,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 @@ -219,6 +225,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/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/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) } 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/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) +} 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/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/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/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/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..b7c19b74b8 --- /dev/null +++ b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt @@ -0,0 +1,164 @@ +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.first +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()) + } + + @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 { + 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/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" } 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",