Merge pull request #108 from Myzel394/fix-105-custom-filename

Add custom filename option
This commit is contained in:
Myzel394 2024-08-21 09:26:43 +02:00 committed by GitHub
commit a88507a905
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 403 additions and 75 deletions

View File

@ -41,4 +41,4 @@ jobs:
track: production
status: inProgress
inAppUpdatePriority: 2
userFraction: 0.33
userFraction: 0.2

View File

@ -35,8 +35,8 @@ android {
applicationId "app.myzel394.alibi"
minSdk 24
targetSdk 34
versionCode 14
versionName "0.5.1"
versionCode 15
versionName "0.5.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -94,9 +94,9 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.3'
implementation 'androidx.activity:activity-compose:1.9.0'
implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.activity:activity-compose:1.9.1'
implementation 'androidx.activity:activity-ktx:1.9.1'
implementation platform('androidx.compose:compose-bom:2024.06.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
@ -105,7 +105,7 @@ dependencies {
implementation "androidx.compose.material:material-icons-extended:1.6.8"
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.lifecycle:lifecycle-service:2.8.3'
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'

View File

@ -29,6 +29,8 @@ data class AppSettings(
val theme: Theme = Theme.SYSTEM,
val lastRecording: RecordingInformation? = null,
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
/// Recording information
// 30 minutes
val maxDuration: Long = 15 * 60 * 1000L,
@ -67,6 +69,10 @@ data class AppSettings(
return copy(lastRecording = lastRecording)
}
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
return copy(filenameFormat = filenameFormat)
}
fun setMaxDuration(duration: Long): AppSettings {
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
throw Exception("Max duration must be between 1 minute and 10 days")
@ -124,14 +130,20 @@ data class AppSettings(
))
}
fun exportToString(): String {
return Json.encodeToString(serializer(), this)
}
enum class Theme {
SYSTEM,
LIGHT,
DARK,
}
fun exportToString(): String {
return Json.encodeToString(serializer(), this)
enum class FilenameFormat {
DATETIME_ABSOLUTE_START,
DATETIME_RELATIVE_START,
DATETIME_NOW,
}
companion object {
@ -151,6 +163,7 @@ data class RecordingInformation(
val folderPath: String,
@Serializable(with = LocalDateTimeSerializer::class)
val recordingStart: LocalDateTime,
val batchesAmount: Int,
val maxDuration: Long,
val intervalDuration: Long,
val fileExtension: String,
@ -165,6 +178,23 @@ data class RecordingInformation(
.hasRecordingsAvailable()
}
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
return when (filenameFormat) {
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
getFullDuration() / 1000
)
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
}
}
fun getFullDuration(): Long {
// This is not accurate, since the last batch may be shorter than the others
// but it's good enough
return intervalDuration * batchesAmount - (intervalDuration * 0.5).toLong()
}
enum class Type {
AUDIO,
VIDEO,

View File

@ -18,6 +18,7 @@ import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
@ -249,19 +250,23 @@ abstract class BatchesFolder(
abstract fun cleanup()
suspend fun concatenate(
recordingStart: LocalDateTime,
extension: String,
recording: RecordingInformation,
filenameFormat: AppSettings.FilenameFormat,
disableCache: Boolean? = null,
onNextParameterTry: (String) -> Unit = {},
durationPerBatchInMilliseconds: Long = 0,
onProgress: (Float?) -> Unit = {},
): String {
val disableCache = disableCache ?: (type != BatchType.INTERNAL)
val date = recording.getStartDateForFilename(filenameFormat)
if (!disableCache && checkIfOutputAlreadyExists(recordingStart, extension)) {
if (!disableCache && checkIfOutputAlreadyExists(
recording.recordingStart,
recording.fileExtension
)
) {
return getOutputFileForFFmpeg(
date = recordingStart,
extension = extension,
date = recording.recordingStart,
extension = recording.fileExtension,
)
}
@ -271,34 +276,12 @@ abstract class BatchesFolder(
onProgress(null)
try {
val fullTime = recording.getFullDuration().toFloat();
val filePaths = getBatchesForFFmpeg()
// Casting here to float so it doesn't need to redo it on every progress update
var fullTime: Float? = null
runCatching {
// `fullTime` is not accurate as the last batch might be shorter,
// but it's good enough for the progress bar
// Using the code below results in a nasty bug:
// since we use ffmpeg to extract the duration, the saf parameter is already
// "used up" and we can't use it again for the actual concatenation
// Since an accurate progress bar is less important than speed,
// we currently don't use this code
/*
val lastBatchTime = (FFprobeKit.execute(
"-i ${filePaths.last()} -show_entries format=duration -v quiet -of csv=\"p=0\"",
).output.toFloat() * 1000).toLong()
fullTime =
((durationPerBatchInMilliseconds * (filePaths.size - 1)) + lastBatchTime).toFloat()
*/
// We use an approximation for the duration of the batches
fullTime = (durationPerBatchInMilliseconds * filePaths.size).toFloat()
}
val outputFile = getOutputFileForFFmpeg(
date = recordingStart,
extension = extension,
date = date,
extension = recording.fileExtension,
)
concatenationFunction(
@ -308,11 +291,7 @@ abstract class BatchesFolder(
) { time ->
// The progressbar for the conversion is calculated based on the
// current time of the conversion and the total time of the batches.
if (fullTime != null) {
onProgress(time / fullTime!!)
} else {
onProgress(null)
}
onProgress(time / fullTime)
}.await()
return outputFile
} catch (e: MediaConverter.FFmpegException) {
@ -607,19 +586,23 @@ abstract class BatchesFolder(
}
fun canAccessFolder(context: Context, uri: Uri): Boolean {
// This always returns false for some reason, let's just assume it's true
return true
/*
return try {
// Create temp file
val tempFile = DocumentFile.fromSingleUri(context, uri)!!.createFile(
"application/octet-stream",
"temp"
)!!
tempFile.delete()
val docFile = DocumentFile.fromSingleUri(context, uri)!!
true
return docFile.canWrite().also {
println("Can write? ${it}")
} && docFile.canRead().also {
println("Can read? ${it}")
}
} catch (error: RuntimeException) {
error.printStackTrace()
false
}
*/
}
}
}

View File

@ -16,9 +16,7 @@ import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.helpers.AudioBatchesFolder
import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
import app.myzel394.alibi.ui.utils.MicrophoneInfo
import java.lang.IllegalStateException
class AudioRecorderService :
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
@ -304,12 +302,14 @@ class AudioRecorderService :
}
// ==== Settings ====
override fun getRecordingInformation() = RecordingInformation(
folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart,
maxDuration = settings.maxDuration,
fileExtension = settings.audioRecorderSettings.fileExtension,
intervalDuration = settings.intervalDuration,
type = RecordingInformation.Type.AUDIO,
)
override fun getRecordingInformation() =
RecordingInformation(
folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart,
maxDuration = settings.maxDuration,
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
fileExtension = settings.audioRecorderSettings.fileExtension,
intervalDuration = settings.intervalDuration,
type = RecordingInformation.Type.AUDIO,
)
}

View File

@ -304,14 +304,16 @@ class VideoRecorderService :
this
}
override fun getRecordingInformation(): RecordingInformation = RecordingInformation(
folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart,
maxDuration = settings.maxDuration,
fileExtension = settings.videoRecorderSettings.fileExtension,
intervalDuration = settings.intervalDuration,
type = RecordingInformation.Type.VIDEO,
)
override fun getRecordingInformation() =
RecordingInformation(
folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart,
maxDuration = settings.maxDuration,
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
fileExtension = settings.videoRecorderSettings.fileExtension,
intervalDuration = settings.intervalDuration,
type = RecordingInformation.Type.VIDEO,
)
companion object {
const val CAMERA_CLOSE_TIMEOUT = 20000L

View File

@ -162,6 +162,7 @@ fun RecorderEventsHandler(
// When recording is loaded from lastRecording
?: settings.lastRecording
?: throw Exception("No recording information available")
val batchesFolder = when (recorder.javaClass) {
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
recording.folderPath,
@ -177,9 +178,8 @@ fun RecorderEventsHandler(
}
batchesFolder.concatenate(
recording.recordingStart,
recording.fileExtension,
durationPerBatchInMilliseconds = settings.intervalDuration,
recording,
filenameFormat = settings.filenameFormat,
onProgress = { percentage ->
processingProgress = percentage
}

View File

@ -0,0 +1,235 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.TextSnippet
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Timelapse
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import kotlinx.coroutines.launch
val FORMAT_RESOURCE_MAP: Map<AppSettings.FilenameFormat, Int> = mapOf(
AppSettings.FilenameFormat.DATETIME_RELATIVE_START to R.string.ui_settings_option_filenameFormat_action_relativeStart_label,
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START to R.string.ui_settings_option_filenameFormat_action_absoluteStart_label,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilenameFormatTile(
settings: AppSettings,
snackbarHostState: SnackbarHostState,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val dataStore = context.dataStore
val successMessage = stringResource(R.string.ui_settings_option_filenameFormat_success)
fun updateValue(format: AppSettings.FilenameFormat) {
scope.launch {
dataStore.updateData {
it.setFilenameFormat(format)
}
}
}
var selectionVisible by remember { mutableStateOf(false) }
val selectionSheetState = rememberModalBottomSheetState(true)
fun hideSheet() {
scope.launch {
selectionSheetState.hide()
selectionVisible = false
}
}
if (selectionVisible) {
SelectionSheet(
sheetState = selectionSheetState,
updateValue = { format ->
hideSheet()
if (format != null) {
updateValue(format)
scope.launch {
snackbarHostState.showSnackbar(
message = successMessage,
duration = SnackbarDuration.Short,
)
}
}
},
onDismiss = ::hideSheet,
)
}
SettingsTile(
title = stringResource(R.string.ui_settings_option_filenameFormat_title),
description = stringResource(R.string.ui_settings_option_filenameFormat_explanation),
leading = {
Icon(
Icons.AutoMirrored.Filled.TextSnippet,
contentDescription = null,
)
},
trailing = {
Button(
onClick = {
scope.launch {
selectionVisible = true
}
},
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = MaterialTheme.shapes.medium,
) {
Text(
text = stringResource(FORMAT_RESOURCE_MAP[settings.filenameFormat]!!),
)
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionSheet(
sheetState: SheetState,
updateValue: (AppSettings.FilenameFormat?) -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = SHEET_BOTTOM_OFFSET)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
stringResource(R.string.ui_settings_option_filenameFormat_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
SelectionButton(
label = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_label),
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_explanation),
icon = Icons.Default.AccessTime,
onClick = {
updateValue(AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START)
},
)
HorizontalDivider()
SelectionButton(
label = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_label),
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_explanation),
icon = Icons.Default.Timelapse,
onClick = {
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
},
)
HorizontalDivider()
SelectionButton(
label = stringResource(R.string.ui_settings_option_filenameFormat_action_now_label),
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_now_explanation),
icon = Icons.Default.Circle,
onClick = {
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
},
)
}
}
}
@Composable
private fun SelectionButton(
label: String,
explanation: String,
icon: ImageVector,
onClick: () -> Unit,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = label
}
.clickable {
onClick()
}
.padding(horizontal = 16.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
.fillMaxWidth(0.1f),
)
Column(
modifier = Modifier.fillMaxWidth(0.9f),
) {
Text(label)
Text(
explanation,
style = MaterialTheme.typography.bodySmall,
)
}
}
}

View File

@ -489,7 +489,7 @@ fun InternalFolderExplanationDialog(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionSheet(
private fun SelectionSheet(
sheetState: SheetState,
updateValue: (String?) -> Unit,
onDismiss: () -> Unit,
@ -596,7 +596,7 @@ fun SelectionSheet(
}
@Composable
fun SelectionButton(
private fun SelectionButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,

View File

@ -1,6 +1,7 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import android.Manifest
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -19,6 +20,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@ -156,6 +158,17 @@ fun SaveFolderPage(
contentDescription = null,
)
}
var showError by rememberSaveable { mutableStateOf(false) }
if (showError) {
_FolderInaccessibleDialog(
onClose = {
showError = false
}
)
}
PermissionRequester(
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
@ -166,7 +179,23 @@ fun SaveFolderPage(
return@rememberFolderSelectorDialog
}
onContinue(saveFolder)
context.contentResolver.takePersistableUriPermission(
folder,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
if (BatchesFolder.canAccessFolder(context, folder)) {
onContinue(folder.toString())
} else {
showError = true
runCatching {
context.contentResolver.releasePersistableUriPermission(
folder,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
}
}
var showCustomFolderHint by rememberSaveable { mutableStateOf(false) }
@ -216,6 +245,44 @@ fun SaveFolderPage(
}
}
@Composable
fun _FolderInaccessibleDialog(
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
icon = {
Icon(
Icons.Default.Error,
contentDescription = null,
)
},
title = {
Text(stringResource(R.string.ui_error_occurred_title))
},
confirmButton = {
Button(onClick = onClose) {
Text(stringResource(R.string.dialog_close_neutral_label))
}
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
Text(
stringResource(R.string.ui_settings_option_saveFolder_batchesFolderInaccessible_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
)
}
@Composable
fun _CustomFolderDialog(
onAbort: () -> Unit,

View File

@ -45,6 +45,7 @@ import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.CustomNotificationT
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.DeleteRecordingsImmediatelyTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.DividerTitle
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.EnableAppLockTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.FilenameFormatTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.ImportExport
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.IntervalDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.MaxDurationTile
@ -145,6 +146,7 @@ fun SettingsScreen(
DeleteRecordingsImmediatelyTile(settings = settings)
CustomNotificationTile(onNavigateToCustomRecordingNotifications, settings = settings)
EnableAppLockTile(settings = settings)
FilenameFormatTile(settings = settings, snackbarHostState = snackbarHostState)
SaveFolderTile(
settings = settings,
snackbarHostState = snackbarHostState,

View File

@ -224,4 +224,13 @@
<string name="ui_welcome_timeSettings_values_1min">1 Minute</string>
<string name="ui_error_occurred_title">There was an error</string>
<string name="ui_settings_option_saveFolder_batchesFolderInaccessible_error">Alibi can\'t access this folder. Please select a different one</string>
<string name="ui_settings_option_filenameFormat_title">Filename Format</string>
<string name="ui_settings_option_filenameFormat_explanation">How should the file be named?</string>
<string name="ui_settings_option_filenameFormat_action_absoluteStart_label">Absolute start</string>
<string name="ui_settings_option_filenameFormat_action_absoluteStart_explanation">Use the time you started the recording</string>
<string name="ui_settings_option_filenameFormat_action_relativeStart_label">Recording start</string>
<string name="ui_settings_option_filenameFormat_action_relativeStart_explanation">Use the time the actual recording starts at</string>
<string name="ui_settings_option_filenameFormat_success">The new format will be used for future recordings</string>
<string name="ui_settings_option_filenameFormat_action_now_label">Save time</string>
<string name="ui_settings_option_filenameFormat_action_now_explanation">Use the time you saved the recording</string>
</resources>