Imported from Dimowner/AudioRecorder (
AGENTS.md). Install upstream withnpx skills add Dimowner/AudioRecorder. Copyright stays with the author.
AGENTS.md
This file provides guidance to AI coding agents working with this repository.
Build Commands
# Build
./gradlew build
# Unit tests (debug config flavor)
./gradlew testDebugConfigDebugUnitTest
# Instrumented tests (requires connected device/emulator)
./gradlew connectedDebugConfigDebugAndroidTest
# Coverage reports
./gradlew jacocoTestReport # Unit test coverage → app/build/reports/jacoco/html/
./gradlew jacocoFullReport # Combined coverage → app/build/reports/jacoco/htmlFull/
./gradlew jacocoTestCoverageVerification # Enforce 5% line coverage minimum
Build flavors: debugConfig (debug, signed) and releaseConfig (minified/shrunk). Most tasks require the flavor in the task name (e.g., testDebugConfigDebugUnitTest).
Architecture Overview
Dual Codebase (V1 → V2 Migration in Progress)
The app contains two parallel implementations:
- V2 (
app/src/main/java/.../v2/) — active development target: Kotlin, Jetpack Compose, Hilt, Room, MVVM + Clean Architecture - V1 (
app/src/main/java/.../root level) — legacy: Java, Views, manual DI viaInjector.java, MVP pattern
The user can toggle between them via settings. HomeActivity is the V2 entry point; MainActivity is the V1 entry point.
All new development should target the v2/ package.
V2 Package Structure
v2/
├── app/ # UI layer — one subfolder per screen (home, records, settings, deleted, lostrecords, info, welcome)
│ └── components/ # Reusable Compose components
├── audio/ # Recording and playback logic + foreground services
├── data/ # Data layer — Room DB, data sources, file I/O, prefs
│ ├── room/ # AppDatabase, DAOs, entities
│ ├── model/ # Domain models (Record, SortOrder, etc.)
│ └── Mappers.kt
├── navigation/ # RecorderNavigationGraph.kt + Routes.kt
├── di/ # Hilt modules (AppModule, DatabaseModule, DataSourceModule) + qualifiers
└── theme/ # Compose theme (dark/dynamic color)
Core Patterns
- Language/UI: Kotlin, Jetpack Compose, Material3
- DI: Hilt (
@HiltAndroidApp,@AndroidEntryPoint,@HiltViewModel) - Database: Room
- Async: Coroutines with
@IoDispatcher/@MainDispatcherqualifiers - Navigation: Navigation Compose
- State management: ViewModels expose immutable state data classes via
mutableStateOf(). One-shot events useSharedFlow.
Service-Based Audio Architecture
Recording and playback run as foreground services (AudioRecordingService, AudioPlaybackService). ViewModels bind via ServiceConnection and receive state via StateFlow/SharedFlow.
Entry Points
HomeActivity— V2 entry pointMainActivity— V1 legacy entry point
Key Non-Obvious Behaviors
- Broken record recovery:
BrokenRecordRestorer.ktdetects/repairs WAV/M4A/3GP files interrupted by crash or reboot. - Waveform data: See the dedicated Waveform Visualization section below.
- Bluetooth mic:
AudioManagerHelpermonitors Bluetooth state reactively;HomeViewModelmanages source selection. - Preferences:
PrefsV2wraps SharedPreferences with Flow-based reactive updates.PrefsandPrefsV2share the same SharedPreferences file.
Waveform Visualization
The waveform UI has two distinct lifecycles: a live waveform that scrolls during active recording, and a static waveform rendered from a persisted amplitude array for finished records. Both V1 and V2 follow the same conceptual pipeline but with different recorder/UI stacks. The post-recording decoder is shared between V1 and V2.
Live waveform (during recording)
The live waveform deliberately keeps in memory only the slice currently visible on screen. Older amplitudes that have scrolled off the left edge are dropped — they are not part of any persistent buffer. The full waveform of the finished file is reconstructed afterwards by decoding the audio (see next section).
V2 — v2/audio/
MediaRecorderBase.ktpollsmediaRecorder.getMaxAmplitude()from a scheduledrecordingTimeUpdateRunnable, accumulating samples intoIntArrayList amplitudesBuffer. The timer emitsRecorderEvent.OnRecordingProgress(durationMills, amplitude)everyAppConstants.RECORDING_VISUALIZATION_INTERVAL_NEW(~10ms).AudioRecordingService.ktkeeps a fixed-size sliding window inLinkedList<Int> recordingAmplitudes. The size cap is computed from screen width bycalculateRecordingAmplitudeBufferSize()(AudioRecordingService.kt:781) so the buffer holds exactly the half-screen-worth of samples that are visible on the right side of the scrubber. On every new sample,handleRecordingProgress()appends to the tail; once the cap is exceeded it callsrecordingAmplitudes.removeFirst()— this is the "delete what's no longer visible" behavior. Samples are also scaled by ~1.2× for visual amplification.- The service publishes
RecordingServiceState.amplitudes: IntArrayplustotalSampleCount/waveformDataOffset(absolute timeline position) viaStateFlow.HomeViewModelcollects this and feeds the Compose state. - Rendering:
v2/app/components/WaveformComposeView.ktusesCanvas.drawLines()to draw the visible amplitudes around a centered scrubber. Grid spacing comes fromAppConstantsV2.RECORDING_GRID_STEP(2000ms). Layout math:pxPerMill = screenWidth × DEFAULT_WIDTH_SCALE / SHORT_RECORD.
V1 — root-level package
AudioRecorder.javaschedulesrecorder.getMaxAmplitude()polls everyAppConstants.RECORDING_VISUALIZATION_INTERVAL(13ms) and invokesonRecordProgress(durationMills, amplitude)on its callback.MainActivityforwards each tick toRecordingWaveformView.addRecordAmp(amp, mills)(app/widget/RecordingWaveformView.kt:96). The view owns the sliding window directly:MutableList<Int> recordingDatais trimmed viaif (recordingData.size > pxToSample(viewWidthPx / 2)) recordingData.removeAt(0). Same principle as V2 — the buffer length is tied to the visible viewport in pixels.- Rendering is canvas-based (
onDraw→drawGrid+drawRecordingWaveform), accumulating segments intoFloatArray drawLinesArray.
Static waveform (after recording stops)
When recording finishes the entire audio file is decoded once to extract a downsampled amplitude array, which is persisted alongside the record. The UI then reads that array to draw the full-duration waveform of any saved recording.
Decoding pipeline (shared by V1 and V2):
audio/AudioWaveformVisualization.ktis a thin Kotlin wrapper aroundaudio/AudioDecoder.java.AudioDecoderuses Android'sMediaExtractor+MediaCodecto stream PCM frames from the recorded file (works for WAV, M4A, 3GP, etc.). For each frame it reads 16-bit shorts, takes the per-frame max across channels, applies a sqrt gain curve, and appends one integer toIntArrayList gains. Frame size is derived fromsampleRate / dpPerSec(calculateSamplesPerFrame()), so the output is already a UI-sized, simplified amplitude array — not the raw PCM.DecodeServiceorchestrates the work as a foreground service. Decoding is skipped for very long recordings (longer thanAppConstants.DECODE_DURATION= 2 hours, seeDecodeService.kt:251) — those records never get a persistedampsarray and fall back to a non-waveform UI.
V2 storage and display:
DecodeService.startNotificationV2()is invoked fromAudioRecordingServicewhen recording stops. After decode, it writes the amplitude array into the Room entity viarecordsDataSource.updateRecord(record.copy(amps = data, isWaveformProcessed = true)). The amplitudes live onv2/data/room/Record.amps: IntArray.- For display,
v2/app/info/widget/WaveformWidget.kt(andWaveformStaticWidget) readsRecord.ampsand resamples it to the canvas width:samplePerPx = durationSample / canvasWidth, then for each pixel picksamps[(index * samplePerPx).toInt()]and normalizes againstAppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE(32767f).
V1 storage and display:
DecodeServiceupdates the legacy SQLite-backedLocalRepository. The V1 entityRecord.javahas its ownint[] ampsfield that mirrors the V2 schema conceptually (the V1→V2 Room migration carries this field across — see041131fand surrounding migration commits).- For display,
app/widget/WaveformViewNew.kt(the newer V1 view) and the olderWaveformView.javaconsume the storedampsviasetWaveform(frameGains). They adjustpxPerSecondbased on duration (ARApplication.getDpPerSecond) and draw one line per sample.SimpleWaveformView.javais a stripped-down variant used in list rows.
Short-record width scaling
The waveform's total drawn width is not constant — it depends on the record's duration relative to a "short record" threshold. The intent: a long record stretches across a fixed number of screens (DEFAULT_WIDTH_SCALE, currently 1.5 — i.e., the waveform spans 1.5 screen widths and the user scrolls/scrubs through it), while a short record uses a proportionally smaller width so its samples don't get visually stretched. This applies to both the live recording view (as duration grows) and the static post-recording view.
Constants:
AppConstantsV2.SHORT_RECORD = 18000L(18s, V2) andWaveformViewNew.SHORT_RECORD = 18000(V1 newer view) — duration threshold above which the waveform reaches its maximum width.AppConstantsV2.DEFAULT_WIDTH_SCALE = 1.5f— describes how many screen widths the full waveform takes when the record is long enough.1.0would mean exactly one screen width;1.5means 1.5 screens.AppConstants.LONG_RECORD_THRESHOLD_SECONDS = 20andAppConstants.SHORT_RECORD_DP_PER_SECOND = 25— the older V1 equivalents used byARApplication.getDpPerSecond()and the legacyWaveformView.java.
The rule (V2 + V1 WaveformViewNew):
widthScale = if (durationMills >= SHORT_RECORD) DEFAULT_WIDTH_SCALE
else durationMills * (DEFAULT_WIDTH_SCALE / SHORT_RECORD)
So a 9s record renders at widthScale = 0.75 (about three-quarters of one screen); an 18s+ record renders at the full 1.5 screens. Short records are not scaled down to DEFAULT_WIDTH_SCALE — they sit at a smaller scale that grows linearly with duration. This keeps short recordings dense and readable instead of being smeared across 1.5 screens of mostly empty waveform.
- V2 live recording: computed every tick in
AudioRecordingService.handleRecordingProgress()(AudioRecordingService.kt:302) and published asRecordingServiceState.widthScale.WaveformComposeViewuses it for layout math. - V1 newer view:
WaveformViewNew.calculateScale()(WaveformViewNew.kt:234) implements the same branch. - V1 legacy view (
WaveformView.java): uses an older but conceptually equivalent rule viaARApplication.getDpPerSecond(durationSec)(ARApplication.kt:234). FordurationSec > LONG_RECORD_THRESHOLD_SECONDS(20s), it computesdpPerSec = WAVEFORM_WIDTH × screenWidthDp / durationSecso the whole record fits 1.5 screens; otherwise it uses the fixedSHORT_RECORD_DP_PER_SECOND(25 dp/s), which gives short records a natural, un-stretched density. The threshold (20s) and short-record formula differ from the newer code (18s, linear interpolation), but the intent is identical.
When working on waveform layout, never hardcode width assumptions — always derive from widthScale / dpPerSec so short and long records behave correctly.
Practical notes
- The live recording buffer and the persisted
ampsarray are independent. Live samples are sized for the viewport (~hundreds of ints); persistedampsis sized to dpPerSec across the whole file (typically a few hundred to a few thousand ints regardless of audio length). - Buffer-sizing math in
AudioRecordingService.calculateRecordingAmplitudeBufferSize()mirrors the rendering math inWaveformComposeView. If you change one, change both. AudioWaveformVisualizationandAudioDecoderare shared legacy code that V2 still depends on — they live at the root level, not underv2/. Treat them as part of the V2 contract until a V2-native replacement exists.- V1 has two parallel short-record thresholds:
LONG_RECORD_THRESHOLD_SECONDS = 20(used by the oldWaveformView.java+ARApplication.getDpPerSecond) and the newerWaveformViewNew.SHORT_RECORD = 18000(matches V2). Make sure you're touching the right one for the view you're modifying.
Testing
- Unit tests:
app/src/test/— use MockK (not Mockito) and Robolectric - Instrumented tests:
app/src/androidTest/ - JaCoCo excludes generated code, Hilt classes, Activities, and Compose components from coverage
Key Dependencies
| Purpose | Library |
|---|---|
| DI | Hilt 2.59 |
| UI | Jetpack Compose BOM 2026.02, Material3 |
| Navigation | Navigation Compose 2.9 |
| Database | Room 2.8 |
| Playback | android.media.MediaPlayer |
| Audio metadata | jaudiotagger 3.0, mp4parser 1.9 |
| Async | Coroutines 1.10 |
| Logging | Timber 5.0 |
| Test mocking | MockK 1.14 |