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 exception is an explicit request for a comment in that instance.
- 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.
| When | Run |
|---|---|
| Iterating on one module | ./gradlew :feature:apps:impl:compileDebugKotlin |
| Before committing | ./gradlew spotlessApply |
| Whole-app check (what CI gates on) | ./gradlew spotlessCheck detektDebug :build-logic:convention:detektMain lintDebug :app:assembleDebug |
| After changing a context file, skill, adapter, or the module graph | ./gradlew validateAgentContext |
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/, core/<name>/, and feature/<name>/{api,impl}/; settings.gradle.kts
is the authoritative list.
feature/*/api— depends on nothing, holds only@SerializableNavKeys and the tab-label string.feature/*/impl—api(projects.feature.<name>.api)plus whichevercoremodules it needs.core/*— may depend on othercoremodules. Never depends on afeaturemodule.- A feature never depends on another feature's
impl. 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.- 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/impl→feature.appdetail.impl.appuses the root packagesk.styk.martin.apkanalyzerwith no suffix. feature:browseis a stub — a placeholder screen with no ViewModel or logic. Don't assume it works.
- 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. - 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'simpl/navigation/. - Register screens via
EntryProviderScope<NavKey>.<feature>Entries(navigator: Navigator)infeature/*/impl/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.uilibrary.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:ui-library. 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.) - Colors and type come from
AppTheme.colors/AppTheme.typography, icons fromApkAnalyzerIcons. Never hardcode a color or reach forMaterialTheme.colorSchemeoutside:core:ui-library. - Check the component inventory in
core/ui-library/AGENTS.mdbefore calling a component — names don't always match file names. - Every list property in State classes and Composable parameters is an
ImmutableListfromkotlinx.collections.immutable.@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.
- 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.
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.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: