How the modules fit together, which direction dependencies are allowed to point, and the
conventions that repeat across the codebase. The canonical short version lives in
AGENTS.md; this document is the expanded reading of it for a contributor or
reviewer who wants the reasoning, not just the rule.
graph TD
app[":app<br/>Activities, nav host, Hilt graph"]
fphone["feature/<name>/phone<br/>screens + ViewModels"]
fapi["feature/<name>/api<br/>NavKeys only"]
core["core/*<br/>domain, data, design system"]
app --> fphone
app --> core
fphone --> fapi
fphone --> core
core --> core
settings.gradle.kts is the authoritative module list.
feature/*/apidepends on nothing. It holds only@SerializableNavKeys and the feature's tab label. This is what lets one feature navigate into another without compiling against its implementation — the whole point of the api split.feature/*/phonedepends on its ownapiplus whichevercoremodules it needs. Never on another feature'sphoneortv. If two features need the same behaviour, it moves into acoremodule.core/*may depend on othercoremodules, never on afeature.appis wiring only — the launcher Activity, the external-APK document Activity, nav hosts, and app-scoped Hilt bindings. No feature logic lands here.- A module's package matches its directory with hyphens removed:
core/user-preferences→core.userpreferences,feature/app-detail/phone→feature.appdetail.phone.
| Module | Owns |
|---|---|
core:apps |
Installed-app and APK analysis: extraction, normalization, caching, domain models |
core:apk-files |
Temporary materialization and cleanup of APKs received via content URIs |
core:app-index |
Device-wide attribute → apps indexes behind Browse |
core:app-permissions |
The deduplicated device-wide permission list |
core:ai-insights |
On-device AI features and the ML Kit engine wrapper |
core:user-preferences |
Recently viewed apps and search history |
core:navigation |
Navigation 3 infrastructure for independent bottom-nav stacks |
core:design-system |
The design system: shared foundation (palette, icons), Material3 phone components, TV Material tv components |
core:common |
Dispatchers, logging, and models shared across domains |
feature:apps |
The installed-app list: search, filter, sort |
feature:app-detail |
The full report for one app or APK |
feature:browse |
Browse by attribute |
feature:settings |
Theme and app settings |
A core module packages by domain once it holds more than one repository/manager family:
core:apps is the worked example, with signing/, permissions/,
components/, manifest/, installsource/, devicefeatures/, packaging/, usagestats/,
storagestats/, export/, and sdkversion/ each holding their own interface, implementation, and
models. A module with a single family (core:app-index, core:apk-files) has not earned that split
and does not pre-empt it.
Every ViewModel extends ViewModel() directly, with no base class, and exposes exactly three
things:
- one
val state: StateFlow<FeatureState> - one
val events: Flow<FeatureEvent>, when the screen has one-shot signals to send - one
fun onAction(action: FeatureAction)with awhendispatch
Nothing else is public. The supporting types are equally fixed:
- State — a
sealed interfaceor an@Immutable data class. Never holds lambdas. - Action — a
sealed interfaceof UI → ViewModel intents. - Event — a
sealed interfaceof one-shot ViewModel → UI signals (navigation, toasts, system intents), delivered overChannel<Event>(Channel.BUFFERED)exposed asreceiveAsFlow(). Never state.
Composables read state with collectAsStateWithLifecycle() and consume events in a
LaunchedEffect.
When a ViewModel needs a runtime parameter it uses
@HiltViewModel(assistedFactory = VM.Factory::class) with @AssistedInject, rather than reading
arguments out of a SavedStateHandle.
Production references:
AppDetailViewModel— assisted injection, privateMutableStateFlowsources combined into one public state.SettingsViewModel— the plain flow-backed shape with state, events, and actions.
Repositories and managers are a public interface plus an internal Impl in the same module,
bound with Hilt @Binds and scoped @Singleton. Interface and implementation live in separate
files; models declared alongside the interface may stay in the interface file.
- Interface methods never throw. They return
Result<T>, a nullableT?, or an empty collection — seeAppDetailRepositoryandAppExportManager. - Dispatchers are injected, never hardcoded: inject
DispatcherProviderand switch withflowOn(dispatcherProvider.default())orwithContext(dispatcherProvider.io()). - Constructor injection is preferred over module
@Provides; a@Providesfunction is for platform types only.
The details, including the cancellation-safe runCatchingCancellable wrapper, are in
coding standards.
Feature modules never import androidx.compose.material3. They use Compose foundation APIs plus the
wrappers, theme, icons, and animation metadata in
core:design-system:phone. If a component does not exist there yet, it is
wrapped there first — the create-compose-component
skill covers the procedure. app uses material3 directly for Scaffold and theme plumbing; that is
the only exception in the repository.
Colors and type come from AppTheme.colors / AppTheme.typography, icons from ApkAnalyzerIcons.
MaterialTheme.colorScheme is never reached for outside core:design-system:phone.
Navigation 3 (androidx.navigation3) only — no legacy Jetpack Navigation. NavKeys are
@Serializable and implement NavKey: a data class when it carries parameters, a data object
when it does not. Keys reachable from another feature live in feature/*/api; keys internal to one
feature live in that feature's phone/navigation/ or tv/navigation/.
Each feature exposes EntryProviderScope<NavKey>.<feature>Entries(navigator: Navigator), which is
wired into the entryProvider { } block in
ApkAnalyzerApp.kt. A
screen that isn't wired there is unreachable. Multi-stack state for the bottom navigation lives in
core:navigation, and the step-by-step procedure is the
implement-navigation skill.
gradle/libs.versions.toml is the only place dependency
coordinates and versions live. SDK levels and the JVM toolchain come from
AndroidSdk.kt
and Kotlin.kt
in build-logic. Nothing is pinned inside a module's
build.gradle.kts.
- Coding standards — the conventions that apply inside these shapes
- Verification — what the static-analysis gates check
docs/app/technical/— cross-cutting decisions and audits, including repository load performance