Android app for inspecting installed apps and APK files — permissions, components, certificates, device-wide statistics. Multi-module, Kotlin, Compose, Hilt.
Every module has its own AGENTS.md. Read it before working inside that module instead of
re-deriving the module's structure.
- Kotlin only. Jetpack Compose only — no XML layouts. Hilt only — no Dagger or Koin.
- Coroutines and flows only for concurrency. Never
Thread,Executor, orrunBlocking. - Don't add a dependency.
gradle/libs.versions.tomlis the only source of coordinates and versions; if a new library seems necessary, ask first. - Never write comments or KDoc — not even when the WHY seems non-obvious. Use self-documenting
names and structure. The only exceptions are an explicit request for a comment in that instance,
and KDoc on
@AppFunction/@AppFunctionSerializabledeclarations incore:app-functions, which the App Functions platform reads as the agent-facing tool description — see that module'sAGENTS.md. - Never hardcode a user-facing string in a Composable. Use
stringResourcebacked byres/values/strings.xmlin the module that owns the UI. - Never hardcode SDK levels or the JVM toolchain in a module's
build.gradle.kts— they come fromAndroidSdk.ktandKotlin.kt. - Commits are authored as the human user only. Never add
Co-Authored-By: Claude,Claude-Session:, or any AI co-author trailer. See thegit-commit-authorskill.
Before changing code:
- Understand the requirement and define its acceptance criteria.
- Search the repository for existing implementations of similar behavior.
- Identify the relevant modules, classes, interfaces, and module-level
AGENTS.mdfiles. - Understand the existing architecture, patterns, and constraints.
- Create a concise implementation plan.
- Implement the smallest clean solution that satisfies the requirement and fits the architecture.
- Run the relevant build checks from Verifying a Change.
- Inspect the complete Git diff, including changes made earlier in the worktree.
- Perform a self-review for correctness, scope, architecture, failure states, and leftover code.
- Fix every issue found during self-review.
- Run the relevant verification again after the final fix.
Do not start implementing before understanding the existing implementation and patterns. Do not introduce a new abstraction, framework, architecture pattern, or utility when an existing solution can be reused.
- Prefer existing patterns over introducing new ones.
- Keep changes focused on the requested behavior and do not modify unrelated files.
- Prefer simple solutions over clever ones and avoid speculative abstractions.
- Reuse existing functionality instead of duplicating it.
- Preserve the architecture. Move or restructure code when necessary for a clean implementation, but ask before making a large architectural change.
- Consider failure states, empty states, and cancellation paths.
- Do not hide errors with broad exception handling.
- Do not leave debug code, temporary logging, or TODOs in production code.
Move fast: don't run detektDebug, lintDebug, spotlessCheck, or validateAgentContext after
every edit. CI runs the whole-app check on every push — let it catch those, and fix failures in a
follow-up commit instead of gating local iteration on them.
| When | Run |
|---|---|
| Iterating on one module | ./gradlew :feature:apps:phone:compileDebugKotlin |
| Before committing | ./gradlew spotlessApply — the only gate required locally |
| Whole-app check (what CI gates on — don't run this locally unless debugging a CI failure) | ./gradlew spotlessCheck detektDebug :build-logic:convention:detektMain lintDebug :app:assembleDebug :tv:assembleDebug |
| After changing a context file, skill, adapter, or the module graph | ./gradlew validateAgentContext — skip this too; CI covers it |
A successful compile is not proof a Compose layout is correct. For visual or layout changes, use
the run-app skill and look at it on a device. Two failure modes that pass every gate: text that
wraps to three lines inside a fixed-width column, and two indicators that disagree because each
derived its own answer.
spotlessCheck does not flag unused imports. Deleting the last use of something does not delete its
import, and no gate will tell you — check by hand.
Modules live under app/, tv/, core/<name>/, and feature/<name>/{api,phone,tv}/;
settings.gradle.kts is the authoritative list.
feature/*/api— depends on nothing, holds only@SerializableNavKeys and the tab-label string.feature/*/phone—api(projects.feature.<name>.api)plus whichevercoremodules it needs.feature/*/tv— the TV screens for that feature, applied withapkanalyzer.feature.tv. Same rules asphone, but it never depends on its siblingphone; logic both need moves tocorewhen the second consumer appears.core/*— may depend on othercoremodules. Never depends on afeaturemodule.- A feature never depends on another feature's
phoneortv. Cross-feature navigation goes through the target'sapimodule. app— wiring only: the launcher Activity, the external-APK document Activity, nav hosts, and app-scoped Hilt bindings. Put no feature logic here.tv— the Android TV app, wiring only, shipped as a separate bundle under the same application ID. TV code depends only on sharedcoremodules,core:design-system:tv, and featureapimodules — never onapp,core:design-system:phone, Material3, or a featurephonemodule. Phone code never depends on TV modules. Seetv/AGENTS.md.- Declare dependencies with typesafe accessors (
projects.core.appPermissions), neverproject(":core:app-permissions"). Apply plugins withalias(libs.plugins.apkanalyzer.*). - A module's package matches its directory with hyphens removed:
core/user-preferences→core.userpreferences,feature/app-detail/phone→feature.appdetail.phone.appuses the root packagesk.styk.martin.apkanalyzerwith no suffix.
- Extend
ViewModel()directly. No base class. - Expose exactly one
val state: StateFlow<FeatureState>and onefun onAction(action: FeatureAction)with awhendispatch. No other public methods. - One-shot events go through
Channel<Event>(Channel.BUFFERED)exposed asreceiveAsFlow()— never as state. - State —
sealed interfaceor@Immutable data class. Never holds lambdas. - Event —
sealed interface. ViewModel→UI signals (navigation, toasts, system intents). - Action —
sealed interface. UI→ViewModel intents. - Model back navigation as both an Action and an Event so the ViewModel never touches
Navigator. - In Composables:
collectAsStateWithLifecycle()for state,LaunchedEffectfor events.
- Repositories and Managers are a public
interfaceplus aninternalImplin the same module, bound with Hilt@Bindsand scoped@Singleton. Keep the interface and implementation in separate files. Models declared alongside the interface stay in the interface file. - Interface methods never throw. Return
Result<T>, a nullableT?, or an empty collection. - Inject
DispatcherProviderand switch withflowOn(dispatcherProvider.default())orwithContext(dispatcherProvider.io()). Never hardcodeDispatchers.IO.
@HiltViewModel+@Inject constructor. When a ViewModel needs a runtime parameter, use@HiltViewModel(assistedFactory = VM.Factory::class)+@AssistedFactory+@AssistedInject.- Modules are
@Module @InstallIn(SingletonComponent::class)— aninterfacewith@Bindsfor interfaces, aclasswith@Providesonly for platform types. - Prefer constructor injection over module
@Provides.
- Navigation 3 only. No legacy Jetpack Navigation.
- NavKeys are
@Serializableand implementNavKey.data classwhen carrying parameters,data objectwhen not. Keys reachable from other features go infeature/*/api; keys internal to one feature go in that feature'sphone/navigation/ortv/navigation/. - Register screens via
EntryProviderScope<NavKey>.<feature>Entries(navigator: Navigator)infeature/*/phone/navigation/, then wire that call into theentryProvider { }block inapp/src/main/kotlin/sk/styk/martin/apkanalyzer/ui/ApkAnalyzerApp.kt. A screen that isn't wired there is unreachable. - Transitions:
bottomEntryMetadata()/slideFromEndEntryMetadata()fromsk.styk.martin.apkanalyzer.core.designsystem.foundation.animation. - Multi-stack state lives in
:core:navigation— seecore/navigation/AGENTS.md.
- Feature modules must never import
androidx.compose.material3. Use Compose foundation APIs plus the wrappers in:core:design-system:phone. If a component isn't wrapped yet, wrap it there first — see thecreate-compose-componentskill. (appuses material3 directly forScaffoldand theme plumbing; that is the only exception.) TV code follows the same rule withandroidx.tv.material3and the wrappers in:core:design-system:tv. - Colors and type come from
AppTheme.colors/AppTheme.typography, icons fromApkAnalyzerIcons. Never hardcode a color or reach forMaterialTheme.colorSchemeoutside:core:design-system:phone. - Check the component inventory in
core/design-system/AGENTS.mdbefore calling a component — names don't always match file names. - Collections in State classes and Composable parameters are plain read-only
List,Set, andMap..compose/stability.confdeclares them stable to the Compose compiler, so never put aMutableList,ArrayList, or other mutable collection there — Compose would miss in-place changes.@Immutableon State data classes;@Stableon non-data classes used as Composable parameters. - Every file with
@Composablefunctions has@Previewfunctions:private, suffixedPreview, wrapped inApkAnalyzerTheme { }, with realistic sample data. Preview the stateless content composable, never the ViewModel-dependent screen. - Composable callbacks are present tense —
onClick,onSelectItem,onBack. NeveronClicked,onItemSelected,onBackPressed. LazyColumnitem keys must be unique across the whole list, not per section. A sectioned list keyed on an item identifier crashes when the same identifier lands in two sections. Deduplicate in the ViewModel rather than compounding the key with the section.- Two indicators of the same fact must come from one predicate. A verdict line and a row of icons that each recompute "is this a problem" will eventually contradict each other on screen.
- A row that has nothing to show on tap means the item sheet is missing, not that the idiom is
optional. Don't neutralise the tap (
indication = null, a no-oponClick) to hide a dead affordance — every list row in app detail istap = explain, long-press = copy.
data object, not plainobject, for sealed interface members.- A nullable primitive that encodes a variant or a third state is a type in disguise.
Boolean?meaning yes/no/unknown, or aString?whose null marks "a different kind of thing", belongs in an enum or a sealed interface.Feature(HardwarevsOpenGlEs) andFeatureAvailabilityincore:appsare the worked examples — both replaced exactly that shape. - Extract a shared helper when the second consumer appears — and grep for a third before writing your own. Duplicated composables here have reached three identical copies before anyone noticed.
- A core module packages by domain once it holds more than one repository/manager family, not by
layer. A flat root full of repositories, a flat
model/holding every data class, and a flatanalysis/utilgrab-bag for internals is the shape to avoid — it forces you to open three unrelated folders to understand one concept. Once a second repository/manager family shows up, give each family its own subpackage holding its interface, impl, models, and any private supporting types together; keep only the handful of models every family composes into at the module'smodel/root.core:apps'ssigning/,permissions/,components/,manifest/,installsource/,devicefeatures/,packaging/,usagestats/,storagestats/,export/, andsdkversion/packages are the worked example. A module with exactly one repository/manager family (e.g.core:app-permissions,core:app-index,core:apk-files) has no reason to do this yet — don't pre-split a module that hasn't earned it. - No wildcard imports.
- Prefer
private;internalfor module-visible;publiconly for actual public API. Loggerfromcore.common.logger, never raw Timber:Logger.d("Tag", "msg"),Logger.e("Tag", throwable, "msg"). Declareprivate const val TAGat file level.@Serializablefor nav keys and new models. Parcelize only for existing Android-specific data already passed through intents/bundles.- No test infrastructure or test dependencies exist here. Don't add tests, test dependencies, or test source sets unless explicitly asked.
Write for non-technical users who understand Android basics. Active voice, present tense, sentence case. Name the concrete thing rather than gesturing at it — "Shows which apps use the Camera permission", not "View permission information". Never surface class names, internal identifiers, or Android API names in a user-facing string.
Read the relevant skill in .claude/skills/ before starting a matching
task — skills hold the step-by-step procedures this file deliberately omits. Each skill's
frontmatter states when it applies.
create-feature-module, create-core-module, create-compose-component, implement-navigation,
spotless-fix, git-commit-author, setup-local-tools, analyze-ci-failure, run-app,
navigate-app-adb, sync-design-changes, translate-strings, release-app,
capture-app-flow-media, triage-crashes, fix-crash, verify, appfunctions,
store-listing-carousel.
All are shared by Claude and Copilot except sync-design-changes, which needs Claude's DesignSync
tool.
AGENTS.mdfiles are canonical. Put guidance in the closest relevantAGENTS.md; never copy it into a tool-specific file.- Scoped
AGENTS.mdfiles document durable context that cannot be inferred cheaply: module boundaries, package-level organization, required patterns, non-obvious behavior, and exceptional entry points. - Do not maintain exhaustive file trees, copied interfaces, ordinary dependency lists, or implementation summaries that repository search can recover more accurately. Name a specific file only when it is a canonical reference, an assembly point, or misleadingly named.
- Prefer package or domain maps over source-file inventories. Adding or renaming an ordinary source
file should not require an
AGENTS.mdupdate. CLAUDE.mdfiles contain exactly@AGENTS.mdand nothing else..github/copilot-instructions.mdis the Copilot adapter; Copilot also reads nestedAGENTS.mddirectly..claude/skills/is shared by both tools — never mirror skills into.github/skills/,.github/prompts/, or.agents/skills/.validateAgentContextenforces AGENTS/CLAUDE pairing, per-module coverage, skill frontmatter, duplicate skill locations, and that every local markdown link resolves.
Read these instead of copying a template into a new file: