Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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`.
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/audiojournal/app/AudioJournalApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
143 changes: 125 additions & 18 deletions app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,13 +72,16 @@ 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
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
Expand Down Expand Up @@ -229,31 +238,129 @@ 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,
)
}
}
}
}
}

/**
* 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<UploadFolder>,
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<UploadStatus?> = 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<Long> = engine.state
.flatMapLatest { current ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UploadWorker>()
Expand All @@ -32,7 +39,7 @@ object UploadScheduler {
.build()

WorkManager.getInstance(context).enqueueUniqueWork(
"upload-${file.name}",
uniqueWorkName(file.name),
ExistingWorkPolicy.KEEP,
request,
)
Expand Down
72 changes: 72 additions & 0 deletions app/src/main/java/com/audiojournal/app/upload/UploadStatus.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading