Skip to content

Add a platform port

The screens in :ui never learn which platform they run on. Everything the two shells do differently (alarms, file pickers, opening a file with another app, where settings are stored) reaches :ui through a small interface called a port, and each shell implements it. This guide adds a new one.

First, decide whether it’s a port at all

Section titled “First, decide whether it’s a port at all”
The thing… Put it… Examples
differs per machine: OS APIs, file dialogs, notifications, system integration a port in ui/platform/Ports.kt, implemented per shell ReminderScheduler, BackupGateway, BackupFilePicker, AttachmentFilePicker, AttachmentOpener
is identical on both (plain JVM code: HTTP, JSON, SQL, hashing) a concrete class in :core, built once in CadenceCore CadenceRepository, CadenceSyncEngine, BlobStore
is a per-device setting the existing SettingsStore port and CadenceSettings theme, density, reminders on/off
is desktop window state with no meaning on a phone :app-desktop only (DesktopWorkspaceStore, workspace.json) sidebar width, window bounds

Sync is deliberately not a port. HTTPS and JSON are the same on both platforms, so CadenceSyncEngine is one concrete class in :core that CadenceCore constructs once on the application scope (ADR 0002, decision 7). Making it a port would buy two implementations of the same code and a fake that asserts nothing. The same test applies to yours: if both implementations would be the same code, it isn’t a port.

The running example is a hypothetical ClipboardWriter that copies a task’s title.

/** Puts text on the system clipboard. Answers whether the platform took it. */
interface ClipboardWriter {
fun copy(text: String): Boolean
}

Guidelines the existing ports follow:

  • Speak domain types and plain values, never Context, Uri or java.awt types. An opaque handle only :ui hands back is fine: BackupTarget is a SAF uri string on Android and a path on the desktop.
  • Answer instead of throwing when “nothing happened” is an ordinary outcome. AttachmentOpener returns false on a phone with no PDF viewer, and the ViewModel shows a snackbar.
  • Use a callback when the answer arrives later. BackupFilePicker and AttachmentFilePicker call back rather than return, because Android’s answer is an Activity result that arrives after the composition that asked.
  • Android (app-android/src/main/java/de/andi1984/cadence/…): for example, an AndroidClipboardWriter(context) over ClipboardManager.
  • Desktop (app-desktop/src/main/kotlin/de/andi1984/cadence/desktop/…): for example, a DesktopClipboardWriter over java.awt.Toolkit. Guard for a headless JVM, the way DesktopAttachmentOpener guards java.awt.Desktop.

3. Wire it: container singleton or composition-bound

Section titled “3. Wire it: container singleton or composition-bound”

There are two ways a port reaches the code that uses it. Pick the one that matches its lifetime.

A container singleton the ViewModel calls (like ReminderScheduler, BackupGateway, AttachmentOpener):

  1. Add it to ViewModelAdapters and pass it through cadenceViewModel(core, adapters, scope) into a new CadenceViewModel constructor parameter.
  2. Construct it in each shell’s AppContainer and add it to the ViewModelAdapters(...) built there: app-android/…/AppContainer.kt and app-desktop/…/AppContainer.kt.
  3. Add a ViewModel method that calls it, and give the screen a callback.

A composition-bound helper a screen calls (like the two file pickers):

  1. On Android, expose it as a @Composable fun rememberSaf…(): YourPort, as in SafBackupFilePicker.kt, because an Activity-result launcher can only be created inside a composition. On the desktop, remember { Desktop…() } in CadenceDesktopApp.kt.
  2. Pass it into the screen composable as a parameter, from CadenceApp.kt on Android and CadenceDesktopApp.kt on the desktop.

Wiring is hand-rolled; there’s no DI framework. A singleton shared by both shells goes in CadenceCore, and a platform-specific one goes in the shell’s AppContainer.

Add a hand-written fake to :ui’s Fakes.kt. There’s no mocking library in this repository. The house style is a recording fake that keeps what it was asked and can be told to refuse:

class RecordingClipboardWriter : ClipboardWriter {
val copied = mutableListOf<String>()
var accepts: Boolean = true
override fun copy(text: String): Boolean {
copied += text
return accepts
}
}

If you added a constructor parameter to CadenceViewModel, update every test that constructs it directly (CadenceViewModelCrudTest and CadenceViewModelUndoTest in ui/src/jvmTest). Only platform ports are ever faked. The stores and the repository under the ViewModel stay real (see testing philosophy).

A desktop implementation with no Compose in it can have a plain JVM test in app-desktop/src/test, like DesktopSettingsStoreTest and DesktopReminderSchedulerTest. Inject whatever a headless runner lacks: DesktopReminderScheduler takes a notify lambda so the tests never need a real system tray.

The CI command never compiles :app-android, so run the Android build as well:

Terminal window
./gradlew :core:testDebugUnitTest :ui:testDebugUnitTest \
:core:jvmTest :ui:jvmTest :app-desktop:test :ui:compileKotlinJvm \
assembleDebug

Then exercise the feature in both shells: ./gradlew :app-desktop:run, and the debug APK on a phone (quickstart).

  • Desktop shell: what the desktop does differently, and the two composition locals Android leaves at their defaults.
  • Architecture: CadenceCore, the two AppContainers and the one ViewModel.
  • ADR 0001: how the shared modules were carved out of the Android app.