package eu.kanade.presentation.more.settings.screen import android.Manifest import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.google.accompanist.permissions.rememberPermissionState import com.hippo.unifile.UniFile import eu.kanade.domain.backup.service.BackupPreferences import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.util.collectAsState import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class SettingsBackupScreen : SearchableSettings { @ReadOnlyComposable @Composable override fun getTitle(): String = stringResource(id = R.string.label_backup) @Composable override fun getPreferences(): List { val backupPreferences = Injekt.get() RequestStoragePermission() return listOf( getCreateBackupPref(), getRestoreBackupPref(), getAutomaticBackupGroup(backupPreferences = backupPreferences), ) } @Composable private fun RequestStoragePermission() { val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) LaunchedEffect(Unit) { permissionState.launchPermissionRequest() } } @Composable private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference { val scope = rememberCoroutineScope() val context = LocalContext.current var flag by rememberSaveable { mutableStateOf(0) } val chooseBackupDir = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/*"), ) { if (it != null) { context.contentResolver.takePersistableUriPermission( it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) BackupCreatorJob.startNow(context, it, flag) } flag = 0 } var showCreateDialog by rememberSaveable { mutableStateOf(false) } if (showCreateDialog) { CreateBackupDialog( onConfirm = { showCreateDialog = false flag = it chooseBackupDir.launch(Backup.getBackupFilename()) }, onDismissRequest = { showCreateDialog = false }, ) } return Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.pref_create_backup), subtitle = stringResource(id = R.string.pref_create_backup_summ), onClick = { scope.launch { if (!BackupCreatorJob.isManualJobRunning(context)) { if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG) } showCreateDialog = true } else { context.toast(R.string.backup_in_progress) } } }, ) } @Composable private fun CreateBackupDialog( onConfirm: (flag: Int) -> Unit, onDismissRequest: () -> Unit, ) { val flags = remember { mutableStateListOf() } AlertDialog( onDismissRequest = onDismissRequest, title = { Text(text = stringResource(id = R.string.backup_choice)) }, text = { val choices = remember { mapOf( BackupConst.BACKUP_CATEGORY to R.string.categories, BackupConst.BACKUP_CHAPTER to R.string.chapters, BackupConst.BACKUP_TRACK to R.string.track, BackupConst.BACKUP_HISTORY to R.string.history, ) } Column { CreateBackupDialogItem( isSelected = true, title = stringResource(id = R.string.manga), ) choices.forEach { (k, v) -> val isSelected = flags.contains(k) CreateBackupDialogItem( isSelected = isSelected, title = stringResource(id = v), modifier = Modifier.clickable { if (isSelected) { flags.remove(k) } else { flags.add(k) } }, ) } } }, dismissButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(id = android.R.string.cancel)) } }, confirmButton = { TextButton( onClick = { val flag = flags.fold(initial = 0, operation = { a, b -> a or b }) onConfirm(flag) }, ) { Text(text = stringResource(id = android.R.string.ok)) } }, ) } @Composable private fun CreateBackupDialogItem( modifier: Modifier = Modifier, isSelected: Boolean, title: String, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth(), ) { Checkbox( modifier = Modifier.heightIn(min = 48.dp), checked = isSelected, onCheckedChange = null, ) Text( text = title, style = MaterialTheme.typography.bodyMedium.merge(), modifier = Modifier.padding(start = 24.dp), ) } } @Composable private fun getRestoreBackupPref(): Preference.PreferenceItem.TextPreference { val context = LocalContext.current var error by remember { mutableStateOf(null) } if (error != null) { val onDismissRequest = { error = null } when (val err = error) { is InvalidRestore -> { val clipboard = LocalClipboardManager.current AlertDialog( onDismissRequest = onDismissRequest, title = { Text(text = stringResource(id = R.string.invalid_backup_file)) }, text = { Text(text = err.message) }, dismissButton = { TextButton( onClick = { clipboard.setText(AnnotatedString(err.message)) context.toast(R.string.copied_to_clipboard) onDismissRequest() }, ) { Text(text = stringResource(id = R.string.copy)) } }, confirmButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(id = android.R.string.ok)) } }, ) } is MissingRestoreComponents -> { AlertDialog( onDismissRequest = onDismissRequest, title = { Text(text = stringResource(id = R.string.pref_restore_backup)) }, text = { var msg = stringResource(id = R.string.backup_restore_content_full) if (err.sources.isNotEmpty()) { msg += "\n\n${stringResource(R.string.backup_restore_missing_sources)}\n${err.sources.joinToString("\n") { "- $it" }}" } if (err.sources.isNotEmpty()) { msg += "\n\n${stringResource(R.string.backup_restore_missing_trackers)}\n${err.trackers.joinToString("\n") { "- $it" }}" } Text(text = msg) }, confirmButton = { TextButton( onClick = { BackupRestoreService.start(context, err.uri) onDismissRequest() }, ) { Text(text = stringResource(id = R.string.action_restore)) } }, ) } else -> error = null // Unknown } } val chooseBackup = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { if (it != null) { val results = try { BackupFileValidator().validate(context, it) } catch (e: Exception) { error = InvalidRestore(e.message.toString()) return@rememberLauncherForActivityResult } if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) { BackupRestoreService.start(context, it) return@rememberLauncherForActivityResult } error = MissingRestoreComponents(it, results.missingSources, results.missingTrackers) } } return Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.pref_restore_backup), subtitle = stringResource(id = R.string.pref_restore_backup_summ), onClick = { if (!BackupRestoreService.isRunning(context)) { if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG) } chooseBackup.launch("*/*") } else { context.toast(R.string.restore_in_progress) } }, ) } @Composable fun getAutomaticBackupGroup( backupPreferences: BackupPreferences, ): Preference.PreferenceGroup { val context = LocalContext.current val backupDirPref = backupPreferences.backupsDirectory() val backupDir by backupDirPref.collectAsState() val pickBackupLocation = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree(), ) { uri -> if (uri != null) { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context.contentResolver.takePersistableUriPermission(uri, flags) val file = UniFile.fromUri(context, uri) backupDirPref.set(file.uri.toString()) } } return Preference.PreferenceGroup( title = stringResource(id = R.string.pref_backup_service_category), preferenceItems = listOf( Preference.PreferenceItem.ListPreference( pref = backupPreferences.backupInterval(), title = stringResource(id = R.string.pref_backup_interval), entries = mapOf( 6 to stringResource(id = R.string.update_6hour), 12 to stringResource(id = R.string.update_12hour), 24 to stringResource(id = R.string.update_24hour), 48 to stringResource(id = R.string.update_48hour), 168 to stringResource(id = R.string.update_weekly), ), onValueChanged = { BackupCreatorJob.setupTask(context, it) true }, ), Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.pref_backup_directory), subtitle = remember(backupDir) { UniFile.fromUri(context, backupDir.toUri()).filePath!! + "/automatic" }, onClick = { pickBackupLocation.launch(null) }, ), Preference.PreferenceItem.ListPreference( pref = backupPreferences.numberOfBackups(), title = stringResource(id = R.string.pref_backup_slots), entries = listOf(2, 3, 4, 5).associateWith { it.toString() }, ), Preference.infoPreference(stringResource(id = R.string.backup_info)), ), ) } } private data class MissingRestoreComponents( val uri: Uri, val sources: List, val trackers: List, ) data class InvalidRestore( val message: String, )