diff --git a/app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt b/app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt index c36c8fb..8cbb36d 100644 --- a/app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt +++ b/app/src/main/java/com/audiojournal/app/recording/RecordingEngine.kt @@ -130,6 +130,27 @@ class RecordingEngine( } } + /** + * Stops the current recording and deletes its file without saving. + * No-op when idle. Finalize failures are ignored because the file is + * being thrown away anyway. + */ + @Synchronized + fun discard() { + val current = _state.value + if (current.phase == RecorderPhase.IDLE) return + val activeRecorder = recorder + recorder = null + recordedBeforePauseMillis = 0L + recordingSinceMillis = null + try { + activeRecorder?.stop() + } catch (_: Exception) { + } + current.activeFile?.delete() + _state.update { RecorderState(phase = RecorderPhase.IDLE, lastSaved = it.lastSaved) } + } + 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 index 6baf17c..afbc6cb 100644 --- a/app/src/main/java/com/audiojournal/app/recording/RecordingService.kt +++ b/app/src/main/java/com/audiojournal/app/recording/RecordingService.kt @@ -70,6 +70,12 @@ class RecordingService : Service() { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } + + ACTION_DISCARD -> { + engine.discard() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } } return START_NOT_STICKY } @@ -126,6 +132,7 @@ class RecordingService : Service() { 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 ACTION_DISCARD = "com.audiojournal.app.action.DISCARD" const val EXTRA_FOLDER_ID = "com.audiojournal.app.extra.FOLDER_ID" const val EXTRA_FOLDER_LABEL = "com.audiojournal.app.extra.FOLDER_LABEL" 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 0277d70..f7192d0 100644 --- a/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt @@ -25,11 +25,13 @@ 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.Delete 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.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledIconButton @@ -41,6 +43,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -145,6 +148,7 @@ fun RecorderScreen(viewModel: RecorderViewModel = viewModel()) { RecorderPhase.IDLE -> RecordButton(onClick = ::startWithPermissionCheck) RecorderPhase.RECORDING, RecorderPhase.PAUSED -> { + var showDiscardDialog by remember { mutableStateOf(false) } Row( horizontalArrangement = Arrangement.spacedBy(32.dp), verticalAlignment = Alignment.CenterVertically, @@ -155,6 +159,30 @@ fun RecorderScreen(viewModel: RecorderViewModel = viewModel()) { onResume = viewModel::resumeRecording, ) StopButton(onClick = viewModel::stopRecording) + DiscardButton(onClick = { showDiscardDialog = true }) + } + if (showDiscardDialog) { + AlertDialog( + onDismissRequest = { showDiscardDialog = false }, + title = { Text(stringResource(R.string.discard_dialog_title)) }, + text = { Text(stringResource(R.string.discard_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + showDiscardDialog = false + viewModel.discardRecording() + }, + modifier = Modifier.testTag("discard_confirm_button"), + ) { + Text(stringResource(R.string.discard)) + } + }, + dismissButton = { + TextButton(onClick = { showDiscardDialog = false }) { + Text(stringResource(R.string.keep_recording)) + } + }, + ) } } } @@ -311,6 +339,27 @@ private fun PauseResumeButton(isPaused: Boolean, onPause: () -> Unit, onResume: } } +@Composable +private fun DiscardButton(onClick: () -> Unit) { + FilledIconButton( + onClick = onClick, + modifier = Modifier + .size(88.dp) + .testTag("discard_button"), + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.discard), + modifier = Modifier.size(40.dp), + ) + } +} + @Composable private fun StopButton(onClick: () -> Unit) { val pulse = rememberInfiniteTransition(label = "pulse") 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 a49b27a..0d97df8 100644 --- a/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt +++ b/app/src/main/java/com/audiojournal/app/ui/RecorderViewModel.kt @@ -71,6 +71,8 @@ class RecorderViewModel(application: Application) : AndroidViewModel(application fun stopRecording() = sendAction(RecordingService.ACTION_STOP, _selectedFolder.value) + fun discardRecording() = sendAction(RecordingService.ACTION_DISCARD) + fun selectFolder(folder: UploadFolder) { _selectedFolder.value = folder } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d46c46a..a8b6beb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,10 @@ Pause Resume Stop + Discard + Discard recording? + The current recording will be deleted without saving. + Keep recording Saved %1$s (%2$s) Queued for upload to %1$s → %2$s diff --git a/app/src/test/java/com/audiojournal/app/recording/RecordingEngineTest.kt b/app/src/test/java/com/audiojournal/app/recording/RecordingEngineTest.kt index 0291af2..e879da3 100644 --- a/app/src/test/java/com/audiojournal/app/recording/RecordingEngineTest.kt +++ b/app/src/test/java/com/audiojournal/app/recording/RecordingEngineTest.kt @@ -181,6 +181,65 @@ class RecordingEngineTest { assertEquals(0, tempFolder.root.listFiles()!!.size) } + @Test + fun `discard while recording deletes the file and saves nothing`() { + engine.start() + timeSource.advance(3_000) + engine.discard() + val state = engine.state.value + assertEquals(RecorderPhase.IDLE, state.phase) + assertNull(state.lastSaved) + assertNull(state.errorMessage) + assertEquals(listOf("start", "stop"), fakeRecorder.calls) + assertEquals(0, tempFolder.root.listFiles()!!.size) + assertEquals(0, engine.elapsedMillis()) + } + + @Test + fun `discard while paused deletes the file`() { + engine.start() + engine.pause() + engine.discard() + assertEquals(RecorderPhase.IDLE, engine.state.value.phase) + assertEquals(0, tempFolder.root.listFiles()!!.size) + } + + @Test + fun `discard when idle is a no-op`() { + engine.discard() + assertEquals(RecorderPhase.IDLE, engine.state.value.phase) + assertTrue(fakeRecorder.calls.isEmpty()) + } + + @Test + fun `discard keeps the previously saved recording`() { + engine.start() + timeSource.advance(2_000) + val saved = engine.stop() + engine.start() + engine.discard() + assertEquals(saved, engine.state.value.lastSaved) + } + + @Test + fun `discard ignores finalize failures and still deletes the file`() { + engine.start() + fakeRecorder.failOnStop = true + engine.discard() + val state = engine.state.value + assertEquals(RecorderPhase.IDLE, state.phase) + assertNull(state.errorMessage) + assertEquals(0, tempFolder.root.listFiles()!!.size) + } + + @Test + fun `can record again after discarding`() { + engine.start() + engine.discard() + assertTrue(engine.start()) + assertEquals(RecorderPhase.RECORDING, engine.state.value.phase) + } + @Test fun `can record again after stopping`() { engine.start()