Initial implementation of Audio Journal Android app - #1
Merged
Conversation
Single-screen Jetpack Compose voice recorder. Recording runs in a foreground microphone service driven by a unit-tested pure-Kotlin state machine. Finished .m4a files are saved to app storage and queued via WorkManager for upload to AWS S3 (credentials from local.properties; app degrades gracefully to local-only storage when unconfigured). Includes JVM unit tests, a Compose UI smoke test, and a GitHub Actions workflow that builds, lints, tests and publishes the debug APK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
The AWS SDK's transitive okhttp-android 5.4.0 dependency requires compileSdk 36, which AGP 8.7.3 does not support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
aws-sdk-kotlin 1.8.2 artifacts are compiled with Kotlin metadata 2.4.0, which the 2.1.21 compiler cannot read (internal compiler error in CI). Also migrate from the removed kotlinOptions DSL to compilerOptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Two AWS SDK transitive jars ship META-INF/versions/9/OSGI-INF/MANIFEST.MF, which fails mergeDebugJavaResource. The OSGi metadata is unused on Android. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Google Drive is now the default upload destination. Auth uses Play services' AuthorizationClient, so no secrets are embedded in the APK; the user connects their Google account in-app. Uploads go through a minimal Drive v3 REST client, with destination folders resolved by name (find-or-create), so an existing folder is reused. A configurable folder list (drive.folders) drives an in-app picker so each recording can target a different folder; the first entry is the default. The S3 backend is kept and selectable via upload.backend=s3, with the folder choice mapped to an S3 key prefix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Gradle 8.14.3 -> 9.6.1, AGP 8.10.1 -> 9.2.0, Kotlin 2.3.21 -> 2.4.0, Compose BOM 2025.05.00 -> 2026.06.01, and latest stable releases of androidx core/lifecycle/activity/work/test, coroutines, serialization, AWS SDK, and play-services-auth. material-icons-extended is now pinned explicitly since recent Compose BOMs no longer manage the frozen icons artifacts. CI moves to JDK 21 and current action majors (checkout@v6, setup-java@v5, gradle/actions@v6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
AGP 9 applies Kotlin itself and errors when the standalone org.jetbrains.kotlin.android plugin is also applied. Drop that plugin, let jvmTarget default from compileOptions.targetCompatibility, and pin the Compose compiler plugin to Kotlin 2.3.10 to match the Kotlin version bundled with AGP 9.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Current androidx stable releases (core 1.19.0, lifecycle 2.11.0) require compiling against API 37. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Google authorizes the app by package name + signing certificate SHA-1. CI runners generate a fresh random debug keystore per build, so artifact APKs could never match the OAuth client registration. Restore the owner's keystore from the DEBUG_KEYSTORE_B64 secret before building so CI APKs carry the registered certificate; without the secret the build still works, minus Google sign-in in the artifact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Check the secret via an env variable inside the script instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
upload-artifact@v4 artifacts are immutable within a run by default, so re-running jobs (e.g. after updating the signing keystore secret) kept serving the stale APK from the first attempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
AGP resolved its default debug keystore to a different location on the CI runner, so the keystore written to ~/.android was ignored and every run signed with a fresh random certificate. Point the debug signing config at ci-debug.keystore in the repo root when that file exists (decoded from the DEBUG_KEYSTORE_B64 secret in CI, gitignored, absent in local builds), and print its fingerprint in the CI log for easy verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
local.properties does not exist on CI runners, so artifact APKs always fell back to the default folder name. The build config already reads the DRIVE_FOLDERS env var; wire it to a repository variable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Folder names are ambiguous in Drive (duplicates are allowed), so destinations are now configured as Label=folderId entries; the label feeds the in-app picker and the S3 key prefix, the id is used directly as the upload parent (find-or-create by name is removed). An empty configuration uploads to the My Drive root. supportsAllDrives is set on the upload so shared-drive folder ids work too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
Folder names are ambiguous in Drive (duplicates are allowed), so destinations are now configured as Label=folderId entries; the label feeds the in-app picker and the S3 key prefix, the id is used directly as the upload parent (find-or-create by name is removed). An empty configuration uploads to the My Drive root. supportsAllDrives is set on the upload so shared-drive folder ids work too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB
….com/dschuld/audio-journal into claude/android-audio-recorder-bgx133
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR introduces the complete initial implementation of Audio Journal, a simple Android voice recorder with automatic cloud upload to AWS S3. The app provides a minimal UI with a single record button, pause/resume controls, and background upload functionality.
Key Changes
Core Recording Engine
RecordingEngine: Pure Kotlin state machine managing recording lifecycle (idle → recording ↔ paused → idle) with elapsed time tracking that excludes paused periodsAudioRecorderinterface withMediaRecorderAudioRecorderimplementation producing AAC-encoded.m4afilesRecordingService: Foreground service keeping the microphone alive with ongoing notification, driven by intents from the UICloud Upload
S3CloudUploader: Uploads recordings to AWS S3 using AWS SDK for KotlinUploadWorker: WorkManager-based background task with exponential backoff retry and network connectivity awarenessUploadScheduler: Queues uploads when recordings finish; handles offline scenarios gracefullyBuildConfigfields populated fromlocal.propertiesor environment variables at build timeUI Layer
RecorderScreen: Jetpack Compose UI with recording status, elapsed time display, and context-aware buttons (record/pause/resume/stop)RecorderViewModel: Observes sharedRecordingEnginestate and provides elapsed time tickerRECORD_AUDIOandPOST_NOTIFICATIONS(Android 13+)Storage & Configuration
RecordingStore: Generates collision-free timestamped filenames (recording_YYYY-MM-DD_HH-MM-SS.m4a)AppContainer: Hand-rolled dependency injection holding singletonRecordingEngineandCloudUploaderS3Config: Immutable configuration with validation for required credentialsTesting
RecordingEnginewith fakeAudioRecorderandTimeSourceUploadDecision)Notable Implementation Details
RecordingEngineis pure Kotlin (no Android framework dependencies) for full testability; invalid state transitions are silently ignored to handle UI/service intent racesrecordedBeforePauseMillisandrecordingSinceMillisServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONEon Android Q+ for proper categorizationhttps://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB