Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
c2b242e
build(kmp): declare shared targets in flipcash.kmp.library
bmc08gt Oct 23, 2026
f2508ca
feat(kmp): add a jvm() target to the shared libraries
bmc08gt Oct 23, 2026
1d367b5
build(kmp): add flipcash.kmp.app.library for android+jvm modules
bmc08gt Oct 23, 2026
46b4b59
build(kmp): move the leaf libraries to android+jvm
bmc08gt Oct 23, 2026
5d41abf
build(parcelable): split hand-written parcel members into ManualParce…
bmc08gt Oct 23, 2026
0ddf461
build(logging): move libs:logging to android+jvm
bmc08gt Oct 23, 2026
6e5146a
build(resources): move ui:resources to android + jvm
bmc08gt Oct 23, 2026
bad0678
build(kmp): move currency, models, locale and connectivity APIs to an…
bmc08gt Oct 23, 2026
fa514cb
build(opencode): move services:opencode to the android+jvm KMP plugin
bmc08gt Oct 23, 2026
451e56a
refactor(services): move flipcash services to android+jvm KMP
bmc08gt Oct 23, 2026
36386fa
build(persistence): move the Room database to android+jvm KMP
bmc08gt Oct 23, 2026
074c62e
build(core): move apps:flipcash:core to android+jvm KMP
bmc08gt Oct 23, 2026
3f90e29
build(persistence): move persistence:sources to android+jvm KMP
bmc08gt Oct 23, 2026
d245059
build(tokens): move tokens:core to android+jvm KMP
bmc08gt Oct 23, 2026
d25809b
build(desktop): add the Compose Desktop shell and Android res as Comp…
bmc08gt Oct 23, 2026
ae1ec91
build(theme): move ui:theme to android+jvm KMP
bmc08gt Oct 23, 2026
82d2635
build(ui): move ui:core and ui:components to android+jvm KMP
bmc08gt Oct 23, 2026
80adf96
build(navigation): move ui:navigation to android+jvm KMP
bmc08gt Oct 23, 2026
6672b93
build(analytics): move shared:analytics to android+jvm KMP
bmc08gt Oct 23, 2026
924470c
build(userflags): move shared:userflags to android+jvm KMP
bmc08gt Oct 23, 2026
90afa5e
build(core): lift AppRoute and flow step types to common
bmc08gt Oct 23, 2026
57f1d91
build(core-ui): move core-ui to android+jvm KMP and lift balance-path…
bmc08gt Oct 23, 2026
61c39c8
build(common-ui): move common-ui to android+jvm KMP and lift ContactA…
bmc08gt Oct 23, 2026
3d02a3e
build(transaction-history): move transaction-history to android+jvm KMP
bmc08gt Oct 23, 2026
0db5af1
build(funding): lift PurchaseMethod types to common
bmc08gt Oct 23, 2026
19e03d4
build(tokens): move shared:tokens to android+jvm KMP and lift the wal…
bmc08gt Oct 23, 2026
3cbffbd
build(balance): move features:balance to android+jvm KMP
bmc08gt Oct 23, 2026
63a2175
feat(desktop): Metro graph and wallet window
bmc08gt Oct 23, 2026
c9ef6dc
feat(desktop): sign in with a dev phrase and show the live balance
bmc08gt Oct 23, 2026
d585683
fix(desktop): add Coil's OkHttp network fetcher so token logos load
bmc08gt Oct 24, 2026
7a7b785
test(desktop): check desktop wiring against Android's in a shared con…
bmc08gt Oct 24, 2026
2fa0c04
refactor(kmp): move platform-neutral sources from androidMain to comm…
bmc08gt Oct 24, 2026
1d77df4
refactor(kmp): read Compose resources in common code and lift R-only …
bmc08gt Oct 24, 2026
597c412
refactor(resources): read the Int-id lookups through Compose resources
bmc08gt Oct 24, 2026
406f8df
feat(device-link): add the seed vault and LAN link protocol
bmc08gt Oct 24, 2026
b3866b2
feat(desktop): sign in from the linked seed or show the link screen
bmc08gt Oct 24, 2026
0be26c7
feat(myaccount): add Link a Desktop behind the LinkDesktop flag
bmc08gt Oct 24, 2026
b30bed1
refactor(login): take the onboarding purchase step from the app
bmc08gt Oct 24, 2026
d816647
test(login): mock Base64.encode as well as encodeToString
bmc08gt Oct 24, 2026
3b1580d
refactor(device-link): move the link protocol into a UI-free :libs:de…
bmc08gt Oct 24, 2026
f16663f
test(device-link): run the link protocol tests on every target
bmc08gt Oct 24, 2026
bdf1d48
test(device-link): check the link against fixed vectors
bmc08gt Oct 24, 2026
4eb607f
feat(shared-core): export the phone link to iOS
bmc08gt Oct 24, 2026
f9fda48
Merge remote-tracking branch 'origin/code/cash' into feat/desktop-spi…
bmc08gt Oct 30, 2026
4574d24
chore(desktop): keep android-only card action and submenu in androidMain
bmc08gt Oct 30, 2026
8082a8d
fix(desktop): use ed25519kmp in payment request code and core-ui from…
bmc08gt Oct 30, 2026
ed71b42
fix(messenger): invite preview resources implement AndroidResourceHelper
bmc08gt Oct 30, 2026
3a83684
fix(desktop): run host tests of KMP-layout modules in flipcashTestDebug
bmc08gt Oct 30, 2026
dc6e40f
fix(test): disambiguate ResourceHelper mocks and resolve ic_delete co…
bmc08gt Oct 30, 2026
2bb3fa4
fix(test): FakeResourceHelper answers Compose StringResource from id …
bmc08gt Oct 30, 2026
b1b2b95
Merge remote-tracking branch 'origin/code/cash' into feat/desktop-spi…
bmc08gt Oct 30, 2026
4abd51f
fix(desktop): use inTransaction and the Android init extension in new…
bmc08gt Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .github/workflows/shared-core-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'kmp/shared-core/**'
- 'libs/codes/kikcode/**'
- 'libs/currency-math/**'
- 'libs/device-link/**'
- 'libs/encryption/**'
- 'libs/reporting/**'
- 'libs/analytics-events/**'
Expand Down Expand Up @@ -72,6 +73,7 @@ jobs:
:libs:analytics-events:iosSimulatorArm64Test \
:libs:codes:kikcode:iosSimulatorArm64Test \
:libs:currency-math:discrete-curve:iosSimulatorArm64Test \
:libs:device-link:iosSimulatorArm64Test \
:libs:encryption:base58:iosSimulatorArm64Test \
:libs:encryption:chat-cipher:iosSimulatorArm64Test \
:libs:encryption:ed25519:iosSimulatorArm64Test \
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.jetbrains.compose)
alias(libs.plugins.metro)
}

kotlin {
jvmToolchain(21)
}

metro {
interop {
includeJavax()
includeDagger()
enableDaggerRuntimeInterop.set(true)
}
}

dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.jetbrains.compose.material3)
implementation(project(":services:opencode"))
implementation(project(":apps:flipcash:features:balance"))
implementation(project(":apps:flipcash:core"))
implementation(project(":apps:flipcash:shared:transaction-history"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":apps:flipcash:shared:funding"))
implementation(project(":apps:flipcash:shared:userflags"))
implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:persistence:db"))
implementation(project(":services:flipcash"))
implementation(project(":libs:coroutines"))
implementation(project(":libs:logging"))
implementation(project(":ui:resources"))
implementation(project(":ui:theme"))
implementation(project(":apps:flipcash:shared:theme"))
implementation(project(":services:opencode-compose"))
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)
implementation(libs.coil3)
implementation(libs.coil3.network)
implementation(libs.event.bus)

testImplementation(project(":apps:flipcash:startup-contract"))
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
}

tasks.withType<Test>().configureEach {
// No backend in tests: keep the exchange's rate stream from starting when the graph builds it.
systemProperty("opencode.processLifecycle.autoStart", "false")
}

// 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() }

compose.desktop {
application {
mainClass = "com.flipcash.desktop.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Flipcash"
packageVersion = "1.0.0"
}
}
}

tasks.register<JavaExec>("snapshot") {
group = "application"
description = "Renders the wallet (or, with -Pscreen=link, the link screen) off-screen to a PNG (-Pout=<file>)."
classpath = sourceSets["main"].runtimeClasspath
mainClass.set("com.flipcash.desktop.SnapshotMainKt")
args(
providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("wallet.png").get().asFile.path),
providers.gradleProperty("screen").getOrElse("wallet"),
)
}

tasks.withType<JavaExec>().configureEach {
devMnemonic?.let { environment("FLIPCASH_DEV_MNEMONIC", it) }
}
68 changes: 68 additions & 0 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt
Original file line number Diff line number Diff line change
@@ -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<DesktopLinkHost.State?>(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<Inet4Address>()
.firstOrNull { it.isSiteLocalAddress }
?.hostAddress
52 changes: 52 additions & 0 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.flipcash.desktop

import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.balance.internal.WalletViewModel
import com.flipcash.app.tokens.ui.SelectTokenViewModel
import com.getcode.opencode.exchange.Exchange
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.network.NetworkConnectivityListener
import com.flipcash.desktop.inject.FlipcashDesktopChannelModule
import com.flipcash.services.inject.FlipcashModule
import com.flipcash.desktop.inject.OpenCodeDesktopChannelModule
import com.getcode.opencode.inject.OpenCodeModule
import com.getcode.opencode.inject.SessionListenerModule
import com.getcode.opencode.inject.TransactorModule
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import javax.inject.Singleton

@Singleton
@DependencyGraph(
scope = AppScope::class,
bindingContainers = [
OpenCodeModule::class,
OpenCodeDesktopChannelModule::class,
SessionListenerModule::class,
TransactorModule::class,
FlipcashModule::class,
com.flipcash.services.inject.ChatCryptoModule::class,
FlipcashDesktopChannelModule::class,
DesktopModule::class,
],
)
interface DesktopGraph {
val walletViewModel: WalletViewModel
val selectTokenViewModel: SelectTokenViewModel
val exchange: Exchange
val resources: ResourceHelper
val networkObserver: NetworkConnectivityListener
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.
val eventRepository: com.getcode.opencode.repositories.EventRepository

// For DesktopWiringTest: the listener set, and singletons to check are shared across injection sites.
val sessionListeners: Set<com.getcode.opencode.providers.SessionListener>
val tokenCoordinator: com.flipcash.app.tokens.TokenCoordinator
val eventBus: com.hoc081098.channeleventbus.ChannelEventBus
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.flipcash.desktop

import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.CachePolicy
import coil3.request.crossfade
import okio.Path.Companion.toOkioPath

/**
* Desktop counterpart of `FlipcashApp.newImageLoader`. Coil 3 has no network fetcher unless one is
* added, so without this every token logo (an https URL) fails with "Unable to create a fetcher".
* The chat photo keyer/fetcher are left out: chat is not on desktop.
*/
internal fun installImageLoader() {
SingletonImageLoader.setSafe { context -> newImageLoader(context) }
}

private fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.crossfade(true)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCache {
DiskCache.Builder()
.directory(DesktopModule.appDir.resolve("image_cache").toOkioPath())
.maxSizeBytes(50L * 1024 * 1024)
.build()
}
.components { add(OkHttpNetworkFetcherFactory()) }
.build()
118 changes: 118 additions & 0 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.flipcash.desktop

import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.core.AppRoute
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
import com.flipcash.app.funding.PurchaseMethodSelection
import com.flipcash.app.funding.PurchaseMethodState
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsFiles
import com.flipcash.libs.coroutines.DispatcherProvider
import com.getcode.chatcipher.ChatCipher
import com.getcode.chatcipher.DefaultChatCipher
import com.getcode.opencode.providers.SessionListener
import com.getcode.opencode.providers.TokenMetadataProvider
import com.getcode.util.locale.LocaleHelper
import com.getcode.util.resources.ComposeResourceHelper
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.network.NetworkConnectivityListener
import com.getcode.utils.network.NetworkObserverStub
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
import java.io.File
import java.util.Currency
import java.util.Locale
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow

/**
* Desktop implementations of the seams Android binds in its Hilt modules. Funding, analytics and
* connectivity are stubs for this slice; the rest are real.
*/
@Module
object DesktopModule {
/** Per-user data directory: the database, DataStore files and no-backup files. */
internal val appDir: File by lazy {
File(System.getProperty("user.home"), ".flipcash").apply { mkdirs() }
}

@Provides
@Singleton
fun seedVault(): SeedVault = KeyringSeedVault()

@Provides
@Singleton
fun dispatchers(): DispatcherProvider = object : DispatcherProvider {
override val Default: CoroutineDispatcher = Dispatchers.Default
override val Main: CoroutineDispatcher = Dispatchers.Main
override val IO: CoroutineDispatcher = Dispatchers.IO
}

// ChatCryptoModule provides this from its companion object, which Metro's Dagger interop
// does not read from a precompiled module.
@Provides
fun chatCipher(): ChatCipher = DefaultChatCipher

@Provides
fun analytics(): FlipcashAnalytics = FlipcashAnalytics.None

@Provides
@Singleton
fun locale(): LocaleHelper = object : LocaleHelper {
override fun getLanguageTag(): String = Locale.getDefault().toLanguageTag()
override fun getDefaultCurrencyName(): String =
runCatching { Currency.getInstance(Locale.getDefault()).currencyCode }.getOrDefault("USD")
override suspend fun getDefaultCountry(): String? =
Locale.getDefault().country.takeIf { it.length == 2 }
}

@Provides
@Singleton
fun network(): NetworkConnectivityListener = NetworkObserverStub()

@Provides
@Singleton
fun preferencesFiles(): PreferencesFiles = DirectoryPreferencesFiles(appDir)

@Provides
@Singleton
fun userFlagsFiles(): UserFlagsFiles = object : UserFlagsFiles {
override val noBackupDir: File = File(appDir, "no_backup").apply { mkdirs() }
override fun dataStoreFile(name: String): File = File(appDir, "datastore/$name.preferences_pb")
}

@Provides
@Singleton
fun resources(): ResourceHelper = ComposeResourceHelper()

@Provides
fun tokenMetadata(coordinator: TokenCoordinator): TokenMetadataProvider = coordinator

// Android adds TokenCoordinator to the listener set in TokenModule, which is Android-only. Without this
// the coordinator never sees onUserLoggedIn and the wallet stays on its loading state.
@Provides
@IntoSet
fun tokenSessionListener(coordinator: TokenCoordinator): SessionListener = coordinator

@Provides
@Singleton
fun purchaseMethods(): PurchaseMethodController = object : PurchaseMethodController {
override val state: StateFlow<PurchaseMethodState> = MutableStateFlow(PurchaseMethodState())
override val selections: Flow<PurchaseMethodSelection> = emptyFlow()
override fun present(metadata: PurchaseMethodMetadata) = Unit
override fun select(method: PurchaseMethod, metadata: PurchaseMethodMetadata) = Unit
override suspend fun presentDepositOptions(popToRoot: Boolean): AppRoute? = null
}
}
Loading
Loading