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 com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import org.json.JSONObject
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
@ -40,8 +40,46 @@ data class AppSettings(
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 {
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)
}
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 {
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
val EXAMPLE_MAX_DURATIONS = listOf(
@ -398,5 +450,23 @@ data class AudioRecorderSettings(
}
}
}).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 {
val file = audioRecorder.lastRecording!!.concatenateFiles()
saveFile(file)
saveFile(file, file.name)
} catch (error: Exception) {
Log.getStackTraceString(error)
} finally {
@ -165,7 +165,7 @@ fun AudioRecorder(
}
)
},
) {padding ->
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()

View File

@ -1,6 +1,7 @@
package app.myzel394.alibi.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@ -15,7 +16,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
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.EncoderTile
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.IntervalDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile
@ -63,7 +67,21 @@ fun SettingsScreen(
)
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 = {
LargeTopAppBar(
title = {
@ -128,16 +146,26 @@ fun SettingsScreen(
ForceExactMaxDurationTile()
InAppLanguagePicker()
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(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp)
.fillMaxWidth(0.5f)
)
BitrateTile()
SamplingRateTile()
EncoderTile(snackbarHostState = snackbarHostState)
OutputFormatTile()
ImportExport(snackbarHostState = snackbarHostState)
}
}
}

View File

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

View File

@ -1,5 +1,4 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name">Alibi</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_settings_language_title">Language</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>