diff --git a/README.md b/README.md index 836d2b6..9565e16 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ optionally) in the background. 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. +- **Live upload status** — under the saved recording the screen tracks the + actual upload job: queued → uploading → uploaded, plus "retrying (attempt 3 + of 8)" and the failure reason when something goes wrong. - **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. @@ -184,6 +187,8 @@ app/src/main/java/com/audiojournal/app/ │ ├── FolderConfig.kt Parses the configured folder list │ ├── UploadWorker.kt WorkManager worker (retry with backoff) │ ├── UploadScheduler.kt Enqueues uploads with a network constraint +│ ├── UploadStatus.kt Status model + pure WorkManager-state mapping +│ ├── UploadStatusRepository.kt Live status of one recording's upload │ └── drive/ │ ├── DriveAuthManager.kt OAuth via Play services (no app secrets) │ ├── DriveApi.kt Minimal Drive v3 REST client @@ -203,7 +208,9 @@ Design choices: 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. + app restarts and reboots and only runs them when the network is up. Its job + state is also the single source of truth for the upload status in the UI, + which observes it as a `Flow` — no second copy of that state to keep in sync. - `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`. diff --git a/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt b/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt index 507f124..4adfd82 100644 --- a/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt +++ b/app/src/main/java/com/audiojournal/app/AudioJournalApp.kt @@ -3,12 +3,14 @@ package com.audiojournal.app import android.app.Application import android.content.Context import android.os.SystemClock +import androidx.work.WorkManager 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.UploadStatusRepository import com.audiojournal.app.upload.S3CloudUploader import com.audiojournal.app.upload.S3Config import com.audiojournal.app.upload.drive.DriveAuthManager @@ -43,6 +45,14 @@ class AppContainer(context: Context) { val driveAuthManager = DriveAuthManager(context) + /** + * Lazy because WorkManager's default initializer runs in a ContentProvider, + * i.e. after [Application.onCreate] where this container is built. + */ + val uploadStatusRepository: UploadStatusRepository by lazy { + UploadStatusRepository(WorkManager.getInstance(context)) + } + val cloudUploader: CloudUploader = when (uploadBackend) { UploadBackend.DRIVE -> DriveCloudUploader(driveAuthManager) UploadBackend.S3 -> S3CloudUploader( diff --git a/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt b/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt index f7192d0..a4c0f91 100644 --- a/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt @@ -25,13 +25,19 @@ 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.CloudOff +import androidx.compose.material.icons.filled.CloudQueue +import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ErrorOutline 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.material.icons.filled.Sync import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledIconButton @@ -66,6 +72,8 @@ import com.audiojournal.app.R import com.audiojournal.app.UploadBackend import com.audiojournal.app.recording.RecorderPhase import com.audiojournal.app.upload.UploadFolder +import com.audiojournal.app.upload.UploadStage +import com.audiojournal.app.upload.UploadStatus import kotlinx.coroutines.launch @Composable @@ -73,6 +81,7 @@ fun RecorderScreen(viewModel: RecorderViewModel = viewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val elapsedMillis by viewModel.elapsedMillis.collectAsStateWithLifecycle() val selectedFolder by viewModel.selectedFolder.collectAsStateWithLifecycle() + val uploadStatus by viewModel.uploadStatus.collectAsStateWithLifecycle() val driveConnected by viewModel.driveConnected.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -229,24 +238,15 @@ fun RecorderScreen(viewModel: RecorderViewModel = viewModel()) { ), 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, + Spacer(Modifier.height(8.dp)) + UploadStatusRow( + status = uploadStatus, + backendLabel = viewModel.backendLabel, + folderLabel = selectedFolder?.label + ?: stringResource(R.string.drive_root), + driveNotConnected = viewModel.uploadBackend == UploadBackend.DRIVE && + driveConnected == false, + isCloudConfigured = viewModel.isCloudConfigured, ) } } @@ -254,6 +254,113 @@ fun RecorderScreen(viewModel: RecorderViewModel = viewModel()) { } } +/** + * Upload status of the last saved recording. Missing credentials and missing + * Drive consent are known up front; everything else comes from [status], which + * tracks the background upload job live. + */ +@Composable +private fun UploadStatusRow( + status: UploadStatus?, + backendLabel: String, + folderLabel: String, + driveNotConnected: Boolean, + isCloudConfigured: Boolean, +) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant + val current = status ?: UploadStatus(UploadStage.QUEUED) + + // The job's state wins over the "not configured" hints once it has run, so + // a finished upload is never described as merely queued. + val stage = when { + current.stage != UploadStage.QUEUED -> current.stage + driveNotConnected || !isCloudConfigured -> UploadStage.KEPT_LOCAL + else -> UploadStage.QUEUED + } + + val reason = current.errorMessage + val (icon, text, color) = when (stage) { + UploadStage.QUEUED -> Triple( + Icons.Filled.CloudQueue, + stringResource(R.string.upload_queued_to, backendLabel, folderLabel), + muted, + ) + + UploadStage.UPLOADING -> Triple( + Icons.Filled.CloudUpload, + stringResource(R.string.upload_uploading, backendLabel, folderLabel), + muted, + ) + + UploadStage.RETRYING -> Triple( + Icons.Filled.Sync, + if (reason != null) { + stringResource( + R.string.upload_retrying_reason, + current.attempt, + current.maxAttempts, + reason, + ) + } else { + stringResource(R.string.upload_retrying, current.attempt, current.maxAttempts) + }, + muted, + ) + + UploadStage.UPLOADED -> Triple( + Icons.Filled.CloudDone, + stringResource(R.string.upload_finished, backendLabel, folderLabel), + MaterialTheme.colorScheme.primary, + ) + + UploadStage.KEPT_LOCAL -> Triple( + Icons.Filled.CloudOff, + if (driveNotConnected) { + stringResource(R.string.upload_drive_not_connected) + } else { + stringResource(R.string.upload_not_configured) + }, + muted, + ) + + UploadStage.FAILED -> Triple( + Icons.Filled.ErrorOutline, + if (reason != null) { + stringResource(R.string.upload_failed_reason, current.attempt, reason) + } else { + stringResource(R.string.upload_failed, current.attempt) + }, + MaterialTheme.colorScheme.error, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (stage == UploadStage.UPLOADING) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = color, + ) + } else { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + modifier = Modifier.testTag("upload_status"), + ) + } +} + @Composable private fun FolderSelector( folders: List, diff --git a/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt b/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt index 0d97df8..3c2964f 100644 --- a/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt @@ -10,14 +10,18 @@ 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 com.audiojournal.app.upload.UploadStatus 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.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -47,6 +51,21 @@ class RecorderViewModel(application: Application) : AndroidViewModel(application if (uploadBackend == UploadBackend.DRIVE) refreshDriveConnection() } + /** + * Live status of the upload of the last saved recording, straight from the + * WorkManager job, so the UI reflects queued -> uploading -> done/failed + * instead of only what was intended at save time. + * Null while nothing has been saved (or the job is no longer known). + */ + val uploadStatus: StateFlow = engine.state + .map { it.lastSaved?.file?.name } + .distinctUntilChanged() + .flatMapLatest { fileName -> + if (fileName == null) flowOf(null) + else container.uploadStatusRepository.statusFor(fileName) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** Ticks while recording so the timer in the UI stays current. */ val elapsedMillis: StateFlow = engine.state .flatMapLatest { current -> diff --git a/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt b/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt index cc26a65..02ebf7e 100644 --- a/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt +++ b/app/src/main/java/com/audiojournal/app/upload/UploadScheduler.kt @@ -13,6 +13,13 @@ import java.util.concurrent.TimeUnit object UploadScheduler { + /** + * Name of the unique work that uploads [fileName]. Recording names are + * timestamped, so one name maps to one upload job — which is what lets the + * UI observe that job's status again later. + */ + fun uniqueWorkName(fileName: String): String = "upload-$fileName" + /** Queues [file] for upload into [folder] once the device is online. */ fun enqueue(context: Context, file: File, folder: UploadFolder?) { val request = OneTimeWorkRequestBuilder() @@ -32,7 +39,7 @@ object UploadScheduler { .build() WorkManager.getInstance(context).enqueueUniqueWork( - "upload-${file.name}", + uniqueWorkName(file.name), ExistingWorkPolicy.KEEP, request, ) diff --git a/app/src/main/java/com/audiojournal/app/upload/UploadStatus.kt b/app/src/main/java/com/audiojournal/app/upload/UploadStatus.kt new file mode 100644 index 0000000..23578f9 --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/UploadStatus.kt @@ -0,0 +1,72 @@ +package com.audiojournal.app.upload + +/** Where a saved recording is on its way to cloud storage. */ +enum class UploadStage { + /** Enqueued, waiting for connectivity or for a worker slot. */ + QUEUED, + UPLOADING, + /** An attempt failed; another one is scheduled (exponential backoff). */ + RETRYING, + UPLOADED, + /** No cloud backend configured, so the file stays on the device. */ + KEPT_LOCAL, + /** Every attempt failed; the file is still on the device. */ + FAILED, +} + +/** What the main screen shows about the upload of the last saved recording. */ +data class UploadStatus( + val stage: UploadStage, + /** 1-based attempt: the one running, the one scheduled next, or the last one tried. */ + val attempt: Int = 1, + val maxAttempts: Int = 1, + /** Failure detail from the last attempt, when known. */ + val errorMessage: String? = null, +) + +/** + * WorkManager-free mirror of `WorkInfo.State` so [uploadStatusOf] stays a pure, + * unit-testable function. `BLOCKED` folds into [PENDING] — from the UI's point + * of view both mean "not started yet". + */ +enum class UploadWorkState { PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED } + +/** + * Maps one upload job's WorkManager state onto the status shown in the UI. + * + * [runAttemptCount] is WorkManager's count of attempts already made, so the + * attempt that is running (or scheduled next) is `runAttemptCount + 1`. + * Returns null when there is nothing worth showing. + */ +fun uploadStatusOf( + workState: UploadWorkState, + runAttemptCount: Int, + maxAttempts: Int, + keptLocal: Boolean = false, + errorMessage: String? = null, +): UploadStatus? { + val attempt = runAttemptCount + 1 + return when (workState) { + UploadWorkState.PENDING -> + if (runAttemptCount > 0) { + UploadStatus(UploadStage.RETRYING, attempt, maxAttempts, errorMessage) + } else { + UploadStatus(UploadStage.QUEUED, attempt, maxAttempts) + } + + UploadWorkState.RUNNING -> UploadStatus(UploadStage.UPLOADING, attempt, maxAttempts) + + UploadWorkState.SUCCEEDED -> UploadStatus( + stage = if (keptLocal) UploadStage.KEPT_LOCAL else UploadStage.UPLOADED, + attempt = attempt, + maxAttempts = maxAttempts, + ) + + // runAttemptCount is not incremented for the attempt that gave up, so + // `attempt` is the number of attempts actually made. + UploadWorkState.FAILED -> + UploadStatus(UploadStage.FAILED, attempt, maxAttempts, errorMessage) + + UploadWorkState.CANCELLED -> null + } +} diff --git a/app/src/main/java/com/audiojournal/app/upload/UploadStatusRepository.kt b/app/src/main/java/com/audiojournal/app/upload/UploadStatusRepository.kt new file mode 100644 index 0000000..786804e --- /dev/null +++ b/app/src/main/java/com/audiojournal/app/upload/UploadStatusRepository.kt @@ -0,0 +1,38 @@ +package com.audiojournal.app.upload + +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Live upload status of a single recording, read back from the WorkManager job + * that [UploadScheduler] enqueued for it. WorkManager keeps the job's state in + * its own database, so this works across process death too. + */ +class UploadStatusRepository(private val workManager: WorkManager) { + + /** Emits whenever the upload of [fileName] changes state; null when unknown. */ + fun statusFor(fileName: String): Flow = + workManager + .getWorkInfosForUniqueWorkFlow(UploadScheduler.uniqueWorkName(fileName)) + .map { infos -> infos.lastOrNull()?.toUploadStatus() } +} + +private fun WorkInfo.toUploadStatus(): UploadStatus? = uploadStatusOf( + workState = when (state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> UploadWorkState.PENDING + WorkInfo.State.RUNNING -> UploadWorkState.RUNNING + WorkInfo.State.SUCCEEDED -> UploadWorkState.SUCCEEDED + WorkInfo.State.FAILED -> UploadWorkState.FAILED + WorkInfo.State.CANCELLED -> UploadWorkState.CANCELLED + }, + runAttemptCount = runAttemptCount, + maxAttempts = UploadWorker.MAX_ATTEMPTS, + keptLocal = outputData.getBoolean(UploadWorker.KEY_KEPT_LOCAL, false), + // Finished work carries the reason in its output data; between retries the + // worker publishes it as progress, which WorkManager may drop — so the + // retry message has to read fine without it. + errorMessage = outputData.getString(UploadWorker.KEY_ERROR) + ?: progress.getString(UploadWorker.KEY_ERROR), +) diff --git a/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt b/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt index d27cdbc..6166f11 100644 --- a/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt +++ b/app/src/main/java/com/audiojournal/app/upload/UploadWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import androidx.work.workDataOf import com.audiojournal.app.AudioJournalApp import java.io.File @@ -49,20 +50,26 @@ class UploadWorker( UploadDecision.SUCCESS -> { if (result is UploadResult.Success) { Log.i(TAG, "Uploaded ${file.name}") + Result.success() } else { Log.i(TAG, "Cloud storage not configured; ${file.name} kept locally") + Result.success(workDataOf(KEY_KEPT_LOCAL to true)) } - Result.success() } UploadDecision.RETRY -> { - Log.w(TAG, "Upload of ${file.name} failed, will retry", (result as UploadResult.Error).cause) + val cause = (result as UploadResult.Error).cause + Log.w(TAG, "Upload of ${file.name} failed, will retry", cause) + // Best effort: lets the UI name the reason while the retry is + // still pending. Progress data does not outlive the job. + setProgress(workDataOf(KEY_ERROR to cause.readableMessage())) Result.retry() } UploadDecision.GIVE_UP -> { - Log.e(TAG, "Upload of ${file.name} failed permanently", (result as UploadResult.Error).cause) - Result.failure() + val cause = (result as UploadResult.Error).cause + Log.e(TAG, "Upload of ${file.name} failed permanently", cause) + Result.failure(workDataOf(KEY_ERROR to cause.readableMessage())) } } } @@ -71,7 +78,20 @@ class UploadWorker( const val KEY_FILE_PATH = "file_path" const val KEY_FOLDER_ID = "folder_id" const val KEY_FOLDER_LABEL = "folder_label" + + /** Output data: the upload was skipped because no backend is configured. */ + const val KEY_KEPT_LOCAL = "kept_local" + + /** Output/progress data: why the last attempt failed. */ + const val KEY_ERROR = "error" + const val MAX_ATTEMPTS = 8 private const val TAG = "UploadWorker" } } + +/** Short, user-facing description of an upload failure. */ +private fun Throwable.readableMessage(): String = + message?.takeIf { it.isNotBlank() } + ?: javaClass.simpleName.takeIf { it.isNotBlank() } + ?: "unknown error" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8b6beb..cc7cdd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,12 @@ Saved %1$s (%2$s) Queued for upload to %1$s → %2$s + Uploading to %1$s → %2$s… + Uploaded to %1$s → %2$s + Upload failed — retrying (attempt %1$d of %2$d) + Upload failed — retrying (attempt %1$d of %2$d): %3$s + Upload failed after %1$d attempts — kept on device + Upload failed after %1$d attempts — kept on device: %2$s Saved on device only — cloud upload not configured Saved on device — connect Google Drive to upload Connect Google Drive diff --git a/app/src/test/java/com/audiojournal/app/upload/UploadStatusTest.kt b/app/src/test/java/com/audiojournal/app/upload/UploadStatusTest.kt new file mode 100644 index 0000000..88b2b8a --- /dev/null +++ b/app/src/test/java/com/audiojournal/app/upload/UploadStatusTest.kt @@ -0,0 +1,100 @@ +package com.audiojournal.app.upload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class UploadStatusTest { + + private fun status( + workState: UploadWorkState, + runAttemptCount: Int = 0, + keptLocal: Boolean = false, + errorMessage: String? = null, + ) = uploadStatusOf( + workState = workState, + runAttemptCount = runAttemptCount, + maxAttempts = MAX_ATTEMPTS, + keptLocal = keptLocal, + errorMessage = errorMessage, + ) + + @Test + fun `a job that has not run yet is queued`() { + assertEquals( + UploadStatus(UploadStage.QUEUED, attempt = 1, maxAttempts = MAX_ATTEMPTS), + status(UploadWorkState.PENDING), + ) + } + + @Test + fun `a running job reports the attempt it is on`() { + assertEquals( + UploadStatus(UploadStage.UPLOADING, attempt = 3, maxAttempts = MAX_ATTEMPTS), + status(UploadWorkState.RUNNING, runAttemptCount = 2), + ) + } + + @Test + fun `pending again after a failed attempt means retrying, with the reason`() { + assertEquals( + UploadStatus( + stage = UploadStage.RETRYING, + attempt = 2, + maxAttempts = MAX_ATTEMPTS, + errorMessage = "network down", + ), + status( + UploadWorkState.PENDING, + runAttemptCount = 1, + errorMessage = "network down", + ), + ) + } + + @Test + fun `a retry without a known reason still reports the attempt`() { + assertEquals( + UploadStatus(UploadStage.RETRYING, attempt = 4, maxAttempts = MAX_ATTEMPTS), + status(UploadWorkState.PENDING, runAttemptCount = 3), + ) + } + + @Test + fun `success is an upload unless the file was only kept locally`() { + assertEquals( + UploadStage.UPLOADED, + status(UploadWorkState.SUCCEEDED)?.stage, + ) + assertEquals( + UploadStage.KEPT_LOCAL, + status(UploadWorkState.SUCCEEDED, keptLocal = true)?.stage, + ) + } + + @Test + fun `failure reports every attempt made and the reason`() { + assertEquals( + UploadStatus( + stage = UploadStage.FAILED, + attempt = MAX_ATTEMPTS, + maxAttempts = MAX_ATTEMPTS, + errorMessage = "403 Forbidden", + ), + status( + UploadWorkState.FAILED, + runAttemptCount = MAX_ATTEMPTS - 1, + errorMessage = "403 Forbidden", + ), + ) + } + + @Test + fun `cancelled work has nothing to show`() { + assertNull(status(UploadWorkState.CANCELLED)) + } + + private companion object { + const val MAX_ATTEMPTS = 8 + } +}