Code Audit Protocol
This protocol defines how FastMediaSorter audits Android code for correctness, readability, memory safety, lifecycle safety, concurrency safety, and performance.
Scope:
app_v2/wear/- shared build, script, and documentation surfaces that affect runtime behavior
Out of scope:
- visual design review
- copywriting polish
- product-priority decisions unless they affect code risk
Goals
- keep code readable and cheap to reason about
- catch memory leaks before release
- prevent lifecycle-unsafe coroutine and listener usage
- keep concurrent access correct, not only leak-free
- keep startup and hot user flows fast
- make regressions reproducible with evidence
- classify every finding by severity so triage is consistent
- turn recurring review comments into mechanical gates
Core rule
Audit in layers:
- prevent
- reproduce
- explain
- gate
Do not jump straight to profiler sessions for every change. First use the cheapest proof that can reject a bad change.
Evidence ladder
Use the lowest-cost proof that matches the risk.
- Static code review
- Existing quality gates
- Targeted compile or resource validation
- Focused unit test
- Debug runtime detection
- Device smoke
- Repeatable benchmark
- Trace or heap analysis
Examples:
- architectural rule check -> static review + gate
- suspected coroutine leak -> gate + debug runtime + LeakCanary
- startup regression -> benchmark + Perfetto trace
Finding severity taxonomy
Tag every finding so a leaked Activity is never weighed the same as an extra !!.
- P0 - crash, ANR, OOM, retained Activity/Fragment/View, deadlock, data loss. Blocks release.
- P1 - data race on shared mutable state, main-thread disk or network I/O, unbounded cache growth, unreleased heavy resource (player, cursor, surface). Fix before merge.
- P2 - allocation churn on a hot path, repeated expensive lookups, missing lifecycle-awareness without a proven leak, over-eager startup work. Fix or file a ticket.
- P3 - readability, naming,
!!without justification, dead comment, minor style. Fix inline or note.
Rule:
- attach a severity to each finding in review comments and in
## Last Auditnotes - P0 and P1 require evidence at the matching rung of the evidence ladder, not opinion
Audit triggers
Run the protocol when any of these is true:
- new screen, manager, worker, repository, or long-lived helper is added
- lifecycle code changes
- coroutine, Flow, callback, listener, or observer code changes
- shared mutable state, synchronization, or dispatcher usage changes
- Room entity, DAO, query, migration, or transaction changes
- player, image loading, caching, or network path changes
- startup path changes
- DI scope or singleton ownership changes
- build, manifest, R8, or keep-rule changes affect profiling, startup, minification, or process behavior
- crash, ANR, OOM, jank, or leak is reported
Layer 1 - Static architecture and readability audit
Check these first:
- UI -> ViewModel -> UseCase -> Repository -> DataSource is preserved
- no business logic is introduced into Activities
- singleton classes do not retain
Activity,Fragment,View,Dialog, or adapter instances - manager classes own a clear release point
- dependencies are scoped intentionally in Hilt
- classes carry few enough responsibilities to review safely
- comments explain why, not what
Class-size methodology - rank by responsibility, not by raw line count:
- run
scripts/quality/measure-hotspots.ps1; it scores eachsrc/mainKotlin file bypublicApi + collaborators + callbackSites + 3*extractMarkers, with LOC reported only for context - a high score means too many responsibilities, which is the real refactor signal; the 1500 LOC rule is only a coarse backstop
- prefer extracting a real responsibility into a
helpers/*Manager.ktover cosmetic line-count splitting
Readability checks:
- no
!!without a justified reason; prefer?.,requireNotNull(x) { "why" }, or a sealed/early-return path - bounded nesting depth; collapse arrow code with early returns and guard clauses
- nullability is expressed in types, not defended with scattered null checks
- screen and domain state is modeled with sealed classes or enums, not loose booleans and flags
- avoid boolean-trap parameters; name intent at the call site
Project hooks already present:
- no Activity logic rule from
CLAUDE.md - file-size and helper-extraction rules
- responsibility ranking via
scripts/quality/measure-hotspots.ps1 - custom script gates under
scripts/quality/ detekt+ ktlint formatting ratchet gate
Layer 2 - Lifecycle, coroutine, and concurrency audit
For every coroutine or Flow change, verify:
- no
GlobalScope - long-lived jobs use injected or lifecycle-bound scopes only
- UI collection is lifecycle-aware (prefer
repeatOnLifecycleorflowWithLifecycleinstead of direct collection inlifecycleScope.launchto prevent background execution/leaks when the app is in the background) - cancellation is not swallowed
- dispatchers are not hardcoded deep inside business logic unless justified
launchvsasyncusage is intentional- callbacks and listeners are unregistered on pause, stop, or destroy as needed
Concurrency correctness (beyond leaks):
- shared mutable state is confined to one thread, or guarded with
Mutex/synchronized/@Volatile; no read-modify-write race - suspend functions are main-safe:
withContext(Dispatchers.IO)sits at the Repository or DataSource boundary, not buried in business logic or pushed onto callers - hot or shared Flows use
stateIn/shareInwithSharingStarted.WhileSubscribed(5_000)so upstream stops when nothing observes - emissions are trimmed with
distinctUntilChanged,conflate, orbufferwhere the collector cannot keep up - no blocking call (
runBlocking, blocking I/O,Thread.sleep) on a UI or single-threaded dispatcher
Current project gates:
scripts/quality/assert-globalscope.ps1scripts/quality/assert-unsafe-collect.ps1
Deterministic lifecycle probe (no profiler needed):
- enable Developer Options “Don’t keep activities” and background process limit = 1
- keep debug
StrictModewithpenaltyDeathon - exercise rotation, background/foreground, and recents-swipe; surviving this proves state restoration and scope ownership without a heap dump
Review questions:
- what owns this job
- when is it cancelled
- what keeps this object reachable
- what mutates this state, and from which thread
- what happens on rotation, background, and process death
Layer 3 - Memory ownership audit
Focus on object retention, cache growth, and heavy-resource release.
Check:
- player instances are always released
- Glide or bitmap requests are cancelled when the host dies
- handlers, runnables, and delayed tasks do not capture dead UI
- adapters do not keep stale listeners or view references
- caches have size bounds and eviction rules
- large temporary allocations are not retained across screens
Contextusage is minimized andapplicationContextis used when UI context is not requiredViewBindingreferences in Fragments are set tonullinonDestroyView()to avoid keeping view hierarchies aliveViewModelholds noView,Context,Fragment, orActivityreferenceViewHolderdoes not store strong references to Activity/Fragment/Context- resource closure is enforced using Kotlin’s
.use { .. }block for allCloseabletypes (streams, cursors, DB sessions)
Register/unregister symmetry (high-value for this MediaStore-heavy app):
- every
registerContentObserverhas a matchingunregisterContentObserveron a lifecycle edge - every
registerReceiverhas a matchingunregisterReceiver - every
addListener/addCallback/addObserver(especiallyPlayer.Listener) has a matchingremoveListener/removeCallback - registration and removal happen on symmetric lifecycle callbacks (
onStart/onStop,onResume/onPause,onCreate/onDestroy), never split across asymmetric ones
ExoPlayer / Media3 ownership:
- a single owner holds each player instance; no duplicate or orphaned instance
- release on
onStopfor API 24+ multi-window, re-create ononStart setVideoSurface(null)before release; remove every addedPlayer.Listener- audio focus is abandoned on release;
playWhenReadyis reset - the player family is mirrored per host - apply the same release contract to every host, not only the one being edited
Glide / bitmap ownership:
- decode at display size with
override(w, h); do not load full-resolution into a thumbnail - prefer
RGB_565for opaque thumbnails to halve bitmap memory clear(target)on detach; do not bindGlide.with(activity)to a long-lived target- memory cache and pool sizes are intentional, not default-by-accident
Shared-state ownership:
- run
scripts/quality/audit-shared-state-writers.ps1to confirm a single intentional writer per shared state, and that consumers read eligibility rather than override it
Debug detector:
LeakCanaryin debug builds (BuildConfig.ENABLE_LEAKCANARY)
Project integration already present:
app_v2/src/debug/java/com/sza/fastmediasorter/core/debug/DebugToolsBootstrap.kt
Required leak scenarios for critical flows:
- open player -> close player
- browse -> player -> back
- rotate during active playback
- dialog or bottom-sheet open -> dismiss -> reopen
- repeated open/close of the same heavy screen
- process death and restore (via “Don’t keep activities”)
Layer 4 - Database and Room audit
Room is core to the stack (2.7.0). Audit every entity, DAO, query, or migration change.
Check:
- no main-thread queries;
allowMainThreadQueries()stays banned - DAO methods are
suspendor returnFlow, never blocking calls on a UI path - multi-step writes that must be atomic are wrapped in
@Transaction @Transactionis used for relation reads that issue more than one query, to keep the result consistent- cursors and
RawQueryresults are closed (.use { .. }) and not held open across screens - queries do not load a full table when a bounded page,
LIMIT, or projection suffices - frequent
WHERE/ORDER BYcolumns are indexed; verify withEXPLAIN QUERY PLANfor hot queries - no N+1 pattern - one query per row inside a loop becomes a single join or
IN (..)query Flowqueries are deduplicated (distinctUntilChanged) so an unrelated table write does not re-emit and re-render- every schema change ships a migration and a migration test; no destructive fallback in release
Review questions:
- does this query run off the main thread end to end
- is the result bounded, or can it grow with the library size
- is this read consistent under concurrent writes
- does a write to an unrelated row wake this Flow
Layer 5 - Main-thread and startup audit
Check for avoidable work on the main thread:
- disk reads and writes
- database queries
- network calls
- expensive parsing
- oversized object graph initialization
- non-critical startup work that can be deferred
Current project hooks:
- debug
StrictMode StrictModeHelperAppStartupInitializer- deferred startup worker path
reportFullyDrawn()marker inMainActivity
Required questions:
- does this work need to happen before first interaction
- can this be lazy-loaded
- can this move to deferred startup
- can the dependency be injected as
dagger.Lazy<T>
Layer 6 - Performance audit
Use two levels:
Static performance review:
- avoid repeated allocation inside hot loops
- avoid repeated path parsing, decoding, sorting, and filtering when results can be reused
- check collection churn in adapters and player helpers
- confirm cache hit path is cheaper than miss path
- prioritize primitive-optimized collections (
SparseArray,LongSparseArray,ArrayMap,ArraySet) over JavaHashMap/HashSetwhen keys are numeric IDs to avoid autoboxing - use
value class(inline classes) for domain identifiers to eliminate runtime object allocation overhead - implement partial updates in lists using
DiffUtilpayloads (getChangePayload) to prevent full rebinds - RecyclerView hygiene: stable IDs where rows persist,
setHasFixedSizewhen size is fixed, sharedRecycledViewPoolfor nested lists, and nevernotifyDataSetChangedwhen a targeted notify orListAdapterdiff applies
Measured performance review:
- benchmark cold start
- benchmark open browse on a large dataset
- benchmark open player
- benchmark first frame or first audio start
- benchmark back-navigation from player
Preferred tooling:
MacrobenchmarkBaseline ProfilesPerfetto- Android Studio Allocation Tracker for churn, when static review cannot locate the source
Rule:
- use benchmarks to prove regression
- use Perfetto to explain regression
- use allocation tracking to locate churn
Layer 7 - Release-build and R8 correctness audit
A passing debug build does not prove the shipped artifact is correct. For changes that touch reflection, serialization, DI graphs, manifests, or dependencies, verify on the minified release/target variant.
Check:
- the release build of the affected flavor compiles, packages, and runs the touched flow
- keep rules cover any reflective or serialized type (Gson/Moshi models, Room, reflection-based libraries)
- no unexpected
R8: missing classorunresolvedwarnings in the minify log - dead-code shrink did not remove a runtime-needed entry point (ties to
CLAUDE.mdRule 20) - the change behaves identically across the flavor matrix it claims to support
Rule:
- a P0/P1 change that affects reflection, DI, or manifests is not done until it is proven on a minified build, not only on debug
Layer 8 - Mechanical gates
If a review comment appears repeatedly, convert it into a gate.
Preferred order:
- project script gate
- custom Android Lint check
- benchmark threshold
Use script gates for fast pattern enforcement. Use custom Lint when the rule is structural and should appear directly in IDE feedback. Use benchmark thresholds when the problem is quantitative.
Recommended future custom Lint rules for this project:
- no Activity business logic
- no UI context stored in singleton or long-lived manager
- no lifecycle-unsafe Flow collection in UI
- no unreleased player/listener ownership pattern
- no direct main-thread disk I/O outside approved wrappers
- no main-thread Room access
Recommended CI/CD automated dynamic analysis additions:
- integrate LeakCanary into instrumented tests via
leakcanary-android-instrumentationto automatically catch memory leaks on CI
Standard audit procedure for a change
- Classify the change and assign an expected severity ceiling.
- Pick the lowest-cost evidence.
- Run static architecture and readability review.
- Run lifecycle, concurrency, memory, and Room review if coroutines, shared state, listeners, player, cache, database, or startup are touched.
- Run project gates.
- Run the cheapest validation command from
docs/BUILD_TEST_FAST_PATH.md. - If risk remains, run debug runtime checks (LeakCanary, StrictMode, “Don’t keep activities”).
- If the change touches reflection, DI, manifests, or dependencies, prove it on a minified release/target variant.
- If regression is observed, capture benchmark evidence.
- If the benchmark fails or the issue is still unclear, capture Perfetto trace, allocation trace, or heap dump.
- Convert recurring findings into a permanent gate.
Standard audit checklist for PR review
Use this checklist in review comments or self-review; tag each finding with a severity:
- ownership of every long-lived object is explicit
- every listener, callback, observer, receiver, and job has a symmetric release point
- no dead UI object can remain strongly referenced after destroy
- shared mutable state has one owner and no unsynchronized race
- every Room query runs off the main thread and is bounded
- no heavy startup work is done eagerly without reason
- no main-thread disk, database, or network path is introduced
- cache growth is bounded
- hot paths avoid repeated allocation and repeated expensive lookups
- failure paths clean up resources too
- reflection/serialization/DI changes are proven on a minified build
- logs are useful and do not become permanent debug noise
- readability: no unjustified
!!, no boolean traps, state is typed - validation evidence matches the risk of the change
Standard incident procedure
For crash, ANR, leak, OOM, or jank:
- classify severity and reproduce on the narrowest possible scenario
- capture logs
- capture LeakCanary evidence for retention issues
- capture benchmark numbers for reproducible slowness
- capture Perfetto trace for unexplained slowness or jank
- reduce to one owner, one resource, one lifecycle edge, one race, or one hot path
- add or tighten a gate so the same class of issue becomes cheaper to catch next time
FastMediaSorter current baseline
Already present:
- debug
StrictMode StrictModeHelperLeakCanaryprofileinstaller- standard-flavor Macrobenchmark + Baseline Profile harness (
benchmark/, S0722) - startup markers
- Perfetto workflow playbook (
docs/PERFETTO_PLAYBOOK.md) - quality gates for
GlobalScopeand unsafe Flow collect detekt+ ktlint formatting ratchet gate- listener symmetry ratchet gate (
scripts/quality/assert-listener-symmetry.ps1) - responsibility ranking (
measure-hotspots.ps1) and shared-state writer audit (audit-shared-state-writers.ps1) - startup deferral infrastructure
Recommended next additions:
- extend perf coverage beyond
standardif a flavor-specific hotspot appears - wire selected perf commands into CI or managed-device automation when the local flow stabilizes
- ratchet benchmark JSON summaries once representative device baselines are committed
Repo commands and anchors
Useful local commands:
.\a.ps1 fk
.\a.ps1 fr
.\a.ps1 fc
.\a.ps1 fu
.\a.ps1 mb
.\a.ps1 gbp
.\a.ps1 adb launch
.\a.ps1 adb log -Tail 400 -Grep "FATAL|ANR|Sxxxx"
pwsh -NoProfile -File scripts/quality/assert-globalscope.ps1 -Gate
pwsh -NoProfile -File scripts/quality/assert-unsafe-collect.ps1 -Gate
pwsh -NoProfile -File scripts/quality/assert-listener-symmetry.ps1 -Gate
pwsh -NoProfile -File scripts/quality/measure-hotspots.ps1
pwsh -NoProfile -File scripts/quality/audit-shared-state-writers.ps1
Important code anchors:
app_v2/src/main/java/com/sza/fastmediasorter/FastMediaSorterApp.ktapp_v2/src/main/java/com/sza/fastmediasorter/core/debug/StrictModeHelper.ktapp_v2/src/main/java/com/sza/fastmediasorter/core/init/AppStartupInitializer.ktapp_v2/src/debug/java/com/sza/fastmediasorter/core/debug/DebugToolsBootstrap.ktdocs/BUILD_TEST_FAST_PATH.mddocs/PERFETTO_PLAYBOOK.md
External references
These references informed the protocol as checked on 2026-06-26:
- Android StrictMode
- Android coroutines best practices
- Kotlin Flow and StateIn/ShareIn guidance
- Android Lint and custom lint checks
- detekt static analysis for Kotlin
- ktlint formatter
- Room best practices and threading
- Media3 ExoPlayer lifecycle and resource release
- Glide caching and bitmap configuration
- Android Macrobenchmark overview
- Android Baseline Profiles overview
- LeakCanary fundamentals
- Perfetto documentation
- Android ProfilingManager API
- OWASP MASTG
Reference URLs:
- https://developer.android.com/reference/android/os/StrictMode
- https://developer.android.com/kotlin/coroutines/coroutines-best-practices
- https://developer.android.com/kotlin/flow/statein-sharein
- https://developer.android.com/studio/write/lint
- https://detekt.dev/
- https://pinterest.github.io/ktlint/
- https://developer.android.com/training/data-storage/room
- https://developer.android.com/media/media3/exoplayer/lifecycle
- https://bumptech.github.io/glide/
- https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview
- https://developer.android.com/topic/performance/baselineprofiles/overview
- https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/
- https://perfetto.dev/docs/
- https://developer.android.com/reference/android/os/ProfilingManager
- https://mas.owasp.org/MASTG/