Skip to content

Commit

Permalink
feat: auto backup naming scheme preference (closes #404)
Browse files Browse the repository at this point in the history
  • Loading branch information
Bnyro committed Oct 20, 2024
1 parent 122798d commit 9c697b3
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 77 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ fun ContactsPage(
}

1 -> {
exportVcard.launch(BackupHelper.backupFileName)
exportVcard.launch(BackupHelper.defaultBackupFileName)
}

2 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.bnyro.contacts.R
import com.bnyro.contacts.domain.enums.BackupType
import com.bnyro.contacts.util.BackupHelper
import com.bnyro.contacts.util.PickFolderContract
import com.bnyro.contacts.util.Preferences
import com.bnyro.contacts.util.workers.BackupWorker
Expand Down Expand Up @@ -44,6 +45,14 @@ fun AutoBackupPref() {
) {
backupType = BackupType.fromInt(it)
}

EditTextPreference(
preferenceKey = Preferences.backupNamingSchemeKey,
title = R.string.backup_naming_scheme,
supportingHint = stringResource(R.string.backup_naming_scheme_hint),
defaultValue = BackupHelper.defaultBackupNamingScheme
)

val backupIntervals = listOf(1, 2, 4, 6, 12, 24, 48)
ListPreference(
preferenceKey = Preferences.backupIntervalKey,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.bnyro.contacts.presentation.screens.settings.components

import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bnyro.contacts.R
import com.bnyro.contacts.presentation.components.ClickableIcon
import com.bnyro.contacts.presentation.features.DialogButton
import com.bnyro.contacts.util.Preferences

@Composable
fun EditTextPreference(
preferenceKey: String,
@StringRes title: Int,
defaultValue: String,
isPassword: Boolean = false,
supportingHint: String? = null,
onChange: (value: String) -> Unit = {}
) {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
var preferenceValue by remember {
mutableStateOf(Preferences.getString(preferenceKey, defaultValue) ?: defaultValue)
}

Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
if (!isPassword) Text(text = stringResource(title), fontSize = 16.sp)
Button(
onClick = { showDialog = true }, modifier = Modifier.padding(vertical = 0.dp)
) {
if (!isPassword) Text(preferenceValue.ifEmpty { defaultValue }) else Text(stringResource(title))
}
}

if (showDialog) {
var passwordVisible by remember { mutableStateOf(false) }
var inputValue by remember {
mutableStateOf(preferenceValue)
}

AlertDialog(
onDismissRequest = { showDialog = false },
confirmButton = {
DialogButton(text = stringResource(R.string.okay)) {
Preferences.edit { putString(preferenceKey, inputValue) }
preferenceValue = inputValue
showDialog = false
}
}, dismissButton = {
DialogButton(text = stringResource(R.string.cancel)) {
showDialog = false
}
}, title = {
Text(stringResource(title))
}, text = {
OutlinedTextField(
value = inputValue,
onValueChange = { inputValue = it },
visualTransformation = if (passwordVisible || !isPassword) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
supportingText = {
if (supportingHint != null) Text(supportingHint)
},
keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Text),
trailingIcon = {
if (isPassword) {
val image = if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
}
ClickableIcon(icon = image,
onClick = { passwordVisible = !passwordVisible })
}
}
)
}
)
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,20 @@
package com.bnyro.contacts.presentation.screens.settings.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.bnyro.contacts.R
import com.bnyro.contacts.presentation.components.ClickableIcon
import com.bnyro.contacts.presentation.features.DialogButton
import com.bnyro.contacts.util.BackupHelper
import com.bnyro.contacts.util.PasswordUtils
import com.bnyro.contacts.util.Preferences

@Composable
fun EncryptBackupsPref() {
var encryptBackups by remember { mutableStateOf(BackupHelper.encryptBackups) }
var showBackupPasswordDialog by remember { mutableStateOf(false) }

SwitchPref(
prefKey = Preferences.encryptBackupsKey,
Expand All @@ -44,61 +27,13 @@ fun EncryptBackupsPref() {
}
}
}
AnimatedVisibility(encryptBackups) {
Button(
onClick = { showBackupPasswordDialog = true },
modifier = Modifier.padding(vertical = 0.dp)
) {
Text(stringResource(R.string.backup_password))
}
}

if (showBackupPasswordDialog) {
var password by remember {
mutableStateOf(
Preferences.getString(Preferences.encryptBackupPasswordKey, "").orEmpty()
)
}
var passwordVisible by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showBackupPasswordDialog = false },
confirmButton = {
DialogButton(text = stringResource(R.string.okay)) {
Preferences.edit { putString(Preferences.encryptBackupPasswordKey, password) }
showBackupPasswordDialog = false
}
},
dismissButton = {
DialogButton(text = stringResource(R.string.cancel)) {
showBackupPasswordDialog = false
}
},
title = {
Text(stringResource(R.string.backup_password))
},
text = {
OutlinedTextField(
value = password,
onValueChange = { password = it },
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
}
ClickableIcon(
icon = image,
onClick = { passwordVisible = !passwordVisible }
)
}
)
}
AnimatedVisibility(encryptBackups) {
EditTextPreference(
preferenceKey = Preferences.encryptBackupPasswordKey,
title = R.string.backup_password,
isPassword = true,
defaultValue = "",
)
}
}
24 changes: 19 additions & 5 deletions app/src/main/java/com/bnyro/contacts/util/BackupHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ object BackupHelper {
val encryptBackups get() = Preferences.getBoolean(Preferences.encryptBackupsKey, false)
val mimeType get() = if (encryptBackups) "application/zip" else "text/vcard"
val openMimeTypes get() = if (encryptBackups) arrayOf("application/zip") else vCardMimeTypes
val backupFileName get() = if (encryptBackups) "contacts.zip" else "contacts.vcf"
val defaultBackupFileName get() = if (encryptBackups) "contacts.zip" else "contacts.vcf"
const val defaultBackupNamingScheme = "%s-backup-%d-%t"

suspend fun backup(context: Context, contactsRepository: ContactsRepository) {
val backupDirPref = Preferences.getString(Preferences.backupDirKey, "").takeIf {
Expand All @@ -21,10 +22,23 @@ object BackupHelper {
val backupDir = DocumentFile.fromTreeUri(context, Uri.parse(backupDirPref)) ?: return
val maxBackupAmount = Preferences.getString(Preferences.maxBackupAmountKey, "5")!!.toInt()

val dateTime = CalendarUtils.getCurrentDateTime()
var backupNamingScheme =
Preferences.getString(Preferences.backupNamingSchemeKey, defaultBackupNamingScheme)
?: defaultBackupNamingScheme

// these are required for uniqueness and automatic backup deletion
if (!backupNamingScheme.contains("%s")
|| !(backupNamingScheme.contains("%d") || backupNamingScheme.contains("%t"))
) backupNamingScheme = defaultBackupNamingScheme

val backupType = contactsRepository.label.lowercase()
val dateTime = CalendarUtils.getCurrentDateTime()
val (date, time) = dateTime.substring(0, 10) to dateTime.substring(10)
val extension = if (encryptBackups) "zip" else "vcf"
val fullName = "${contactsRepository.label.lowercase()}-backup-$dateTime.$extension"
val fileName = backupNamingScheme.replace("%s", backupType)
.replace("%d", date)
.replace("%t", time)
val fullName = "${fileName}.$extension"

runCatching {
backupDir.findFile(fullName)?.delete()
Expand All @@ -41,11 +55,11 @@ object BackupHelper {

// delete all the old backup files
val backupFiles = backupDir.listFiles().filter {
it.name.orEmpty().startsWith(contactsRepository.label.lowercase())
it.name.orEmpty().contains(backupType)
}
if (backupFiles.size <= maxBackupAmount) return

backupFiles.sortedBy { it.name.orEmpty() }
backupFiles.sortedBy { it.lastModified() }
.take(backupFiles.size - maxBackupAmount)
.forEach { it.delete() }
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/bnyro/contacts/util/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ object Preferences {
const val themeKey = "theme"
const val backupDirKey = "backupDir"
const val backupTypeKey = "backupType"
const val backupNamingSchemeKey = "backupNamingScheme"
const val sortOrderKey = "sorting"
const val hiddenAccountsKey = "hiddenAccounts"
const val backupIntervalKey = "backupInterval"
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@
<string name="dark">Dark</string>
<string name="auto_backup">Automatic backup</string>
<string name="backup_interval">Backup interval</string>
<string name="backup_naming_scheme">Backup naming scheme</string>
<string name="backup_naming_scheme_hint">%d: date, %t: time, %s: source (local, device), source is required and either date or time</string>
<string name="max_backup_amount">Max amount of backups</string>
<string name="choose_dir">Directory</string>
<string name="both">Both</string>
Expand Down

0 comments on commit 9c697b3

Please sign in to comment.