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
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"

Expand Down
49 changes: 49 additions & 0 deletions app/src/main/java/com/audiojournal/app/ui/RecorderScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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))
}
},
)
}
}
}
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<string name="pause">Pause</string>
<string name="resume">Resume</string>
<string name="stop">Stop</string>
<string name="discard">Discard</string>
<string name="discard_dialog_title">Discard recording?</string>
<string name="discard_dialog_message">The current recording will be deleted without saving.</string>
<string name="keep_recording">Keep recording</string>

<string name="last_saved">Saved %1$s (%2$s)</string>
<string name="upload_queued_to">Queued for upload to %1$s → %2$s</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading