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()