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 track: production
status: inProgress status: inProgress
inAppUpdatePriority: 2 inAppUpdatePriority: 2
userFraction: 0.33 userFraction: 0.2

View File

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

View File

@ -29,6 +29,8 @@ data class AppSettings(
val theme: Theme = Theme.SYSTEM, val theme: Theme = Theme.SYSTEM,
val lastRecording: RecordingInformation? = null, val lastRecording: RecordingInformation? = null,
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
/// Recording information /// Recording information
// 30 minutes // 30 minutes
val maxDuration: Long = 15 * 60 * 1000L, val maxDuration: Long = 15 * 60 * 1000L,
@ -67,6 +69,10 @@ data class AppSettings(
return copy(lastRecording = lastRecording) return copy(lastRecording = lastRecording)
} }
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
return copy(filenameFormat = filenameFormat)
}
fun setMaxDuration(duration: Long): AppSettings { fun setMaxDuration(duration: Long): AppSettings {
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) { if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
throw Exception("Max duration must be between 1 minute and 10 days") 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 { enum class Theme {
SYSTEM, SYSTEM,
LIGHT, LIGHT,
DARK, DARK,
} }
fun exportToString(): String { enum class FilenameFormat {
return Json.encodeToString(serializer(), this) DATETIME_ABSOLUTE_START,
DATETIME_RELATIVE_START,
DATETIME_NOW,
} }
companion object { companion object {
@ -151,6 +163,7 @@ data class RecordingInformation(
val folderPath: String, val folderPath: String,
@Serializable(with = LocalDateTimeSerializer::class) @Serializable(with = LocalDateTimeSerializer::class)
val recordingStart: LocalDateTime, val recordingStart: LocalDateTime,
val batchesAmount: Int,
val maxDuration: Long, val maxDuration: Long,
val intervalDuration: Long, val intervalDuration: Long,
val fileExtension: String, val fileExtension: String,
@ -165,6 +178,23 @@ data class RecordingInformation(
.hasRecordingsAvailable() .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 { enum class Type {
AUDIO, AUDIO,
VIDEO, VIDEO,

View File

@ -18,6 +18,7 @@ import androidx.annotation.RequiresApi
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.db.AppSettings 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.MEDIA_RECORDINGS_PREFIX
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
@ -249,19 +250,23 @@ abstract class BatchesFolder(
abstract fun cleanup() abstract fun cleanup()
suspend fun concatenate( suspend fun concatenate(
recordingStart: LocalDateTime, recording: RecordingInformation,
extension: String, filenameFormat: AppSettings.FilenameFormat,
disableCache: Boolean? = null, disableCache: Boolean? = null,
onNextParameterTry: (String) -> Unit = {}, onNextParameterTry: (String) -> Unit = {},
durationPerBatchInMilliseconds: Long = 0,
onProgress: (Float?) -> Unit = {}, onProgress: (Float?) -> Unit = {},
): String { ): String {
val disableCache = disableCache ?: (type != BatchType.INTERNAL) 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( return getOutputFileForFFmpeg(
date = recordingStart, date = recording.recordingStart,
extension = extension, extension = recording.fileExtension,
) )
} }
@ -271,34 +276,12 @@ abstract class BatchesFolder(
onProgress(null) onProgress(null)
try { try {
val fullTime = recording.getFullDuration().toFloat();
val filePaths = getBatchesForFFmpeg() 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( val outputFile = getOutputFileForFFmpeg(
date = recordingStart, date = date,
extension = extension, extension = recording.fileExtension,
) )
concatenationFunction( concatenationFunction(
@ -308,11 +291,7 @@ abstract class BatchesFolder(
) { time -> ) { time ->
// The progressbar for the conversion is calculated based on the // The progressbar for the conversion is calculated based on the
// current time of the conversion and the total time of the batches. // current time of the conversion and the total time of the batches.
if (fullTime != null) { onProgress(time / fullTime)
onProgress(time / fullTime!!)
} else {
onProgress(null)
}
}.await() }.await()
return outputFile return outputFile
} catch (e: MediaConverter.FFmpegException) { } catch (e: MediaConverter.FFmpegException) {
@ -607,19 +586,23 @@ abstract class BatchesFolder(
} }
fun canAccessFolder(context: Context, uri: Uri): Boolean { 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 { return try {
// Create temp file // Create temp file
val tempFile = DocumentFile.fromSingleUri(context, uri)!!.createFile( val docFile = DocumentFile.fromSingleUri(context, uri)!!
"application/octet-stream",
"temp"
)!!
tempFile.delete()
true return docFile.canWrite().also {
println("Can write? ${it}")
} && docFile.canRead().also {
println("Can read? ${it}")
}
} catch (error: RuntimeException) { } catch (error: RuntimeException) {
error.printStackTrace() error.printStackTrace()
false false
} }
*/
} }
} }
} }

View File

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

View File

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

View File

@ -162,6 +162,7 @@ fun RecorderEventsHandler(
// When recording is loaded from lastRecording // When recording is loaded from lastRecording
?: settings.lastRecording ?: settings.lastRecording
?: throw Exception("No recording information available") ?: throw Exception("No recording information available")
val batchesFolder = when (recorder.javaClass) { val batchesFolder = when (recorder.javaClass) {
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder( AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
recording.folderPath, recording.folderPath,
@ -177,9 +178,8 @@ fun RecorderEventsHandler(
} }
batchesFolder.concatenate( batchesFolder.concatenate(
recording.recordingStart, recording,
recording.fileExtension, filenameFormat = settings.filenameFormat,
durationPerBatchInMilliseconds = settings.intervalDuration,
onProgress = { percentage -> onProgress = { percentage ->
processingProgress = 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SelectionSheet( private fun SelectionSheet(
sheetState: SheetState, sheetState: SheetState,
updateValue: (String?) -> Unit, updateValue: (String?) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@ -596,7 +596,7 @@ fun SelectionSheet(
} }
@Composable @Composable
fun SelectionButton( private fun SelectionButton(
label: String, label: String,
icon: ImageVector, icon: ImageVector,
onClick: () -> Unit, onClick: () -> Unit,

View File

@ -1,6 +1,7 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import android.Manifest import android.Manifest
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -156,6 +158,17 @@ fun SaveFolderPage(
contentDescription = null, contentDescription = null,
) )
} }
var showError by rememberSaveable { mutableStateOf(false) }
if (showError) {
_FolderInaccessibleDialog(
onClose = {
showError = false
}
)
}
PermissionRequester( PermissionRequester(
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE, permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
icon = Icons.AutoMirrored.Filled.InsertDriveFile, icon = Icons.AutoMirrored.Filled.InsertDriveFile,
@ -166,7 +179,23 @@ fun SaveFolderPage(
return@rememberFolderSelectorDialog 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) } 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 @Composable
fun _CustomFolderDialog( fun _CustomFolderDialog(
onAbort: () -> Unit, 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.DeleteRecordingsImmediatelyTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.DividerTitle 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.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.ImportExport
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.IntervalDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.IntervalDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.MaxDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.MaxDurationTile
@ -145,6 +146,7 @@ fun SettingsScreen(
DeleteRecordingsImmediatelyTile(settings = settings) DeleteRecordingsImmediatelyTile(settings = settings)
CustomNotificationTile(onNavigateToCustomRecordingNotifications, settings = settings) CustomNotificationTile(onNavigateToCustomRecordingNotifications, settings = settings)
EnableAppLockTile(settings = settings) EnableAppLockTile(settings = settings)
FilenameFormatTile(settings = settings, snackbarHostState = snackbarHostState)
SaveFolderTile( SaveFolderTile(
settings = settings, settings = settings,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,

View File

@ -224,4 +224,13 @@
<string name="ui_welcome_timeSettings_values_1min">1 Minute</string> <string name="ui_welcome_timeSettings_values_1min">1 Minute</string>
<string name="ui_error_occurred_title">There was an error</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_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> </resources>