diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..a3a41dc --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,67 @@ +name: Android CI + +on: + push: + pull_request: + +jobs: + build: + name: Build, lint & unit tests + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + # Sign debug builds with the repo owner's debug keystore (stored as a + # base64 secret) so CI APKs carry the same certificate SHA-1 that is + # registered for Google Drive OAuth. Without the secret, CI falls back + # to a runner-generated keystore and the APK still builds fine — but + # Google sign-in won't work in that artifact. + - name: Install debug keystore from secret + env: + KEYSTORE_B64: ${{ secrets.DEBUG_KEYSTORE_B64 }} + run: | + if [ -n "$KEYSTORE_B64" ]; then + echo "$KEYSTORE_B64" | base64 -d > ci-debug.keystore + echo "Installed debug keystore from secret. Fingerprint:" + keytool -list -keystore ci-debug.keystore -storepass android + else + echo "DEBUG_KEYSTORE_B64 secret not set; artifact APKs will use a runner-generated keystore (Google sign-in won't work in them)." + fi + + - name: Build, lint and run unit tests + env: + # Comma-separated Drive upload destinations ("Label=folderId" or a + # bare folderId) baked into the APK; the first entry is the default. + # Set as a repository variable (Settings -> Secrets and variables -> + # Actions -> Variables). + DRIVE_FOLDERS: ${{ vars.DRIVE_FOLDERS }} + run: ./gradlew assembleDebug testDebugUnitTest lintDebug --no-daemon --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: audio-journal-debug-apk + path: app/build/outputs/apk/debug/app-debug.apk + # Allow re-runs of the same workflow run to replace the artifact + # (v4 artifacts are otherwise immutable, so a re-run after e.g. a + # secret change would silently keep the stale APK). + overwrite: true + + - name: Upload test and lint reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: reports + path: | + app/build/reports + app/build/test-results diff --git a/README.md b/README.md index 459f95c..836d2b6 100644 --- a/README.md +++ b/README.md @@ -1 +1,222 @@ -# 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 (Google Drive by default, AWS S3 +optionally) 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 your **Google Drive** (or an AWS S3 bucket if + you switch backends). If you're offline, the upload waits for connectivity + and retries with exponential backoff — the recording is never lost. +- **Folder per note type** — configure one or more Drive folders by **folder + id** (unambiguous, unlike names); with several configured, a picker in the + app chooses where the next recording goes. The first entry is the default. +- **Works without cloud setup** — until you connect Google Drive (or + configure S3), 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 + +### Google Drive (the default) + +No secrets go into the app — you sign in with the Google account on your +phone, and Google Play services brokers scoped, revocable OAuth tokens. What +you do need is a one-time (~15 min) registration in Google Cloud Console so +Google knows your app: + +1. Go to , create a project (e.g. + `audio-journal`). +2. **APIs & Services → Library** → search "Google Drive API" → **Enable**. +3. **APIs & Services → OAuth consent screen** → user type **External** → fill + in the app name and your email. Under **Test users**, add your own Google + account, and leave the publishing status on **Testing**. +4. **APIs & Services → Credentials → Create credentials → OAuth client ID** → + application type **Android**: + - Package name: `com.audiojournal.app` + - SHA-1 fingerprint: run `./gradlew signingReport` in the project (or in + Android Studio's Gradle panel) and copy the SHA-1 of the `debug` variant. + + No client secret is downloaded or embedded — the registration is matched + against your app's package name and signing certificate at runtime. +5. (Optional) Choose your upload folders in `local.properties`: + + ```properties + drive.folders=Journal=1AbCdEfGhIjKlMnOpQrStUv,Ideas=1ZyXwVuTsRqPoNmLkJiHgF + ``` + + Each entry is `Label=folderId` (label shown in the app, folder id used + for the upload — ids are unambiguous, unlike folder names). Find a + folder's id by opening it in Drive on the web; it's the last part of the + URL: `drive.google.com/drive/folders/`. The first entry is the + default destination; with more than one, an in-app picker switches + between them per recording. Unset means uploads go to the My Drive root. + + For CI-built APKs (the GitHub Actions artifact), set the same value as a + repository **variable** named `DRIVE_FOLDERS` (repo Settings → Secrets + and variables → Actions → Variables). +6. Rebuild, install, and tap **Connect Google Drive** in the app. Pick your + account and approve the consent screen. Because the app is unverified, + Google shows a warning — click **Advanced → Go to audio-journal (unsafe)**; + it's your own app and your own Cloud project. + +> **Scope note:** the app requests full Drive access so it can upload into +> pre-existing folders you point it at by id. If you'd rather restrict it to +> content the app itself creates, change `DRIVE_SCOPE` in +> `DriveAuthManager.kt` to `https://www.googleapis.com/auth/drive.file`. + +### AWS S3 (optional alternative) + +Set `upload.backend=s3` in `local.properties` to switch. Then: + +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 and put everything into + `local.properties`: + + ```properties + upload.backend=s3 + s3.bucket=my-audio-journal + s3.region=eu-central-1 + s3.accessKeyId=AKIA... + s3.secretAccessKey=... + ``` + +Recordings land under `recordings//` in the bucket, mirroring +the folder picker. + +> **Security note:** unlike Drive, the S3 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 prefix, one bucket). + +## 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 +│ ├── FolderConfig.kt Parses the configured folder list +│ ├── UploadWorker.kt WorkManager worker (retry with backoff) +│ ├── UploadScheduler.kt Enqueues uploads with a network constraint +│ └── drive/ +│ ├── DriveAuthManager.kt OAuth via Play services (no app secrets) +│ ├── DriveApi.kt Minimal Drive v3 REST client +│ ├── DriveJson.kt Pure request/response helpers (unit tested) +│ └── DriveCloudUploader.kt Google Drive implementation (default) +└── 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 with Google Drive (default) and S3 + implementations, selected by the `upload.backend` build property; adding + another backend 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..c94ded3 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,132 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + 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 = 37 + + defaultConfig { + applicationId = "com.audiojournal.app" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // "drive" (default) or "s3" + buildConfigField("String", "UPLOAD_BACKEND", "\"${secret("upload.backend").ifBlank { "drive" }}\"") + // Comma-separated Drive upload destinations as "Label=folderId" (or a + // bare folderId); the first entry is the default. Empty = Drive root. + buildConfigField("String", "DRIVE_FOLDERS", "\"${secret("drive.folders")}\"") + 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")}\"") + } + + // CI provides the repo owner's debug keystore (decoded from a secret) at + // this path so artifact APKs carry the certificate SHA-1 registered for + // Google Drive OAuth. Local builds don't have the file and keep using the + // default ~/.android/debug.keystore. + val ciDebugKeystore = rootProject.file("ci-debug.keystore") + if (ciDebugKeystore.exists()) { + signingConfigs { + getByName("debug") { + storeFile = ciDebugKeystore + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_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" + excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } + + lint { + warningsAsErrors = false + abortOnError = true + } +} + +// Kotlin's jvmTarget defaults to android.compileOptions.targetCompatibility +// (17) under AGP's built-in Kotlin, so no explicit kotlin {} block is needed. + +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) + implementation(libs.play.services.auth) + implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.kotlinx.serialization.json) + + 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..507f124 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt @@ -0,0 +1,68 @@ +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.FolderConfig +import com.audiojournal.app.upload.UploadFolder +import com.audiojournal.app.upload.S3CloudUploader +import com.audiojournal.app.upload.S3Config +import com.audiojournal.app.upload.drive.DriveAuthManager +import com.audiojournal.app.upload.drive.DriveCloudUploader +import java.io.File + +enum class UploadBackend { DRIVE, S3 } + +/** + * 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 uploadBackend: UploadBackend = + if (BuildConfig.UPLOAD_BACKEND.equals("s3", ignoreCase = true)) UploadBackend.S3 + else UploadBackend.DRIVE + + /** Folder choices for recordings; the first entry is the default. */ + val uploadFolders: List = FolderConfig.parse(BuildConfig.DRIVE_FOLDERS) + + val driveAuthManager = DriveAuthManager(context) + + val cloudUploader: CloudUploader = when (uploadBackend) { + UploadBackend.DRIVE -> DriveCloudUploader(driveAuthManager) + UploadBackend.S3 -> 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..6baf17c --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/recording/RecordingService.kt @@ -0,0 +1,145 @@ +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.UploadFolder +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) { + val folder = intent.getStringExtra(EXTRA_FOLDER_ID)?.let { id -> + UploadFolder( + label = intent.getStringExtra(EXTRA_FOLDER_LABEL) ?: id, + folderId = id, + ) + } + UploadScheduler.enqueue(applicationContext, saved.file, folder) + } + 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" + const val EXTRA_FOLDER_ID = "com.audiojournal.app.extra.FOLDER_ID" + const val EXTRA_FOLDER_LABEL = "com.audiojournal.app.extra.FOLDER_LABEL" + + fun sendAction(context: Context, action: String, folder: UploadFolder? = null) { + val intent = Intent(context, RecordingService::class.java).setAction(action) + if (folder != null) { + intent.putExtra(EXTRA_FOLDER_ID, folder.folderId) + intent.putExtra(EXTRA_FOLDER_LABEL, folder.label) + } + 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..0277d70 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt @@ -0,0 +1,348 @@ +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.IntentSenderRequest +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.ArrowDropDown +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.Folder +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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.UploadBackend +import com.audiojournal.app.recording.RecorderPhase +import com.audiojournal.app.upload.UploadFolder +import kotlinx.coroutines.launch + +@Composable +fun RecorderScreen(viewModel: RecorderViewModel = viewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val elapsedMillis by viewModel.elapsedMillis.collectAsStateWithLifecycle() + val selectedFolder by viewModel.selectedFolder.collectAsStateWithLifecycle() + val driveConnected by viewModel.driveConnected.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val driveConsentLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ) { + viewModel.refreshDriveConnection() + } + + 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(32.dp)) + + if (viewModel.folders.size > 1 && selectedFolder != null) { + FolderSelector( + folders = viewModel.folders, + selected = selectedFolder!!, + onSelect = viewModel::selectFolder, + ) + Spacer(Modifier.height(16.dp)) + } + + if (viewModel.uploadBackend == UploadBackend.DRIVE && driveConnected == false) { + OutlinedButton( + onClick = { + scope.launch { + val consent = viewModel.driveConsentIntent() + if (consent != null) { + driveConsentLauncher.launch( + IntentSenderRequest.Builder(consent.intentSender).build(), + ) + } else { + viewModel.refreshDriveConnection() + } + } + }, + modifier = Modifier.testTag("drive_connect_button"), + ) { + Text(stringResource(R.string.drive_connect)) + } + Spacer(Modifier.height(16.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)) + val uploadStatus = when { + viewModel.uploadBackend == UploadBackend.DRIVE && driveConnected == false -> + stringResource(R.string.upload_drive_not_connected) + + viewModel.isCloudConfigured -> + stringResource( + R.string.upload_queued_to, + viewModel.backendLabel, + selectedFolder?.label ?: stringResource(R.string.drive_root), + ) + + else -> stringResource(R.string.upload_not_configured) + } + Text( + text = uploadStatus, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun FolderSelector( + folders: List, + selected: UploadFolder, + onSelect: (UploadFolder) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.testTag("folder_selector"), + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text(selected.label) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = stringResource(R.string.folder_label), + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + folders.forEach { folder -> + DropdownMenuItem( + text = { Text(folder.label) }, + leadingIcon = if (folder == selected) { + { Icon(Icons.Filled.CloudDone, contentDescription = null) } + } else { + null + }, + onClick = { + onSelect(folder) + expanded = false + }, + ) + } + } + } +} + +@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..a49b27a --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt @@ -0,0 +1,96 @@ +package com.audiojournal.app.ui + +import android.app.Application +import android.app.PendingIntent +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.audiojournal.app.AudioJournalApp +import com.audiojournal.app.UploadBackend +import com.audiojournal.app.recording.RecorderPhase +import com.audiojournal.app.recording.RecorderState +import com.audiojournal.app.recording.RecordingService +import com.audiojournal.app.upload.UploadFolder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@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 uploadBackend: UploadBackend = container.uploadBackend + val backendLabel: String = container.cloudUploader.backendLabel + val isCloudConfigured: Boolean = container.cloudUploader.isConfigured + + /** Folder choices for recordings; the first entry is the default. */ + val folders: List = container.uploadFolders + + private val _selectedFolder = MutableStateFlow(folders.firstOrNull()) + val selectedFolder: StateFlow = _selectedFolder.asStateFlow() + + /** null while the silent check is still running. */ + private val _driveConnected = MutableStateFlow(null) + val driveConnected: StateFlow = _driveConnected.asStateFlow() + + init { + if (uploadBackend == UploadBackend.DRIVE) refreshDriveConnection() + } + + /** 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, _selectedFolder.value) + + fun selectFolder(folder: UploadFolder) { + _selectedFolder.value = folder + } + + /** Consent UI intent, or null when Drive access is already granted. */ + suspend fun driveConsentIntent(): PendingIntent? = container.driveAuthManager.consentIntent() + + fun refreshDriveConnection() { + viewModelScope.launch { + _driveConnected.value = container.driveAuthManager.getAccessToken() != null + } + } + + fun clearError() = engine.clearError() + + private fun sendAction(action: String, folder: UploadFolder? = null) { + RecordingService.sendAction(getApplication(), action, folder) + } + + 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..fb1eb47 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/CloudUploader.kt @@ -0,0 +1,33 @@ +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, not signed in) and may be retried. */ + data class Error(val cause: Throwable) : UploadResult +} + +/** + * Destination-agnostic upload abstraction. The app ships with Google Drive + * (default) and AWS S3 implementations, selected via the `upload.backend` + * build property; other backends can be added by implementing this interface + * and wiring it in AppContainer. + */ +interface CloudUploader { + val isConfigured: Boolean + + /** Human-readable backend name for UI status messages. */ + val backendLabel: String + + /** + * Uploads [file] into [folder] (a Drive folder id, an S3 key prefix). + * Null means the backend's root location. + */ + suspend fun upload(file: File, folder: UploadFolder?): UploadResult +} diff --git a/app/src/main/java/com/audiojournal/app/upload/FolderConfig.kt b/app/src/main/java/com/audiojournal/app/upload/FolderConfig.kt new file mode 100644 index 0000000..0d3ab68 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/FolderConfig.kt @@ -0,0 +1,35 @@ +package com.audiojournal.app.upload + +/** + * A cloud upload destination. For Google Drive, [folderId] is the Drive + * folder id (the last path segment of the folder's URL in Drive on the web); + * [label] is what the in-app picker shows. For S3, [label] doubles as the + * key prefix. + */ +data class UploadFolder( + val label: String, + val folderId: String, +) + +/** + * Parses the comma-separated `drive.folders` build property. Each entry is + * either `Label=folderId` or a bare `folderId` (the id then doubles as the + * label). The first entry is the default destination. An empty configuration + * means no folder: uploads go to the Drive root. + */ +object FolderConfig { + + fun parse(raw: String): List = + raw.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { entry -> + val separator = entry.indexOf('=') + val (label, id) = if (separator > 0) { + entry.take(separator).trim() to entry.substring(separator + 1).trim() + } else { + entry to entry + } + if (id.isEmpty()) null else UploadFolder(label, id) + } +} 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..3477fb0 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/S3CloudUploader.kt @@ -0,0 +1,63 @@ +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 val backendLabel: String = "Amazon S3" + + override suspend fun upload(file: File, folder: UploadFolder?): UploadResult { + if (!config.isConfigured) return UploadResult.NotConfigured + val prefix = folder?.label?.takeIf { it.isNotBlank() }?.let { "$it/" } ?: "" + 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/$prefix${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..cc26a65 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt @@ -0,0 +1,40 @@ +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 into [folder] once the device is online. */ + fun enqueue(context: Context, file: File, folder: UploadFolder?) { + val request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + UploadWorker.KEY_FILE_PATH to file.absolutePath, + UploadWorker.KEY_FOLDER_ID to folder?.folderId, + UploadWorker.KEY_FOLDER_LABEL to folder?.label, + ), + ) + .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..d27cdbc --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt @@ -0,0 +1,77 @@ +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 folder = inputData.getString(KEY_FOLDER_ID)?.let { id -> + UploadFolder(label = inputData.getString(KEY_FOLDER_LABEL) ?: id, folderId = id) + } + val uploader = (applicationContext as AudioJournalApp).container.cloudUploader + val result = uploader.upload(file, folder) + 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 KEY_FOLDER_ID = "folder_id" + const val KEY_FOLDER_LABEL = "folder_label" + const val MAX_ATTEMPTS = 8 + private const val TAG = "UploadWorker" + } +} diff --git a/app/src/main/java/com/audiojournal/app/upload/drive/DriveApi.kt b/app/src/main/java/com/audiojournal/app/upload/drive/DriveApi.kt new file mode 100644 index 0000000..1b62a69 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/drive/DriveApi.kt @@ -0,0 +1,73 @@ +package com.audiojournal.app.upload.drive + +import java.io.File +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID + +class DriveApiException(val statusCode: Int, message: String) : IOException(message) + +/** + * Minimal Drive v3 REST client using HttpURLConnection — just the single + * call this app needs (multipart file upload into a folder id). Must be + * called from a background dispatcher. + */ +class DriveApi { + + /** Uploads [file] via multipart upload, returns the new file's id. */ + fun uploadFile( + accessToken: String, + file: File, + mimeType: String, + folderId: String?, + ): String { + val boundary = "audio-journal-${UUID.randomUUID()}" + val metadata = DriveJson.fileMetadataJson(file.name, mimeType, folderId) + val url = "$API_BASE/upload/drive/v3/files?uploadType=multipart&fields=id&supportsAllDrives=true" + val response = request("POST", url, accessToken) { conn -> + conn.setRequestProperty("Content-Type", "multipart/related; boundary=$boundary") + conn.doOutput = true + conn.outputStream.buffered().use { out -> + out.write("--$boundary\r\n".toByteArray()) + out.write("Content-Type: application/json; charset=UTF-8\r\n\r\n".toByteArray()) + out.write(metadata.toByteArray(Charsets.UTF_8)) + out.write("\r\n--$boundary\r\n".toByteArray()) + out.write("Content-Type: $mimeType\r\n\r\n".toByteArray()) + file.inputStream().use { it.copyTo(out) } + out.write("\r\n--$boundary--\r\n".toByteArray()) + } + } + return DriveJson.parseFileId(response) + ?: throw IOException("Drive upload response had no id") + } + + private inline fun request( + method: String, + url: String, + accessToken: String, + configure: (HttpURLConnection) -> Unit, + ): String { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.requestMethod = method + connection.connectTimeout = TIMEOUT_MILLIS + connection.readTimeout = TIMEOUT_MILLIS + connection.setRequestProperty("Authorization", "Bearer $accessToken") + configure(connection) + val status = connection.responseCode + if (status !in 200..299) { + val error = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "" + throw DriveApiException(status, "Drive API $method $url failed with $status: ${error.take(500)}") + } + return connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } + + private companion object { + const val API_BASE = "https://www.googleapis.com" + const val TIMEOUT_MILLIS = 60_000 + } +} diff --git a/app/src/main/java/com/audiojournal/app/upload/drive/DriveAuthManager.kt b/app/src/main/java/com/audiojournal/app/upload/drive/DriveAuthManager.kt new file mode 100644 index 0000000..81f7ba9 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/drive/DriveAuthManager.kt @@ -0,0 +1,57 @@ +package com.audiojournal.app.upload.drive + +import android.app.PendingIntent +import android.content.Context +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.common.api.Scope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await + +/** + * Wraps Google Play services' AuthorizationClient to obtain OAuth access + * tokens for the Drive API. No client secret lives in the app: the OAuth + * client is registered in Google Cloud Console against this app's package + * name and signing certificate, and Play services brokers the tokens for the + * Google account on the device. + */ +class DriveAuthManager(private val context: Context) { + + private val authorizationRequest = AuthorizationRequest.builder() + .setRequestedScopes(listOf(Scope(DRIVE_SCOPE))) + .build() + + /** + * Access token if the user has already granted Drive access, or null if + * consent is still needed (or Play services is unavailable). + */ + suspend fun getAccessToken(): String? = try { + val result = Identity.getAuthorizationClient(context) + .authorize(authorizationRequest) + .await() + if (result.hasResolution()) null else result.accessToken + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + /** + * PendingIntent that launches the Google consent UI, or null if access + * is already granted (no UI needed). + */ + suspend fun consentIntent(): PendingIntent? { + val result = Identity.getAuthorizationClient(context) + .authorize(authorizationRequest) + .await() + return if (result.hasResolution()) result.pendingIntent else null + } + + companion object { + // Full Drive scope so recordings can be placed into folders that + // already exist in your Drive (e.g. the one your backend reads). + // Switch to "https://www.googleapis.com/auth/drive.file" if you only + // need folders created by this app. + const val DRIVE_SCOPE = "https://www.googleapis.com/auth/drive" + } +} diff --git a/app/src/main/java/com/audiojournal/app/upload/drive/DriveCloudUploader.kt b/app/src/main/java/com/audiojournal/app/upload/drive/DriveCloudUploader.kt new file mode 100644 index 0000000..3f53024 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/drive/DriveCloudUploader.kt @@ -0,0 +1,40 @@ +package com.audiojournal.app.upload.drive + +import com.audiojournal.app.upload.CloudUploader +import com.audiojournal.app.upload.UploadFolder +import com.audiojournal.app.upload.UploadResult +import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Uploads recordings into the user's Google Drive, directly into the folder + * id from the configuration (no name lookup — folder names are ambiguous in + * Drive). Without a configured folder, files land in the My Drive root. + */ +class DriveCloudUploader( + private val auth: DriveAuthManager, + private val api: DriveApi = DriveApi(), +) : CloudUploader { + + // Drive needs no build-time secrets; the user connects their account at runtime. + override val isConfigured: Boolean = true + + override val backendLabel: String = "Google Drive" + + override suspend fun upload(file: File, folder: UploadFolder?): UploadResult { + return try { + val token = auth.getAccessToken() + ?: return UploadResult.Error(IllegalStateException("Google Drive is not connected")) + withContext(Dispatchers.IO) { + api.uploadFile(token, file, "audio/mp4", folder?.folderId) + } + UploadResult.Success + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + UploadResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/audiojournal/app/upload/drive/DriveJson.kt b/app/src/main/java/com/audiojournal/app/upload/drive/DriveJson.kt new file mode 100644 index 0000000..354626e --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/drive/DriveJson.kt @@ -0,0 +1,30 @@ +package com.audiojournal.app.upload.drive + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** + * Pure request/response helpers for the Drive v3 REST API, kept free of any + * Android or HTTP types so they are unit testable. + */ +object DriveJson { + + /** Metadata JSON for the multipart file upload. */ + fun fileMetadataJson(fileName: String, mimeType: String, parentId: String?): String = + buildJsonObject { + put("name", fileName) + put("mimeType", mimeType) + if (parentId != null) { + putJsonArray("parents") { add(parentId) } + } + }.toString() + + /** Extracts the id field of a files.create response. */ + fun parseFileId(responseBody: String): String? = + Json.parseToJsonElement(responseBody).jsonObject["id"]?.jsonPrimitive?.content +} 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..d46c46a --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + + Audio Journal + + Tap to record + Recording… + Paused + + Record + Pause + Resume + Stop + + Saved %1$s (%2$s) + Queued for upload to %1$s → %2$s + Saved on device only — cloud upload not configured + Saved on device — connect Google Drive to upload + Connect Google Drive + Choose folder + My Drive + + 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 @@ + + +