From ba065afe3082724c6900c3479134e3fe52c5c2f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:45:12 +0000 Subject: [PATCH 01/17] Add Audio Journal Android app: record/pause/resume/stop with S3 upload 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 Claude-Session: https://claude.ai/code/session_01SvPfCEeJ3tvSAje8PnEfPB --- README.md | 172 +++++++++++- app/build.gradle.kts | 109 ++++++++ app/proguard-rules.pro | 6 + .../audiojournal/app/RecorderScreenTest.kt | 28 ++ app/src/main/AndroidManifest.xml | 36 +++ .../com/audiojournal/app/AudioJournalApp.kt | 50 ++++ .../java/com/audiojournal/app/MainActivity.kt | 21 ++ .../app/recording/AudioRecorder.kt | 74 ++++++ .../app/recording/RecorderState.kt | 17 ++ .../app/recording/RecordingEngine.kt | 136 ++++++++++ .../app/recording/RecordingService.kt | 132 +++++++++ .../app/storage/RecordingStore.kt | 35 +++ .../com/audiojournal/app/ui/RecorderScreen.kt | 244 +++++++++++++++++ .../audiojournal/app/ui/RecorderViewModel.kt | 61 +++++ .../com/audiojournal/app/ui/TimeFormat.kt | 14 + .../com/audiojournal/app/ui/theme/Theme.kt | 29 ++ .../audiojournal/app/upload/CloudUploader.kt | 25 ++ .../app/upload/S3CloudUploader.kt | 60 +++++ .../app/upload/UploadScheduler.kt | 34 +++ .../audiojournal/app/upload/UploadWorker.kt | 72 +++++ .../res/drawable/ic_launcher_foreground.xml | 16 ++ .../main/res/drawable/ic_notification_mic.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 21 ++ app/src/main/res/values/themes.xml | 4 + .../app/recording/RecordingEngineTest.kt | 200 ++++++++++++++ .../app/storage/RecordingStoreTest.kt | 51 ++++ .../com/audiojournal/app/ui/TimeFormatTest.kt | 30 +++ .../audiojournal/app/upload/S3ConfigTest.kt | 22 ++ .../app/upload/UploadDecisionTest.kt | 31 +++ build.gradle.kts | 6 + gradle.properties | 11 + gradle/libs.versions.toml | 37 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ local.properties.sample | 11 + settings.gradle.kts | 24 ++ 41 files changed, 2194 insertions(+), 1 deletion(-) create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/audiojournal/app/RecorderScreenTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/audiojournal/app/AudioJournalApp.kt create mode 100644 app/src/main/java/com/audiojournal/app/MainActivity.kt create mode 100644 app/src/main/java/com/audiojournal/app/recording/AudioRecorder.kt create mode 100644 app/src/main/java/com/audiojournal/app/recording/RecorderState.kt create mode 100644 app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt create mode 100644 app/src/main/java/com/audiojournal/app/recording/RecordingService.kt create mode 100644 app/src/main/java/com/audiojournal/app/storage/RecordingStore.kt create mode 100644 app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt create mode 100644 app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt create mode 100644 app/src/main/java/com/audiojournal/app/ui/TimeFormat.kt create mode 100644 app/src/main/java/com/audiojournal/app/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/audiojournal/app/upload/CloudUploader.kt create mode 100644 app/src/main/java/com/audiojournal/app/upload/S3CloudUploader.kt create mode 100644 app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt create mode 100644 app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_notification_mic.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/audiojournal/app/recording/RecordingEngineTest.kt create mode 100644 app/src/test/java/com/audiojournal/app/storage/RecordingStoreTest.kt create mode 100644 app/src/test/java/com/audiojournal/app/ui/TimeFormatTest.kt create mode 100644 app/src/test/java/com/audiojournal/app/upload/S3ConfigTest.kt create mode 100644 app/src/test/java/com/audiojournal/app/upload/UploadDecisionTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 local.properties.sample create mode 100644 settings.gradle.kts diff --git a/README.md b/README.md index 459f95c..bf9f68c 100644 --- a/README.md +++ b/README.md @@ -1 +1,171 @@ -# audio-journal \ No newline at end of file +# Audio Journal + +A deliberately simple Android voice recorder: one big record button, pause / +resume, stop — and every finished recording is saved on the device and +automatically uploaded to cloud storage (AWS S3) in the background. + +## What it does + +- **Record** — tap the big red microphone button. Recording runs in a + foreground service, so it keeps going with the screen off or while you use + other apps (you'll see an ongoing notification). +- **Pause / resume** — while recording, a pause button appears next to the + stop button. +- **Stop** — finalizes the recording as an AAC `.m4a` file (small files, + playable everywhere) named like `recording_2026-07-09_14-30-00.m4a`. +- **Automatic cloud upload** — when you stop, the file is queued with + WorkManager and uploaded to `s3:///recordings/`. If you're + offline, the upload waits for connectivity and retries with exponential + backoff — the recording is never lost. +- **Works without cloud setup** — with no S3 credentials configured, the app + simply keeps recordings on the device and says so in the UI. + +Recordings live in the app's private storage: +`Android/data/com.audiojournal.app/files/recordings/` (also browsable via USB). + +## Getting started (no Android experience needed) + +### 1. Install Android Studio + +Download it from and run the installer +with default settings. It bundles everything you need (Android SDK, emulator, +JDK). + +### 2. Open the project + +In Android Studio: **File → Open…** and select this repository's folder. +The first "Gradle sync" downloads dependencies and takes a few minutes. + +### 3. Run it in the emulator + +1. **Tools → Device Manager → Create virtual device** — pick any phone + (e.g. Pixel 8), accept the suggested system image, finish. +2. Press the green **Run ▶** button in the toolbar. +3. The emulator boots and the app launches. Grant the microphone permission + when asked. The emulator can use your computer's microphone: in the + emulator's side panel choose **⋯ (Extended controls) → Microphone → Virtual + microphone uses host audio input**. + +### 4. Install it on your phone + +**Option A – straight from Android Studio (easiest):** + +1. On the phone, enable developer mode: **Settings → About phone → tap + "Build number" seven times**, then **Settings → System → Developer options + → enable "USB debugging"**. +2. Connect the phone via USB and accept the "Allow USB debugging?" prompt. +3. Your phone now appears in Android Studio's device dropdown — press + **Run ▶**. + +**Option B – install the APK file:** + +1. Build it: **Build → Build App Bundle(s) / APK(s) → Build APK(s)** in + Android Studio (or `./gradlew assembleDebug` on the command line). The file + ends up at `app/build/outputs/apk/debug/app-debug.apk`. + Alternatively, download the `audio-journal-debug-apk` artifact that the + GitHub Actions CI build attaches to every push — no local build needed. +2. Copy the APK to your phone (USB, cloud drive, email to yourself…), open it + there, and allow "install from unknown sources" when prompted. + +## Configuring cloud upload (AWS S3) + +The app uploads to an S3 bucket using credentials you provide at build time. + +### One-time AWS setup + +1. In the [S3 console](https://s3.console.aws.amazon.com/), create a bucket + (e.g. `my-audio-journal`), keeping "Block all public access" **on**. +2. In the [IAM console](https://console.aws.amazon.com/iam/), create a user + (e.g. `audio-journal-app`) **without** console access, and attach only this + inline policy (replace the bucket name): + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::my-audio-journal/recordings/*" + } + ] + } + ``` + +3. Create an **access key** for that user (IAM → the user → Security + credentials → Create access key → "Application running outside AWS") and + note the key ID and secret. + +### Tell the app about it + +Copy the `s3.*` lines from [`local.properties.sample`](local.properties.sample) +into `local.properties` in the project root (Android Studio creates that file +automatically; it is gitignored so the secrets stay out of git): + +```properties +s3.bucket=my-audio-journal +s3.region=eu-central-1 +s3.accessKeyId=AKIA... +s3.secretAccessKey=... +``` + +Rebuild and reinstall the app. After stopping a recording the UI shows +"Queued for cloud upload" and the file appears in your bucket under +`recordings/` as soon as the device is online. + +> **Security note:** the credentials are baked into your locally built APK. +> That is fine for a personal app you build and install yourself — but don't +> distribute that APK, and keep the IAM policy as narrow as shown above +> (upload-only, one folder, one bucket). A future version could switch to a +> Google Drive sign-in or a presigned-URL backend to avoid on-device secrets. + +## Development + +### Architecture + +``` +app/src/main/java/com/audiojournal/app/ +├── AudioJournalApp.kt Application + hand-rolled DI container +├── MainActivity.kt Single activity hosting the Compose UI +├── recording/ +│ ├── RecordingEngine.kt Pure-Kotlin state machine (idle → recording ⇄ paused) +│ ├── AudioRecorder.kt Interface + MediaRecorder implementation +│ ├── RecordingService.kt Foreground service that keeps the mic alive +│ └── RecorderState.kt State model +├── storage/ +│ └── RecordingStore.kt Output directory + timestamped file names +├── upload/ +│ ├── CloudUploader.kt Destination-agnostic upload interface +│ ├── S3CloudUploader.kt AWS S3 implementation +│ ├── UploadWorker.kt WorkManager worker (retry with backoff) +│ └── UploadScheduler.kt Enqueues uploads with a network constraint +└── ui/ + ├── RecorderScreen.kt The one screen (Jetpack Compose, Material 3) + ├── RecorderViewModel.kt Bridges UI ↔ engine/service + └── TimeFormat.kt Elapsed-time formatting +``` + +Design choices: + +- The recording state machine (`RecordingEngine`) contains no Android + framework types, so all of its behavior is covered by fast JVM unit tests. +- Recording runs in a **foreground service** with the `microphone` service + type — required on modern Android for the mic to stay usable in the + background. +- Uploads go through **WorkManager**, which persists queued uploads across + app restarts and reboots and only runs them when the network is up. +- `CloudUploader` is an interface: adding Google Drive or another backend + later means one new class and one changed line in `AppContainer`. + +### Tests + +```bash +./gradlew test # JVM unit tests (state machine, naming, retry logic) +./gradlew connectedDebugAndroidTest # UI smoke test (needs an emulator/device) +./gradlew lint # Android lint +``` + +### Continuous integration + +Every push runs [GitHub Actions](.github/workflows/android.yml): assemble, +lint, unit tests — and publishes the debug APK as a downloadable artifact. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..058e703 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,109 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +// Cloud upload credentials are read from local.properties (never committed) or +// from environment variables, and baked into BuildConfig. See README.md. +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) file.inputStream().use { load(it) } +} + +fun secret(propertyName: String): String { + val envName = propertyName.replace('.', '_').uppercase() + return localProperties.getProperty(propertyName) + ?: System.getenv(envName) + ?: "" +} + +android { + namespace = "com.audiojournal.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.audiojournal.app" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "S3_BUCKET", "\"${secret("s3.bucket")}\"") + buildConfigField("String", "S3_REGION", "\"${secret("s3.region")}\"") + buildConfigField("String", "S3_ACCESS_KEY_ID", "\"${secret("s3.accessKeyId")}\"") + buildConfigField("String", "S3_SECRET_ACCESS_KEY", "\"${secret("s3.secretAccessKey")}\"") + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "/META-INF/INDEX.LIST" + excludes += "/META-INF/DEPENDENCIES" + excludes += "/META-INF/io.netty.versions.properties" + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } + + lint { + warningsAsErrors = false + abortOnError = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.aws.s3) + + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..357246b --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,6 @@ +# Keep AWS SDK for Kotlin service clients working after shrinking. +-keep class aws.sdk.kotlin.** { *; } +-keep class aws.smithy.kotlin.** { *; } +-dontwarn aws.sdk.kotlin.** +-dontwarn aws.smithy.kotlin.** +-dontwarn org.slf4j.** diff --git a/app/src/androidTest/java/com/audiojournal/app/RecorderScreenTest.kt b/app/src/androidTest/java/com/audiojournal/app/RecorderScreenTest.kt new file mode 100644 index 0000000..3d600f9 --- /dev/null +++ b/app/src/androidTest/java/com/audiojournal/app/RecorderScreenTest.kt @@ -0,0 +1,28 @@ +package com.audiojournal.app + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * UI smoke test. Runs on an emulator or device: + * ./gradlew connectedDebugAndroidTest + */ +@RunWith(AndroidJUnit4::class) +class RecorderScreenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun recordButtonIsShownWhenIdle() { + composeRule.onNodeWithTag("record_button").assertIsDisplayed() + composeRule.onNodeWithText("Tap to record").assertIsDisplayed() + composeRule.onNodeWithText("00:00").assertIsDisplayed() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dc70feb --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt b/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt new file mode 100644 index 0000000..418e2bf --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt @@ -0,0 +1,50 @@ +package com.audiojournal.app + +import android.app.Application +import android.content.Context +import android.os.SystemClock +import com.audiojournal.app.recording.MediaRecorderAudioRecorder +import com.audiojournal.app.recording.RecordingEngine +import com.audiojournal.app.storage.RecordingStore +import com.audiojournal.app.upload.CloudUploader +import com.audiojournal.app.upload.S3CloudUploader +import com.audiojournal.app.upload.S3Config +import java.io.File + +/** + * Hand-rolled dependency container (no DI framework needed at this size). + * Holds the single [RecordingEngine] shared by the UI and the foreground + * service, and the configured [CloudUploader]. + */ +class AppContainer(context: Context) { + + val recordingStore = RecordingStore( + recordingsDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "recordings"), + ) + + val recordingEngine = RecordingEngine( + recorderFactory = { MediaRecorderAudioRecorder(context) }, + store = recordingStore, + timeSource = { SystemClock.elapsedRealtime() }, + ) + + val cloudUploader: CloudUploader = S3CloudUploader( + S3Config( + bucket = BuildConfig.S3_BUCKET, + region = BuildConfig.S3_REGION, + accessKeyId = BuildConfig.S3_ACCESS_KEY_ID, + secretAccessKey = BuildConfig.S3_SECRET_ACCESS_KEY, + ), + ) +} + +class AudioJournalApp : Application() { + + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + container = AppContainer(applicationContext) + } +} diff --git a/app/src/main/java/com/audiojournal/app/MainActivity.kt b/app/src/main/java/com/audiojournal/app/MainActivity.kt new file mode 100644 index 0000000..4032523 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/MainActivity.kt @@ -0,0 +1,21 @@ +package com.audiojournal.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.audiojournal.app.ui.RecorderScreen +import com.audiojournal.app.ui.theme.AudioJournalTheme + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AudioJournalTheme { + RecorderScreen() + } + } + } +} diff --git a/app/src/main/java/com/audiojournal/app/recording/AudioRecorder.kt b/app/src/main/java/com/audiojournal/app/recording/AudioRecorder.kt new file mode 100644 index 0000000..27eda64 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/recording/AudioRecorder.kt @@ -0,0 +1,74 @@ +package com.audiojournal.app.recording + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import java.io.File + +/** + * Thin abstraction over the platform recorder so the recording state machine + * ([RecordingEngine]) can be unit tested with a fake implementation. + * + * Implementations are single-use: one instance records one file. + */ +interface AudioRecorder { + /** Starts recording into [output]. Throws if the recorder cannot start. */ + fun start(output: File) + + fun pause() + + fun resume() + + /** Stops recording and finalizes the output file. Throws if nothing valid was recorded. */ + fun stop() +} + +/** + * Production implementation backed by [MediaRecorder], producing an + * AAC-encoded .m4a file. + */ +class MediaRecorderAudioRecorder(private val context: Context) : AudioRecorder { + + private var mediaRecorder: MediaRecorder? = null + + override fun start(output: File) { + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setAudioSamplingRate(44_100) + recorder.setAudioEncodingBitRate(128_000) + recorder.setOutputFile(output.absolutePath) + try { + recorder.prepare() + recorder.start() + } catch (e: Exception) { + recorder.release() + throw e + } + mediaRecorder = recorder + } + + override fun pause() { + mediaRecorder?.pause() + } + + override fun resume() { + mediaRecorder?.resume() + } + + override fun stop() { + val recorder = mediaRecorder ?: return + mediaRecorder = null + try { + recorder.stop() + } finally { + recorder.release() + } + } +} diff --git a/app/src/main/java/com/audiojournal/app/recording/RecorderState.kt b/app/src/main/java/com/audiojournal/app/recording/RecorderState.kt new file mode 100644 index 0000000..1257a10 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/recording/RecorderState.kt @@ -0,0 +1,17 @@ +package com.audiojournal.app.recording + +import java.io.File + +enum class RecorderPhase { IDLE, RECORDING, PAUSED } + +data class SavedRecording( + val file: File, + val durationMillis: Long, +) + +data class RecorderState( + val phase: RecorderPhase = RecorderPhase.IDLE, + val activeFile: File? = null, + val lastSaved: SavedRecording? = null, + val errorMessage: String? = null, +) diff --git a/app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt b/app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt new file mode 100644 index 0000000..c36c8fb --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt @@ -0,0 +1,136 @@ +package com.audiojournal.app.recording + +import com.audiojournal.app.storage.RecordingStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Monotonic time source, injectable for tests. + * Production uses [android.os.SystemClock.elapsedRealtime]. + */ +fun interface TimeSource { + fun elapsedRealtimeMillis(): Long +} + +/** + * The recording state machine: idle -> recording <-> paused -> idle. + * + * Pure Kotlin (no Android framework types) so it is fully unit testable. + * Invalid transitions (e.g. pause while idle) are ignored rather than throwing, + * because UI events and service intents can race. + * + * A single instance is shared between the UI (which observes [state]) and the + * foreground [RecordingService] (which drives the transitions). + */ +class RecordingEngine( + private val recorderFactory: () -> AudioRecorder, + private val store: RecordingStore, + private val timeSource: TimeSource, +) { + + private val _state = MutableStateFlow(RecorderState()) + val state: StateFlow = _state.asStateFlow() + + private var recorder: AudioRecorder? = null + private var recordedBeforePauseMillis = 0L + private var recordingSinceMillis: Long? = null + + val isActive: Boolean + get() = _state.value.phase != RecorderPhase.IDLE + + /** Wall time recorded so far, excluding paused stretches. */ + fun elapsedMillis(): Long { + val running = recordingSinceMillis?.let { timeSource.elapsedRealtimeMillis() - it } ?: 0L + return recordedBeforePauseMillis + running + } + + @Synchronized + fun start(): Boolean { + if (_state.value.phase != RecorderPhase.IDLE) return false + val output = store.newRecordingFile() + return try { + val newRecorder = recorderFactory() + newRecorder.start(output) + recorder = newRecorder + recordedBeforePauseMillis = 0L + recordingSinceMillis = timeSource.elapsedRealtimeMillis() + _state.update { + RecorderState( + phase = RecorderPhase.RECORDING, + activeFile = output, + lastSaved = it.lastSaved, + ) + } + true + } catch (e: Exception) { + output.delete() + recorder = null + _state.update { + it.copy( + phase = RecorderPhase.IDLE, + activeFile = null, + errorMessage = "Could not start recording: ${e.message}", + ) + } + false + } + } + + @Synchronized + fun pause() { + if (_state.value.phase != RecorderPhase.RECORDING) return + recorder?.pause() + recordedBeforePauseMillis = elapsedMillis() + recordingSinceMillis = null + _state.update { it.copy(phase = RecorderPhase.PAUSED) } + } + + @Synchronized + fun resume() { + if (_state.value.phase != RecorderPhase.PAUSED) return + recorder?.resume() + recordingSinceMillis = timeSource.elapsedRealtimeMillis() + _state.update { it.copy(phase = RecorderPhase.RECORDING) } + } + + /** + * Stops and finalizes the current recording. + * Returns the saved recording, or null if there was nothing to stop or + * finalizing failed (in which case the broken file is deleted). + */ + @Synchronized + fun stop(): SavedRecording? { + val current = _state.value + if (current.phase == RecorderPhase.IDLE) return null + val duration = elapsedMillis() + val file = current.activeFile + val activeRecorder = recorder + recorder = null + recordedBeforePauseMillis = 0L + recordingSinceMillis = null + return try { + activeRecorder?.stop() + val saved = file?.let { SavedRecording(it, duration) } + _state.update { + RecorderState(phase = RecorderPhase.IDLE, lastSaved = saved ?: it.lastSaved) + } + saved + } catch (e: Exception) { + file?.delete() + _state.update { + RecorderState( + phase = RecorderPhase.IDLE, + lastSaved = it.lastSaved, + errorMessage = "Recording could not be saved: ${e.message}", + ) + } + null + } + } + + fun clearError() { + _state.update { it.copy(errorMessage = null) } + } +} diff --git a/app/src/main/java/com/audiojournal/app/recording/RecordingService.kt b/app/src/main/java/com/audiojournal/app/recording/RecordingService.kt new file mode 100644 index 0000000..298d2c4 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/recording/RecordingService.kt @@ -0,0 +1,132 @@ +package com.audiojournal.app.recording + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.audiojournal.app.AudioJournalApp +import com.audiojournal.app.MainActivity +import com.audiojournal.app.R +import com.audiojournal.app.upload.UploadScheduler + +/** + * Foreground service that keeps the microphone alive while recording, even + * when the screen is off or the app is in the background. The actual state + * lives in the shared [RecordingEngine]; this service drives its transitions + * in response to intents sent from the UI. + */ +class RecordingService : Service() { + + private val engine: RecordingEngine + get() = (application as AudioJournalApp).container.recordingEngine + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + startAsForeground(getString(R.string.notification_recording)) + if (!engine.start()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + ACTION_PAUSE -> { + engine.pause() + updateNotification(getString(R.string.notification_paused)) + } + + ACTION_RESUME -> { + engine.resume() + updateNotification(getString(R.string.notification_recording)) + } + + ACTION_STOP -> { + val saved = engine.stop() + if (saved != null) { + UploadScheduler.enqueue(applicationContext, saved.file) + } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_NOT_STICKY + } + + private fun startAsForeground(text: String) { + val notification = buildNotification(text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun updateNotification(text: String) { + val manager = getSystemService(NotificationManager::class.java) + manager.notify(NOTIFICATION_ID, buildNotification(text)) + } + + private fun buildNotification(text: String): Notification { + val openAppIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_mic) + .setContentTitle(getString(R.string.app_name)) + .setContentText(text) + .setContentIntent(openAppIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + companion object { + private const val CHANNEL_ID = "recording" + private const val NOTIFICATION_ID = 1 + + const val ACTION_START = "com.audiojournal.app.action.START" + const val ACTION_PAUSE = "com.audiojournal.app.action.PAUSE" + const val ACTION_RESUME = "com.audiojournal.app.action.RESUME" + const val ACTION_STOP = "com.audiojournal.app.action.STOP" + + fun sendAction(context: Context, action: String) { + val intent = Intent(context, RecordingService::class.java).setAction(action) + if (action == ACTION_START) { + ContextCompat.startForegroundService(context, intent) + } else { + context.startService(intent) + } + } + } +} diff --git a/app/src/main/java/com/audiojournal/app/storage/RecordingStore.kt b/app/src/main/java/com/audiojournal/app/storage/RecordingStore.kt new file mode 100644 index 0000000..2c094cb --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/storage/RecordingStore.kt @@ -0,0 +1,35 @@ +package com.audiojournal.app.storage + +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Owns the directory recordings are written to and generates timestamped, + * collision-free file names like `recording_2026-07-09_14-30-00.m4a`. + */ +class RecordingStore( + private val recordingsDir: File, + private val wallClockMillis: () -> Long = System::currentTimeMillis, +) { + + fun newRecordingFile(): File { + recordingsDir.mkdirs() + val timestamp = FILE_NAME_FORMAT.format(Date(wallClockMillis())) + var candidate = File(recordingsDir, "recording_$timestamp$EXTENSION") + var suffix = 2 + while (candidate.exists()) { + candidate = File(recordingsDir, "recording_${timestamp}_$suffix$EXTENSION") + suffix++ + } + return candidate + } + + companion object { + const val EXTENSION = ".m4a" + + private val FILE_NAME_FORMAT: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US) + } +} diff --git a/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt b/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt new file mode 100644 index 0000000..ada7b5c --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt @@ -0,0 +1,244 @@ +package com.audiojournal.app.ui + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.audiojournal.app.R +import com.audiojournal.app.recording.RecorderPhase + +@Composable +fun RecorderScreen(viewModel: RecorderViewModel = viewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val elapsedMillis by viewModel.elapsedMillis.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { results -> + if (results[Manifest.permission.RECORD_AUDIO] == true) { + viewModel.startRecording() + } + } + + fun startWithPermissionCheck() { + val hasMic = ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + if (hasMic) { + viewModel.startRecording() + } else { + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS) + } else { + arrayOf(Manifest.permission.RECORD_AUDIO) + } + permissionLauncher.launch(permissions) + } + } + + state.errorMessage?.let { message -> + LaunchedEffect(message) { + snackbarHostState.showSnackbar(message) + viewModel.clearError() + } + } + + Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = when (state.phase) { + RecorderPhase.IDLE -> stringResource(R.string.status_idle) + RecorderPhase.RECORDING -> stringResource(R.string.status_recording) + RecorderPhase.PAUSED -> stringResource(R.string.status_paused) + }, + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = formatElapsed(if (state.phase == RecorderPhase.IDLE) 0L else elapsedMillis), + style = MaterialTheme.typography.displayLarge, + fontFamily = FontFamily.Monospace, + ) + + Spacer(Modifier.height(48.dp)) + + when (state.phase) { + RecorderPhase.IDLE -> RecordButton(onClick = ::startWithPermissionCheck) + + RecorderPhase.RECORDING, RecorderPhase.PAUSED -> { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PauseResumeButton( + isPaused = state.phase == RecorderPhase.PAUSED, + onPause = viewModel::pauseRecording, + onResume = viewModel::resumeRecording, + ) + StopButton(onClick = viewModel::stopRecording) + } + } + } + + Spacer(Modifier.height(48.dp)) + + state.lastSaved?.let { saved -> + if (state.phase == RecorderPhase.IDLE) { + Text( + text = stringResource( + R.string.last_saved, + saved.file.name, + formatElapsed(saved.durationMillis), + ), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = if (viewModel.isCloudConfigured) { + stringResource(R.string.upload_queued) + } else { + stringResource(R.string.upload_not_configured) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun RecordButton(onClick: () -> Unit) { + FilledIconButton( + onClick = onClick, + modifier = Modifier + .size(160.dp) + .testTag("record_button"), + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = stringResource(R.string.record), + modifier = Modifier.size(72.dp), + ) + } +} + +@Composable +private fun PauseResumeButton(isPaused: Boolean, onPause: () -> Unit, onResume: () -> Unit) { + FilledIconButton( + onClick = if (isPaused) onResume else onPause, + modifier = Modifier + .size(88.dp) + .testTag("pause_resume_button"), + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Icon( + imageVector = if (isPaused) Icons.Filled.PlayArrow else Icons.Filled.Pause, + contentDescription = stringResource(if (isPaused) R.string.resume else R.string.pause), + modifier = Modifier.size(40.dp), + ) + } +} + +@Composable +private fun StopButton(onClick: () -> Unit) { + val pulse = rememberInfiniteTransition(label = "pulse") + val scale by pulse.animateFloat( + initialValue = 1f, + targetValue = 1.06f, + animationSpec = infiniteRepeatable(tween(700), RepeatMode.Reverse), + label = "pulseScale", + ) + Box( + modifier = Modifier + .size(120.dp) + .scale(scale) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center, + ) { + FilledIconButton( + onClick = onClick, + modifier = Modifier + .size(120.dp) + .testTag("stop_button"), + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.stop), + modifier = Modifier.size(56.dp), + ) + } + } +} diff --git a/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt b/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt new file mode 100644 index 0000000..48175af --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt @@ -0,0 +1,61 @@ +package com.audiojournal.app.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.audiojournal.app.AudioJournalApp +import com.audiojournal.app.recording.RecorderPhase +import com.audiojournal.app.recording.RecorderState +import com.audiojournal.app.recording.RecordingService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn + +@OptIn(ExperimentalCoroutinesApi::class) +class RecorderViewModel(application: Application) : AndroidViewModel(application) { + + private val container = (application as AudioJournalApp).container + private val engine = container.recordingEngine + + val state: StateFlow = engine.state + + val isCloudConfigured: Boolean = container.cloudUploader.isConfigured + + /** Ticks while recording so the timer in the UI stays current. */ + val elapsedMillis: StateFlow = engine.state + .flatMapLatest { current -> + when (current.phase) { + RecorderPhase.RECORDING -> flow { + while (true) { + emit(engine.elapsedMillis()) + delay(TIMER_TICK_MILLIS) + } + } + + else -> flow { emit(engine.elapsedMillis()) } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L) + + fun startRecording() = sendAction(RecordingService.ACTION_START) + + fun pauseRecording() = sendAction(RecordingService.ACTION_PAUSE) + + fun resumeRecording() = sendAction(RecordingService.ACTION_RESUME) + + fun stopRecording() = sendAction(RecordingService.ACTION_STOP) + + fun clearError() = engine.clearError() + + private fun sendAction(action: String) { + RecordingService.sendAction(getApplication(), action) + } + + private companion object { + const val TIMER_TICK_MILLIS = 100L + } +} diff --git a/app/src/main/java/com/audiojournal/app/ui/TimeFormat.kt b/app/src/main/java/com/audiojournal/app/ui/TimeFormat.kt new file mode 100644 index 0000000..4de3b7f --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/ui/TimeFormat.kt @@ -0,0 +1,14 @@ +package com.audiojournal.app.ui + +/** Formats a duration as `MM:SS`, or `H:MM:SS` once it reaches an hour. */ +fun formatElapsed(millis: Long): String { + val totalSeconds = millis / 1000 + val seconds = totalSeconds % 60 + val minutes = (totalSeconds / 60) % 60 + val hours = totalSeconds / 3600 + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/com/audiojournal/app/ui/theme/Theme.kt b/app/src/main/java/com/audiojournal/app/ui/theme/Theme.kt new file mode 100644 index 0000000..3a86f38 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/ui/theme/Theme.kt @@ -0,0 +1,29 @@ +package com.audiojournal.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val RecordRed = Color(0xFFD32F2F) +private val RecordRedDark = Color(0xFFEF5350) + +private val LightColors = lightColorScheme( + primary = RecordRed, + onPrimary = Color.White, +) + +private val DarkColors = darkColorScheme( + primary = RecordRedDark, + onPrimary = Color.Black, +) + +@Composable +fun AudioJournalTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors, + content = content, + ) +} diff --git a/app/src/main/java/com/audiojournal/app/upload/CloudUploader.kt b/app/src/main/java/com/audiojournal/app/upload/CloudUploader.kt new file mode 100644 index 0000000..6af730b --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/CloudUploader.kt @@ -0,0 +1,25 @@ +package com.audiojournal.app.upload + +import java.io.File + +sealed interface UploadResult { + /** The file was uploaded to cloud storage. */ + data object Success : UploadResult + + /** No cloud storage credentials are configured; the file stays local only. */ + data object NotConfigured : UploadResult + + /** The upload failed (e.g. no network, credentials rejected) and may be retried. */ + data class Error(val cause: Throwable) : UploadResult +} + +/** + * Destination-agnostic upload abstraction. The app ships with an S3 + * implementation ([S3CloudUploader]); other backends (Google Drive, ...) can + * be added by implementing this interface and swapping it in AppContainer. + */ +interface CloudUploader { + val isConfigured: Boolean + + suspend fun upload(file: File): UploadResult +} diff --git a/app/src/main/java/com/audiojournal/app/upload/S3CloudUploader.kt b/app/src/main/java/com/audiojournal/app/upload/S3CloudUploader.kt new file mode 100644 index 0000000..5c4953a --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/S3CloudUploader.kt @@ -0,0 +1,60 @@ +package com.audiojournal.app.upload + +import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider +import aws.sdk.kotlin.services.s3.S3Client +import aws.sdk.kotlin.services.s3.model.PutObjectRequest +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.content.ByteStream +import aws.smithy.kotlin.runtime.content.fromFile +import java.io.File + +/** + * Immutable S3 configuration, populated from BuildConfig (which in turn reads + * local.properties / environment variables at build time). + */ +data class S3Config( + val bucket: String, + val region: String, + val accessKeyId: String, + val secretAccessKey: String, +) { + val isConfigured: Boolean + get() = bucket.isNotBlank() && + region.isNotBlank() && + accessKeyId.isNotBlank() && + secretAccessKey.isNotBlank() +} + +/** Uploads recordings to `s3:///recordings/`. */ +class S3CloudUploader(private val config: S3Config) : CloudUploader { + + override val isConfigured: Boolean + get() = config.isConfigured + + override suspend fun upload(file: File): UploadResult { + if (!config.isConfigured) return UploadResult.NotConfigured + return try { + S3Client { + region = config.region + credentialsProvider = StaticCredentialsProvider( + Credentials( + accessKeyId = config.accessKeyId, + secretAccessKey = config.secretAccessKey, + ), + ) + }.use { s3 -> + s3.putObject( + PutObjectRequest { + bucket = config.bucket + key = "recordings/${file.name}" + body = ByteStream.fromFile(file) + contentType = "audio/mp4" + }, + ) + } + UploadResult.Success + } catch (e: Exception) { + UploadResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt b/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt new file mode 100644 index 0000000..dcc3716 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt @@ -0,0 +1,34 @@ +package com.audiojournal.app.upload + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import java.io.File +import java.util.concurrent.TimeUnit + +object UploadScheduler { + + /** Queues [file] for upload once the device has network connectivity. */ + fun enqueue(context: Context, file: File) { + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(UploadWorker.KEY_FILE_PATH to file.absolutePath)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + "upload-${file.name}", + ExistingWorkPolicy.KEEP, + request, + ) + } +} diff --git a/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt b/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt new file mode 100644 index 0000000..f861734 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt @@ -0,0 +1,72 @@ +package com.audiojournal.app.upload + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.audiojournal.app.AudioJournalApp +import java.io.File + +/** + * Decides how a finished upload attempt maps onto a WorkManager result. + * Extracted as a pure function so retry behavior is unit testable. + */ +enum class UploadDecision { SUCCESS, RETRY, GIVE_UP } + +fun decideUploadOutcome(result: UploadResult, runAttemptCount: Int, maxAttempts: Int): UploadDecision = + when (result) { + is UploadResult.Success -> UploadDecision.SUCCESS + // Nothing to do without credentials; the file is kept locally. + is UploadResult.NotConfigured -> UploadDecision.SUCCESS + is UploadResult.Error -> + if (runAttemptCount + 1 < maxAttempts) UploadDecision.RETRY else UploadDecision.GIVE_UP + } + +/** + * Background upload of one recording. WorkManager persists the request, waits + * for network connectivity, and retries with exponential backoff, so a + * recording finished offline is uploaded when the device is back online. + */ +class UploadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val path = inputData.getString(KEY_FILE_PATH) ?: return Result.failure() + val file = File(path) + if (!file.exists()) { + Log.w(TAG, "Recording $path no longer exists; skipping upload") + return Result.success() + } + + val uploader = (applicationContext as AudioJournalApp).container.cloudUploader + val result = uploader.upload(file) + return when (decideUploadOutcome(result, runAttemptCount, MAX_ATTEMPTS)) { + UploadDecision.SUCCESS -> { + if (result is UploadResult.Success) { + Log.i(TAG, "Uploaded ${file.name}") + } else { + Log.i(TAG, "Cloud storage not configured; ${file.name} kept locally") + } + Result.success() + } + + UploadDecision.RETRY -> { + Log.w(TAG, "Upload of ${file.name} failed, will retry", (result as UploadResult.Error).cause) + Result.retry() + } + + UploadDecision.GIVE_UP -> { + Log.e(TAG, "Upload of ${file.name} failed permanently", (result as UploadResult.Error).cause) + Result.failure() + } + } + } + + companion object { + const val KEY_FILE_PATH = "file_path" + const val MAX_ATTEMPTS = 8 + private const val TAG = "UploadWorker" + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c2db1a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_notification_mic.xml b/app/src/main/res/drawable/ic_notification_mic.xml new file mode 100644 index 0000000..e15ecf9 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_mic.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c08351e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #B71C1C + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3cca6ca --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + Audio Journal + + Tap to record + Recording… + Paused + + Record + Pause + Resume + Stop + + Saved %1$s (%2$s) + Queued for cloud upload + Saved on device only — cloud upload not configured + + Recording + Recording in progress + Recording paused + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ba83a1c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +