> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modaal.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The Structure of a Kotlin Multiplatform Android App

> A Duet Android app is a thin Compose shell over a shared Kotlin core: one Gradle module per feature, plus recorded fixtures its iOS twin replays too.

A Duet-built Android app is structured as a thin, native Compose app over a shared Kotlin core: every feature's logic lives in its own Gradle module under `commonMain`, the Android app module holds only what is platform work by nature — the entry activity, the screens, the I/O workers — and recorded fixtures pin what the logic does. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, scaffolds this shape as one repository holding two native apps: the Android app this page walks, and an iOS twin that consumes the identical compiled core. Each section below names the iOS counterpart of the part it describes. This page walks the tree the way [How Android apps work](/articles/how-android-apps-work) walks a standard Android project — read that page first if manifests, activities and Gradle modules are new vocabulary.

<Note>
  The worked example throughout is **[Memory Lane](https://memorylaneapp.lovable.app/)**, a shared-memories app whose production iOS app was joined by a full Android twin built on Duet — the same codebase [Handling platform-specific UI](/articles/cross-platform-ui-parity) quotes. Memory Lane is live on the [App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051); every file name and count below comes from that repository.
</Note>

## What does the tree look like?

One repository holds both apps and the contract between them. The Android-relevant parts of Memory Lane's tree:

```text theme={null}
src-kmp/                      # the Gradle plane: shared core + the Android app
├── subtrees/
│   ├── timeline/logic/       # one module per feature: State, Action, Effect, reducer
│   ├── capture/logic/        #   …16 feature modules in this app
│   └── priming/logic/
├── services/                 # cross-cutting ports: identity, feeds, consent
├── telemetry/                # the closed analytics event grammar
├── theming/                  # design tokens, shared with the iPhone app
├── apple-umbrella/           # Kotlin/Native framework for the iPhone app
├── replay-runner/            # replays recorded fixtures on the JVM
└── app/                      # the Android app module
    ├── MainActivity.kt       # the Android edge: intents, Bundle I/O, retained tree
    ├── AppRoot.kt            # the Compose root
    ├── MainNavHost.kt        # the kind → renderer table for the main host
    ├── <Feature>Builder.kt   # one builder per mounted feature
    ├── ui/                   # theme and controls
    ├── workers/              # the I/O: repositories, push, media
    └── widgets/              # home-screen widgets
src-ios/                      # the iPhone app; consumes the same core
parity/
├── feature-specs/            # one one-pager per feature
└── fixtures/                 # recorded behavior — build products, never hand-edited
```

Everything under `src-kmp/` is an ordinary Gradle build you can open in Android Studio. The one module with no Android role is `apple-umbrella`, which packages the same feature modules as a Kotlin/Native framework for the iPhone app.

## Where does feature logic live?

Each feature is one Gradle module under `subtrees/`, compiled to `commonMain` — Kotlin with no Android or iOS imports. A feature is three declarations and a function: a serializable **state**, the **actions** that can change it, the **effects** it may request, and a pure [reducer](/articles/duet-glossary#reducer). Memory Lane's smallest feature, the notification soft-ask, in skeleton (the full file is 103 lines):

```kotlin PrimingFeature.kt (commonMain) theme={null}
@Serializable
data class PrimingState(
  val outcome: PrimingOutcome? = null,   // the user's choice, latched
)

sealed interface PrimingAction {
  data object EnableTapped : PrimingAction
  data object NotNowTapped : PrimingAction
}

sealed interface PrimingEffectPayload {
  data object RequestPushPermission : PrimingEffectPayload
  data class NotifyListener(val event: PrimingDelegateEvent) : PrimingEffectPayload
}

fun primingReducer(
  state: PrimingState,
  action: PrimingAction,
): Reduced<PrimingState, PrimingEffectPayload> =
  when (action) {
    PrimingAction.EnableTapped ->
      if (state.outcome != null) {
        Reduced(state)                   // the first verb won; later taps are inert
      } else {
        Reduced(
          state.copy(outcome = PrimingOutcome.enabled),
          listOf(
            Effect.Run(PrimingEffectPayload.RequestPushPermission),
            Effect.Run(PrimingEffectPayload.NotifyListener(PrimingDelegateEvent.EnableChosen))))
      }

    PrimingAction.NotNowTapped ->
      if (state.outcome != null) {
        Reduced(state)
      } else {
        Reduced(
          state.copy(outcome = PrimingOutcome.declined),
          listOf(Effect.Run(PrimingEffectPayload.NotifyListener(PrimingDelegateEvent.Declined))))
      }
  }
```

The reducer takes no environment — no clock, no network, no permission API. The platform permission prompt is requested as an *effect value*; a worker in the app module performs it. That separation is what makes the feature recordable: a [scenario](/articles/duet-glossary#scenario) drives the reducer and records state and effects into `parity/fixtures/priming.*`, and both the Android app's JVM test lane and the iPhone app's Swift lane replay those exact bytes in CI. This layer has no hand-written iOS counterpart: the same module compiles into the `apple-umbrella` framework, and the iPhone app calls the same reducer through it.

The app module consumes the features as ordinary project dependencies — this is the whole wiring, from Memory Lane's `app/build.gradle.kts`:

```kotlin app/build.gradle.kts theme={null}
dependencies {
  implementation(project(":subtrees:timeline:logic"))
  implementation(project(":subtrees:memorydetail:logic"))
  implementation(project(":subtrees:mainnav:logic"))
  implementation(project(":subtrees:rootnav:logic"))
  implementation(project(":subtrees:registration:logic"))
  implementation(project(":subtrees:introduceyourself:logic"))
  implementation(project(":subtrees:onboarding:logic"))
  implementation(project(":subtrees:accept:logic"))
  implementation(project(":subtrees:enterinvitecode:logic"))
  implementation(project(":subtrees:profile:logic"))
  implementation(project(":subtrees:shared:logic"))
  implementation(project(":subtrees:capture:logic"))
  implementation(project(":subtrees:invitecode:logic"))
  implementation(project(":subtrees:priming:logic"))
  implementation(project(":subtrees:sharepicker:contract"))
  implementation(project(":subtrees:sharepicker:logic"))
  // …theming, services, telemetry
}
```

## What is in the Android app module?

`app/` holds the three kinds of code that are platform work by nature:

* **The Android edge.** One activity. It owns exactly what only an activity can: the splash window, intent ingress (deep links, notification taps), saved-instance `Bundle` I/O, and the handle to a retained component tree that survives rotation. Its iOS counterpart is the scene component in the iPhone app target — the same thin glue, owning only what the platform's entry object must. Memory Lane's `MainActivity`, trimmed to its structure:

```kotlin MainActivity.kt theme={null}
class MainActivity : ComponentActivity() {
  private lateinit var retained: RetainedRoot<RootBuilder>

  override fun onCreate(savedInstanceState: Bundle?) {
    installSplashScreen()
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()

    // Rotation recreates the Activity; getOrCreate returns the same tree.
    val restoredSpineJson = savedInstanceState?.getString(SPINE_KEY)
    retained =
      instanceKeeper().getOrCreate {
        RetainedRoot(Dispatchers.Main.immediate, RootBuilder::teardown) { scope ->
          RootBuilder(
            restoredSpineJson = restoredSpineJson,
            composition = RootComponent(ActivityRootDependency(applicationContext, scope)),
            scope = scope,
          )
        }
      }

    // URL taps on a fresh launch only: recreation re-delivers the original
    // intent, and a stale invite link must not re-enter the accept flow.
    if (savedInstanceState == null) {
      intent?.deepLinkUrl()?.let(retained.component::onUrl)
    }

    setContent {
      MemoryLaneTheme {
        AppRoot(retained.component.shell)
      }
    }
  }

  override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    // Navigation is state, so the route spine rides the Bundle like any value.
    retained.component.captureSpineJson()?.let { outState.putString(SPINE_KEY, it) }
  }
}
```

* **Builders and shells.** One builder per mounted feature (`TimelineBuilder.kt`, `CaptureBuilder.kt`, …) constructs the feature's store and its Compose [shell](/articles/duet-glossary#shell) — the screen that reads state and sends actions. Navigation hosts like `MainNavHost.kt` bind each shared navigation *kind* to a Material renderer. The iOS counterparts are the SwiftUI builders and shells in the iPhone app — `MainNavHost.kt`'s twin is `MainNavShell.swift`, binding the same kinds to sheets and stacks; [Handling platform-specific UI](/articles/cross-platform-ui-parity) shows the two tables side by side.
* **Workers.** Everything that touches the world — repositories over the backend, push messaging, media capture, image downscaling — implements a port the reducers name as effects ([worker](/articles/duet-glossary#worker) in the glossary). Memory Lane's `workers/` directory is Firebase clients, `MediaRecorder` sessions and notification plumbing: ordinary Android code behind interfaces the shared core defines. The iOS twin implements the same ports in Swift — AVFoundation sessions and Apple push plumbing behind the identical interfaces.

The app module contains no feature logic. A saving rule, a permission latch, a navigation decision — each lives once, in its `commonMain` module, where the iPhone app compiles the identical code. When Memory Lane's sixteen Android screens were written against the already-recorded core, zero logic files changed.

<Frame caption="Memory Lane's registration screen on an iPhone simulator and a Pixel 8 emulator. One shared reducer owns the flow on both platforms; each app composes its own screen, and only iOS offers Sign in with Apple — a platform difference the shared logic models as an ordinary outcome.">
  <img src="https://mintcdn.com/modaal/S5bsKen-_yplB54_/images/memory-lane-iphone-android-pair.png?fit=max&auto=format&n=S5bsKen-_yplB54_&q=85&s=52a79ac45b024e4522766ad8958f1b32" alt="Side by side: Memory Lane's registration screen in SwiftUI on an iPhone — hand-drawn clouds, a portrait illustration, Continue with Apple and Continue with Google buttons — and in Jetpack Compose on a Pixel 8 emulator — the same serif tagline with a single Continue with Google button" style={{ width: "560px" }} width="1140" height="1200" data-path="images/memory-lane-iphone-android-pair.png" />
</Frame>

## What does day one look like, before any of this grows?

The tree above is a shipped app's grown state. On the day the scaffold runs, the same shape is already there at minimal size: one sample feature module, an app module whose root mounts that single feature ([mount](/articles/duet-glossary#mount) in the glossary), one worker file, the theming and telemetry modules, and a CI workflow that records and verifies on the first push. Growth is additive — each new feature is a new `subtrees/` module and a new builder, and the mount harness gives way to a real navigation root when the second screen arrives. The structure Memory Lane ships with is the structure the wizard created, sixteen features later.

<Frame caption="The two wizard cards that scaffold this tree. 'iPhone and Android together' emits the Kotlin Multiplatform shape on day one; 'iPhone now, Android later' starts Swift-only and converges on it.">
  <img src="https://mintcdn.com/modaal/AYD1mVTqZy0sXufj/images/duet-wizard-cards.png?fit=max&auto=format&n=AYD1mVTqZy0sXufj&q=85&s=5dbdd5bac4cffbd9fc25263b2dc7f0a8" alt="Modaal new-project wizard: the Multiplatform lane with the 'iPhone now, Android later' and 'iPhone and Android together' Duet template cards" style={{ width: "600px" }} width="1128" height="586" data-path="images/duet-wizard-cards.png" />
</Frame>

## What about widgets and background work?

Home-screen widgets are ordinary Android surfaces in the app module: Memory Lane ships a feed widget and a quick-capture widget that render *projections* of shared state — plain values a sync worker writes — rather than mounting reducers of their own. The iOS twin ships its widgets the same way, as a WidgetKit extension beside the app target. Background work follows the platform's rules described in [How Android apps work](/articles/how-android-apps-work#what-runs-in-the-background): push messages arrive through a service, deferred work goes through scheduled workers, and each of them enters the shared core the same way the UI does — by sending actions.

## Common questions

<AccordionGroup>
  <Accordion title="Is this just a Kotlin Multiplatform project I could set up myself?" icon="k">
    Structurally yes — it is Gradle, `commonMain` modules and a Kotlin/Native framework, with nothing proprietary in the tree. What the scaffold fixes on top of raw KMP is the feature shape (state, actions, effects, one reducer per module), the recorded fixtures as the behavior contract, and the CI gate that replays them on both platforms. Those conventions are the part a from-scratch setup has to invent and enforce.
  </Accordion>

  <Accordion title="Can I open it in Android Studio?" icon="folder-open">
    Yes. `src-kmp/` is a standard Gradle build: Android Studio opens it, indexes it, runs the app and the JVM tests. The iPhone half opens in Xcode from the same repository.
  </Accordion>

  <Accordion title="Where is the iOS app in this tree?" icon="apple">
    Under `src-ios/`, as an ordinary Xcode project. It consumes the identical compiled feature modules through the `apple-umbrella` Kotlin/Native framework — there is no Swift re-implementation of any reducer in the repository.
  </Accordion>

  <Accordion title="Why is there only one activity?" icon="mobile-screen">
    Because navigation is feature state, not activity plumbing. Which screen is showing, which sheet is up, what the back stack holds — each is a value a reducer owns, so it is recorded and replayed like any other behavior, and the activity's job shrinks to hosting the Compose tree and persisting the route spine so a system-killed process comes back on the same screen.
  </Accordion>

  <Accordion title="How much does the shared core add to the APK?" icon="weight-hanging">
    Measured on Memory Lane: about 3 MB for the Kotlin Multiplatform core in the Android app. The iPhone app carries the Kotlin/Native framework as its equivalent cost.
  </Accordion>
</AccordionGroup>

<Note>
  [Modaal](https://modaal.dev) scaffolds this tree for you — pick a Duet card in the new-project wizard's Multiplatform lane.
</Note>

## Sources and further reading

* [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) — the language feature the shared core builds on
* [Guide to app architecture](https://developer.android.com/topic/architecture) — Google's recommendation the feature shape aligns with: unidirectional data flow, state as data
* [Guide to Android app modularization](https://developer.android.com/topic/modularization) — the per-feature Gradle module pattern in general form
* [Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states) — the saved-state contract the route spine rides
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the toolchain and the versioned contracts

## Read next

<CardGroup cols={2}>
  <Card title="How Android apps work" icon="cubes" href="/articles/how-android-apps-work">
    The standard anatomy this page builds on: the manifest, activities, Compose and the Gradle build.
  </Card>

  <Card title="How to build testable Android apps" icon="vial-circle-check" href="/articles/testable-android-apps">
    What Duet fixes in place that a blank Android project leaves open, and what that buys in tests.
  </Card>

  <Card title="Duet overview" icon="mobile-screen-button" href="/articles/duet">
    What Duet is, the two wizard cards that scaffold it, and how a feature gets built.
  </Card>

  <Card title="Duet glossary" icon="book" href="/articles/duet-glossary">
    Every term on this page — reducer, shell, worker, mount, fixture — defined with one link each.
  </Card>

  <Card title="Duet tutorials" icon="graduation-cap" href="/tutorials/duet">
    This tree built by hand: the first Compose shell, host and Activity in Tutorial 2, the workers in Tutorial 4, the back policy and the route restore in Tutorial 5.
  </Card>
</CardGroup>
