Skip to content

feat(desktop): Compose Desktop client for the wallet and linking - #1894

Merged
bmc08gt merged 52 commits into
code/cashfrom
feat/desktop-spike-land
Oct 30, 2026
Merged

bmc08gt merged 52 commits into
code/cashfrom
feat/desktop-spike-land

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Lands the desktop stack (#1838, #1843, #1844, #1849, #1850) as one squash, merged up to current code/cash. Those PRs will be closed in favour of this one.

What it adds:

  • A jvm() target on the shared KMP libraries, and the balance data stack, services, persistence, tokens, transaction history, core-ui and the UI modules moved to android + jvm source sets.
  • apps:desktop: the wallet screen on Compose Desktop, with the account seed in the OS keyring.
  • Linking a desktop from the phone, on Android and through SharedCore for iOS.

Merging code/cash in:

  • Strings in moved modules go through each module's strings table, so new code/cash strings were added there.
  • FlagWithFiat, NewUserTutorial and PriceWithFlag stay deleted, as on code/cash after feat(onboarding): new-user onboarding tweaks #1862.
  • The card action, the submenu and the invite preview resources stay in androidMain.

Test fixes:

  • flipcashTestDebug now runs the androidHostTest tasks of the moved modules. Before this, CI skipped those tests.
  • FakeResourceHelper resolves Compose string resources from R.string stubs. Without that, 26 transaction-history mapper tests failed.
  • ic_delete.xml uses #FFFFFFFF because the Compose resources parser rejects @android:color/white.

All 14 KMP library modules repeated the same android compileSdk/minSdk
block and the same five Apple targets, and only text-format applied the
convention plugin. The plugin now declares those targets, and every module
applies it and keeps only its namespace and test opt-ins.

shared-core attaches its SharedCore framework to each native target via
targets.withType<KotlinNativeTarget>() instead of a local target list.
discrete-curve hard-coded compileSdk = 37; it now picks up the catalog's
37 with minor API level 2 like the other modules.
First step of the desktop client: the KMP libraries need a JVM target
before Compose Desktop modules can depend on them. flipcash.kmp.library
now declares jvm() with jvmTarget from the android-java catalog version.

Two expect declarations needed JVM actuals:

- sha512 derivePlatform: the JDK ships PBKDF2WithHmacSHA512, so the
  actual is the Android JCA one with the same ASCII-only guard.
- Ed25519Kmp: pure Java on net.i2p.crypto:eddsa, which already backs the
  test-utils shadow. The 64-byte private key is eddsa's clamped hash `h`,
  the same orlp layout the JNI and cinterop actuals return, so keys move
  between platforms unchanged. The RFC 8032 vectors in ed25519.json pass
  on jvmTest, so no native library is loaded on desktop.

sign.c includes sha3.h, but that header declares the sha512_* functions
and sha3.c is libtom's SHA-512 (it carries the 0x428a2f98d728ae22 round
constant). It's a misnamed file, not SHA-3, which is why standard
SHA-512 on the JVM reproduces the native signatures.
Hilt's Gradle plugin rejects the AGP KMP android target, so KMP modules
run the Hilt KSP processors through kspAndroid without the plugin. Only
the app and notifications use @androidentrypoint, which is what the
plugin's bytecode rewrite exists for.

libs:coroutines moves onto the plugin, and its test fixtures become a
:libs:coroutines:test-fixtures module, since the KMP android target has
no testFixtures. shared:funding moves its sources to androidMain as the
first Hilt-bearing module on the KMP plugin.
Converts network:jwt, currency-math, encryption:keys, datetime and
messaging onto flipcash.kmp.app.library and gives encryption:utils a
jvmShared source set for its java.*/protobuf helpers.

Adds :libs:parcelable, a common Parcelable/@parcelize stand-in that
typealiases android.os.Parcelable on Android, so parcelable models can
live in commonMain. currency-math's test fixtures become
:libs:currency-math:test-fixtures, and the desktop build reads the curve
tables from the classpath.
…lable

A common @parcelize class implemented the JVM Parcelable's abstract members on Android only,
so it failed to compile for jvm. Parcelable is now a member-free marker; PublicKey, which writes
its own parcel format, implements ManualParcelable.
Timber, SystemClock and android.database.SQLException move behind PlatformLog
actuals; FileTree stays in androidMain and plugs in through LogFileStore.
The library had debug and release values for NOTIFY_ERRORS, which a
single-variant KMP target can't carry, so the app now sets BuildFlags from its
own BuildConfig at startup.
ResourceHelper keeps the members that return plain types in commonMain.
The four that return Drawable, Typeface, DisplayMetrics and File move
to AndroidResourceHelper, which only the access-key screens use; the
app binds both. FakeResourceHelper moves from testFixtures to
:ui:resources:test-fixtures, since the KMP library target has no
testFixtures.
…droid+jvm

Currency icons sit behind an expect object (null on desktop), and
SocialProfile's file extension lookup gets a JVM copy of the AOSP
MimeTypeMap rules.
services:opencode now builds for jvm with Hilt wiring left in androidMain.
Its keys came from the Java JNI class com.getcode.ed25519.Ed25519, which
has no desktop equivalent, so:

- ed25519 gains a jvmShared source set with a Kotlin Ed25519 object
  (same members as the Java class) over Ed25519Kmp. KeyPair keeps the
  Base64 string fields and its Parcelable form, so stored keys are
  unchanged. Callers move from com.getcode.ed25519 to
  com.getcode.ed25519kmp.
- mnemonic's DerivedKey, MnemonicPhrase and MnemonicCache, and utils'
  Ed25519 extensions, move from androidMain to jvmShared.
- logging gains a plain Log (Timber on Android) for the two opencode
  files that logged without trace()'s gating.
- toInstantFromMillis moves to datetime's commonMain.
- UserManager reads BuildFlags.isDebug instead of opencode's
  BuildConfig, which a KMP library no longer generates.

The KMP Android target now compiles Java (withJava()): Hilt's KSP
processors emit hilt_aggregated_deps as Java, and without it the app
graph lost every opencode @module binding.

Adds HeadlessBalanceTest (jvmTest), which fetches token balances from
the OCP backend over grpc-okhttp with no UI or DI. It reads the phrase
from FLIPCASH_TEST_MNEMONIC or flipcash.test.mnemonic in
local.properties and skips when neither is set.
services:flipcash now applies flipcash.kmp.app.library with Hilt run
through kspAndroid. Android-only pieces sit behind seams:

- UserManager takes an AnalyticsIdentifier instead of MixpanelAPI;
  FlipcashModule binds it to Mixpanel.identify.
- ForegroundGate is a common interface; the process-lifecycle
  implementation stays in androidMain.
- ChatId's hand-written parcel code moves to ManualParcelable.
- KeyPair derivation goes through the byte-seed path shared with
  services:opencode, so derived keys are unchanged.

Test fakes move to :services:flipcash:test-fixtures. shared:analytics
reads BuildFlags.isDebug instead of this module's BuildConfig.
FlipcashDatabase, its DAOs, entities and migrations now live in commonMain.
Migrations run on SQLiteConnection, MessageDao's raw query takes a
RoomRawQuery, and init() takes a builder factory: Android passes
Room.databaseBuilder(context), desktop opens a file through
BundledSQLiteDriver. isOpen() stays Android-only, since Room exposes
RoomDatabase.isOpen there alone.
The module keeps everything it had in androidMain, with the dependencies
flipcash.android.feature used to add. Only the blocklist and activity
feed models, which persistence:sources maps to and from, move to
commonMain.
Data sources call a common inTransaction instead of room-ktx's
withTransaction, which Room KMP only offers on Android. Android keeps
withTransaction; the JVM runs the block on Room's writer connection.
Token icons encode with java.util.Base64, which matches Android's NO_WRAP
output. The draft data source and its mappers use Uri, so they stay in
androidMain.
…ose resources

:apps:desktop is a kotlin.jvm + compose.desktop app with an empty Metro
graph. ui:resources now also generates a Compose Multiplatform Res class
from its Android res/ through a Sync task, so common code can read the
same strings and drawables without a second copy in the repo.
Fonts load through an expect seam: Android keeps R.font, desktop reads
the same files from the classpath. Dynamic color and the View helpers
stay in androidMain.
Only the files the balance screen reaches move to commonMain; the rest
stay in androidMain unchanged. Drawable lookups by Android resource id go
through painterForResId, with a desktop ResourceHelper over Compose
resources. Blur support and hex colour parsing get small platform seams.
CodeNavigator, its options, flow scope and the nav-result types move to
commonMain; navigation3 1.1.4 publishes a desktop variant, so the
back stack type is the same artifact on both platforms. Scenes, flow
hosts and AppNavHost stay in androidMain.

RepeatOnLifecycle reads LocalLifecycleOwner from lifecycle-runtime-compose,
the class the old androidx.compose.ui.platform one already aliases on
Android.
The FlipcashAnalytics interface, the token symbol resolver and the
CompositionLocal move to common code. Conversions (toUri and app:core's
DeeplinkType) and the Mixpanel delegate stay Android. RecordingAnalytics
moves to a :test-fixtures module, since the KMP Android target has no
testFixtures; its 10 consumers point at that module.

Also moves ui:navigation's BaseViewModel to common code; it uses only
lifecycle and coroutines.
Flag labels sit behind a FlagStrings expect object (Android keeps the
R.string ids; desktop has no flag editor). UserFlagsFiles replaces the
Context parameter so the DataStore file comes from the platform.
AppRoute, its step and result types, DeviceContact and TipCardOwner move
to app:core's commonMain with the shared Parcelable aliases. The three
files that use Uri or R (CurrencyCreatorDraft, TokenDisplayName,
DeeplinkType) stay in androidMain. FlowRoute moves to ui:navigation's
commonMain.

ui:resources adds stringRes/pluralStringRes for composables that hold an
Int resource ID, and lets other modules register their own Compose
resources with ComposeResourceIds to get an Int ID on desktop.
… composables

Lifts the nine composables the balance screen and activity feed render
(TileButton, TokenCard, TokenCardStack, TokenImageWithName, ViewerAmount,
ActivityAmount, CurrencyAppreciationLabel, FlagWithFiat, NewUserTutorial)
to commonMain. Everything else stays in androidMain unchanged.

Seams: screenHeightPx (Configuration on Android, window container on
desktop) and ImageRequestPlaceholders for Coil placeholders. Flags load
through painterForResId. app:core exposes its res/ as Compose resources
and gains CoreStrings/CoreDrawables id objects for the lifted files.
opencode-compose moves to KMP with LocalExchange in commonMain.
…vatar

BlurHash decodes to pixels in common code; each platform wraps them (Android
still builds the same ARGB_8888 Bitmap, and keeps BlurHash.decode as an
extension). ContactAvatar uses Coil's LocalPlatformContext and passes the URL
string to Coil, which maps it as it did the Uri. app:core's media resolver and
TimeProvider move to common; the Coil-backed ImageCachePresence stays Android.
Lift the activity feed coordinator and updater, both mappers and the
recent-activity composables to commonMain. String ids go through a
TxStrings expect object (R.string on Android, the Compose resource
registry on desktop); the 24-hour clock check is an expect seam.
NetworkUpdater moves to app:core's commonMain.
PurchaseMethod, PurchaseMethodController and PurchaseMethodState move to
commonMain so the balance view model can take the controller on desktop.
The controller implementation and its UI stay in androidMain. The module
moves to flipcash.kmp.app.library, since its app-module dependencies
have no Apple targets.
…let coordinators

TokenCoordinator, WalletRevealCoordinator, TokenSelectionResolver and
SelectTokenViewModel move to commonMain; the rest of the module stays in
androidMain.

Two app:core seams replace the Android types TokenCoordinator used:
- PreferencesFiles hands out the DataStore file; Android binds it to
  Context.preferencesDataStoreFile through Hilt.
- AppProcessLifecycle is ProcessLifecycleOwner on Android and a
  lifecycle held in RESUMED on desktop, since lifecycle-process has no
  desktop artifact.

SelectTokenViewModel becomes an open class without Hilt annotations.
HiltSelectTokenViewModel in androidMain carries @hiltviewmodel, and the
ten hiltViewModel<SelectTokenViewModel>() call sites across eight
features now request it.
WalletViewModel becomes an open class in commonMain with an @Inject
constructor; the @hiltviewmodel subclass HiltWalletViewModel stays in
androidMain, and the Android entry point asks Hilt for it. The wallet
content and header composables move to commonMain and read their
strings and drawables through CoreStrings/CoreDrawables.

The view model only called ChatCoordinator.hasEverTipped(), so it now
takes a one-method TipHistorySource from app:core. ChatCoordinator
extends it and ChatModule binds it, so Android injects the same object
and balance no longer depends on shared:chat (10k lines).

ReportDrawnWhen becomes an expect seam (no-op on jvm). card-expand
moves to android+jvm with everything in commonMain.
Desktop builds WalletViewModel from a Metro graph that reuses the
Dagger modules Android's Hilt graph installs. OpenCodeModule and
FlipcashModule split into shared providers (commonMain), the Android
channel/protocol config (androidMain) and an okhttp channel module in
apps/desktop. Internal @provides functions carry @JvmName so Dagger's
factory names stay unmangled for Metro's runtime interop.

DesktopModule binds the ten seams Android provides from Hilt modules;
funding, analytics and connectivity are stubs. shared:theme moves to
common so the window paints the Flipcash colour scheme.
DevSession reads FLIPCASH_DEV_MNEMONIC, which Gradle sets for the desktop
JavaExec tasks only when local.properties has flipcash.test.mnemonic. It
goes in the environment rather than a -D flag so the phrase stays out of
the process command line, and it is never logged.

Four gaps kept the wallet on its loading state after sign-in:

- Nothing constructed EventRepository, which turns Events.OnLoggedIn into
  SessionListener.onUserLoggedIn. Android builds it eagerly in
  FlipcashApp; the graph now exposes it and devSignIn touches it first.
- TokenCoordinator joins the listener set in TokenModule, which is
  Android-only. DesktopModule now adds it.
- Through Dagger's generated factories, Metro 1.4.5 drops provider-level
  @singleton, so each injection site got its own ChannelEventBus and
  events never reached the repository. services:opencode now applies
  Metro with Dagger interop and generates its own factories instead of
  running dagger-compiler on jvm.
- The bonding curve tables were never loaded, so every balance threw
  "DiscreteBondingCurve not initialized" inside a retry that swallowed
  it. devSignIn calls Curves.initialize() first, as Android's
  Application does.

OpenCodeExchange's state fields move above init: init hands streamRates
to another thread, which could read them before they were assigned.
Token logos are https URLs. Coil 3 ships no network fetcher, and the
desktop app had no coil-network artifact on its classpath, so every logo
failed with "Unable to create a fetcher" and the card stack showed blank
avatars. coil-network-okhttp registers its fetcher through ServiceLoader,
so adding the dependency is the fix.

installImageLoader mirrors FlipcashApp.newImageLoader (crossfade, memory
cache, 50 MB disk cache), minus the chat photo keyer and fetcher.
@bmc08gt bmc08gt self-assigned this Oct 30, 2026
@bmc08gt
bmc08gt requested a review from jeffyanta as a code owner October 30, 2026 02:17
…ke-land

* origin/code/cash:
  feat(payments): persist payment requests for offline (#1886) (#1892)
  feat(chat): add pin message contract scaffolding (#1884)

# Conflicts:
#	apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDaoTest.kt
#	apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDaoTest.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDao.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDao.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/GroupKeyEnvelopeEntity.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/PaymentRequestEntity.kt
#	apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/ChatRemovalDataTest.kt
#	apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/GroupKeyEnvelopeDataSource.kt
#	apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/PaymentRequestDataSource.kt
#	services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/internal/network/extensions/PinMappingTest.kt
#	services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorageTest.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/chat/GroupKeyEnvelopeStore.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/Pin.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/PinSummaryStorage.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorage.kt
#	services/flipcash/test-fixtures/src/commonMain/kotlin/com/flipcash/services/chat/InMemoryGroupKeyEnvelopeStore.kt
@github-actions github-actions Bot added type: feature New functionality area: payments Payments, transfers, intents, billing area: crypto Solana, keys, encryption, signing area: auth Login, session, access keys, identity area: ui Compose UI, theme, components, resources area: network gRPC, connectivity, API, exchange rates area: onramp Deposit, purchase, Coinbase, fiat on-ramp area: notifications Push notifications, in-app messaging area: build-system Gradle, convention plugins, build-logic area: tokens Token accounts, balances, token info area: intents Intent construction, submission, server-side state area: deeplinks Deep link handling, URL routing, and link parsing area: session area: onboarding and removed type: feature New functionality labels Oct 30, 2026
@bmc08gt
bmc08gt merged commit 9baf3b0 into code/cash Oct 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth Login, session, access keys, identity area: build-system Gradle, convention plugins, build-logic area: crypto Solana, keys, encryption, signing area: deeplinks Deep link handling, URL routing, and link parsing area: intents Intent construction, submission, server-side state area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: onboarding area: onramp Deposit, purchase, Coinbase, fiat on-ramp area: payments Payments, transfers, intents, billing area: session area: tokens Token accounts, balances, token info area: ui Compose UI, theme, components, resources

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant