Merge pull request #29 from Myzel394/add-import-export

Add import export
This commit is contained in:
Myzel394 2023-10-22 18:06:51 +02:00 committed by GitHub
commit 4ba0c64f54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 312 additions and 25 deletions

View File

@ -5,8 +5,8 @@ import android.os.Build
import android.util.Log import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.json.JSONObject
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter.ISO_DATE_TIME import java.time.format.DateTimeFormatter.ISO_DATE_TIME
@ -40,8 +40,46 @@ data class AppSettings(
DARK, DARK,
} }
fun toJSONObject(): JSONObject {
return JSONObject(
mapOf(
"audioRecorderSettings" to audioRecorderSettings.toJSONObject(),
"hasSeenOnboarding" to hasSeenOnboarding,
"showAdvancedSettings" to showAdvancedSettings,
"theme" to theme.name,
)
)
}
fun exportToString(): String {
return JSONObject(
mapOf(
"_meta" to mapOf(
"version" to 1,
"date" to LocalDateTime.now().format(ISO_DATE_TIME),
"app" to "app.myzel394.alibi",
),
"data" to toJSONObject(),
)
).toString(0)
}
companion object { companion object {
fun getDefaultInstance(): AppSettings = AppSettings() fun getDefaultInstance(): AppSettings = AppSettings()
fun fromJSONObject(data: JSONObject): AppSettings {
return AppSettings(
audioRecorderSettings = AudioRecorderSettings.fromJSONObject(data.getJSONObject("audioRecorderSettings")),
hasSeenOnboarding = data.getBoolean("hasSeenOnboarding"),
showAdvancedSettings = data.getBoolean("showAdvancedSettings"),
theme = Theme.valueOf(data.getString("theme")),
)
}
fun fromExportedString(data: String): AppSettings {
val json = JSONObject(data)
return fromJSONObject(json.getJSONObject("data"))
}
} }
} }
@ -292,6 +330,20 @@ data class AudioRecorderSettings(
return supportedFormats.contains(outputFormat) return supportedFormats.contains(outputFormat)
} }
fun toJSONObject(): JSONObject {
return JSONObject(
mapOf(
"maxDuration" to maxDuration,
"intervalDuration" to intervalDuration,
"forceExactMaxDuration" to forceExactMaxDuration,
"bitRate" to bitRate,
"samplingRate" to samplingRate,
"outputFormat" to outputFormat,
"encoder" to encoder,
)
)
}
companion object { companion object {
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings() fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
val EXAMPLE_MAX_DURATIONS = listOf( val EXAMPLE_MAX_DURATIONS = listOf(
@ -398,5 +450,23 @@ data class AudioRecorderSettings(
} }
} }
}).toMap() }).toMap()
fun fromJSONObject(data: JSONObject): AudioRecorderSettings {
return AudioRecorderSettings(
maxDuration = data.getLong("maxDuration"),
intervalDuration = data.getLong("intervalDuration"),
forceExactMaxDuration = data.getBoolean("forceExactMaxDuration"),
bitRate = data.getInt("bitRate"),
samplingRate = data.optInt("samplingRate", -1).let {
if (it == -1) null else it
},
outputFormat = data.optInt("outputFormat", -1).let {
if (it == -1) null else it
},
encoder = data.optInt("encoder", -1).let {
if (it == -1) null else it
},
)
}
} }
} }

View File

@ -0,0 +1,165 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import app.myzel394.alibi.ui.utils.rememberFileSelectorDialog
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun ImportExport(
snackbarHostState: SnackbarHostState,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
var settingsToBeImported by remember { mutableStateOf<AppSettings?>(null) }
val saveFile = rememberFileSaverDialog("application/json")
val openFile = rememberFileSelectorDialog { uri ->
val file = File.createTempFile("alibi_settings", ".json")
context.contentResolver.openInputStream(uri)!!.use {
it.copyTo(file.outputStream())
}
val rawContent = file.readText()
settingsToBeImported = AppSettings.fromExportedString(rawContent)
}
if (settingsToBeImported != null) {
val successMessage = stringResource(R.string.ui_settings_option_import_success)
AlertDialog(
onDismissRequest = {
settingsToBeImported = null
},
title = {
Text(stringResource(R.string.ui_settings_option_import_label))
},
text = {
Text(stringResource(R.string.ui_settings_option_import_dialog_text))
},
icon = {
Icon(
Icons.Default.Download,
contentDescription = null,
)
},
confirmButton = {
Button(
onClick = {
scope.launch {
dataStore.updateData {
settingsToBeImported!!
}
settingsToBeImported = null
snackbarHostState.showSnackbar(
message = successMessage,
withDismissAction = true,
duration = SnackbarDuration.Short,
)
}
},
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_import_dialog_confirm))
}
},
dismissButton = {
Button(
onClick = {
settingsToBeImported = null
},
colors = ButtonDefaults.textButtonColors(),
) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
},
)
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Button(
onClick = {
openFile("application/json")
},
colors = ButtonDefaults.filledTonalButtonColors(),
) {
Icon(
Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_import_label))
}
Button(
onClick = {
val rawContent = settings.exportToString()
val tempFile = File.createTempFile("alibi_settings", ".json")
tempFile.writeText(rawContent)
saveFile(tempFile, "alibi_settings.json")
},
colors = ButtonDefaults.filledTonalButtonColors(),
) {
Icon(
Icons.Default.Upload,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_export_label))
}
}
}

