Skip to content

Latest commit

 

History

History
154 lines (121 loc) · 8.61 KB

architecture.md

File metadata and controls

154 lines (121 loc) · 8.61 KB

Architecture

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.

The module graph

graph TD
    app[":app<br/>Activities, nav host, Hilt graph"]
    fphone["feature/&lt;name&gt;/phone<br/>screens + ViewModels"]
    fapi["feature/&lt;name&gt;/api<br/>NavKeys only"]
    core["core/*<br/>domain, data, design system"]

    app --> fphone
    app --> core
    fphone --> fapi
    fphone --> core
    core --> core
Loading

settings.gradle.kts is the authoritative module list.

Dependency rules

  • feature/*/api depends on nothing. It holds only @Serializable NavKeys 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/*/phone depends on its own api plus whichever core modules it needs. Never on another feature's phone or tv. If two features need the same behaviour, it moves into a core module.
  • core/* may depend on other core modules, never on a feature.
  • app is 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 ownership

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.

ViewModel conventions

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 a when dispatch

Nothing else is public. The supporting types are equally fixed:

  • State — a sealed interface or an @Immutable data class. Never holds lambdas.
  • Action — a sealed interface of UI → ViewModel intents.
  • Event — a sealed interface of one-shot ViewModel → UI signals (navigation, toasts, system intents), delivered over Channel<Event>(Channel.BUFFERED) exposed as receiveAsFlow(). 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, private MutableStateFlow sources combined into one public state.
  • SettingsViewModel — the plain flow-backed shape with state, events, and actions.

Data-layer conventions

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 nullable T?, or an empty collection — see AppDetailRepository and AppExportManager.
  • Dispatchers are injected, never hardcoded: inject DispatcherProvider and switch with flowOn(dispatcherProvider.default()) or withContext(dispatcherProvider.io()).
  • Constructor injection is preferred over module @Provides; a @Provides function is for platform types only.

The details, including the cancellation-safe runCatchingCancellable wrapper, are in coding standards.

Design system conventions

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

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.

One source of versions

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.

Related