View File

@ -64,7 +64,7 @@ fun AudioRecorder(
try { try {
val file = audioRecorder.lastRecording!!.concatenateFiles() val file = audioRecorder.lastRecording!!.concatenateFiles()
saveFile(file) saveFile(file, file.name)
} catch (error: Exception) { } catch (error: Exception) {
Log.getStackTraceString(error) Log.getStackTraceString(error)
} finally { } finally {
@ -165,7 +165,7 @@ fun AudioRecorder(
} }
) )
}, },
) {padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@ -1,6 +1,7 @@
package app.myzel394.alibi.ui.screens package app.myzel394.alibi.ui.screens
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
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
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -15,7 +16,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -39,6 +42,7 @@ import app.myzel394.alibi.ui.SUPPORTS_DARK_MODE_NATIVELY
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ImportExport
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.InAppLanguagePicker import app.myzel394.alibi.ui.components.SettingsScreen.atoms.InAppLanguagePicker
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.IntervalDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.IntervalDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile
@ -63,7 +67,21 @@ fun SettingsScreen(
) )
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
snackbar = {
Snackbar(
snackbarData = it,
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
dismissActionContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
)
},
topBar = { topBar = {
LargeTopAppBar( LargeTopAppBar(
title = { title = {
@ -128,16 +146,26 @@ fun SettingsScreen(
ForceExactMaxDurationTile() ForceExactMaxDurationTile()
InAppLanguagePicker() InAppLanguagePicker()
AnimatedVisibility(visible = settings.showAdvancedSettings) { AnimatedVisibility(visible = settings.showAdvancedSettings) {
Column { Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
Column {
Divider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp)
)
BitrateTile()
SamplingRateTile()
EncoderTile(snackbarHostState = snackbarHostState)
OutputFormatTile()
}
Divider( Divider(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.5f)
.padding(horizontal = 16.dp, vertical = 32.dp)
) )
BitrateTile() ImportExport(snackbarHostState = snackbarHostState)
SamplingRateTile()
EncoderTile(snackbarHostState = snackbarHostState)
OutputFormatTile()
} }
} }
} }

View File

@ -1,33 +1,53 @@
package app.myzel394.alibi.ui.utils package app.myzel394.alibi.ui.utils
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import java.io.File import java.io.File
@Composable @Composable
fun rememberFileSaverDialog(mimeType: String): ((File) -> Unit) { fun rememberFileSaverDialog(mimeType: String): ((File, String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
var file = remember { mutableStateOf<File?>(null) } var file = remember { mutableStateOf<File?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(mimeType)) { val launcher =
it?.let { rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(mimeType)) {
context.contentResolver.openOutputStream(it)?.use { outputStream -> it?.let {
file.value!!.inputStream().use { inputStream -> context.contentResolver.openOutputStream(it)?.use { outputStream ->
inputStream.copyTo(outputStream) file.value!!.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
} }
} }
file.value = null
}
return { it, name ->
file.value = it
launcher.launch(name ?: it.name)
}
}
@Composable
fun rememberFileSelectorDialog(
callback: (Uri) -> Unit
): ((String) -> Unit) {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
if (it != null) {
callback(it)
}
} }
file.value = null return { mimeType ->
} launcher.launch(arrayOf(mimeType))
return {
file.value = it
launcher.launch(it.name)
} }
} }

View File

@ -1,5 +1,4 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android" <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name">Alibi</string> <string name="app_name">Alibi</string>
<string name="dialog_close_cancel_label">Cancel</string> <string name="dialog_close_cancel_label">Cancel</string>
@ -64,4 +63,9 @@
<string name="ui_audioRecorder_error_recording_description">Alibi encountered an error during recording. Would you like to try saving the recording?</string> <string name="ui_audioRecorder_error_recording_description">Alibi encountered an error during recording. Would you like to try saving the recording?</string>
<string name="ui_settings_language_title">Language</string> <string name="ui_settings_language_title">Language</string>
<string name="ui_settings_language_update_label">Change</string> <string name="ui_settings_language_update_label">Change</string>
<string name="ui_settings_option_import_label">Import Settings</string>
<string name="ui_settings_option_export_label">Export Settings</string>
<string name="ui_settings_option_import_dialog_text">Are you sure you want to import these settings? Your current settings will be overwritten!</string>
<string name="ui_settings_option_import_dialog_confirm">Import settings</string>
<string name="ui_settings_option_import_success">Settings have been imported successfully!</string>
</resources> </resources>