Skip to content
Snippets Groups Projects
Commit f3f13a71 authored by Kelvin's avatar Kelvin
Browse files

New auto-backup storage using the Storage Access Framework, minor dialog...

New auto-backup storage using the Storage Access Framework, minor dialog tweaks, minor settings ui tweaks
parent 5155423a
No related branches found
No related tags found
No related merge requests found
......@@ -406,6 +406,33 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField("External Storage", FieldForm.GROUP, "", 12)
var storage = Storage();
@Serializable
class Storage {
var storage_general: String? = null;
var storage_download: String? = null;
fun getStorageGeneralUri(): Uri? = storage_general?.let { Uri.parse(it) };
fun getStorageDownloadUri(): Uri? = storage_download?.let { Uri.parse(it) };
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
@FormField("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3)
fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it);
}
}
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4)
fun changeStorageDownload() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
}
@FormField("Auto Update", "group", "Configure the auto updater", 12)
var autoUpdate = AutoUpdate();
@Serializable
......@@ -511,7 +538,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1)
fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!);
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings();
};
}
@FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2)
fun restoreAutomaticBackup() {
......@@ -542,6 +571,7 @@ class Settings : FragmentedStorageFileJson() {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Licenses cleared, might require app restart");
it.reloadSettings();
}
}
}
......
......@@ -15,7 +15,9 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.dialogs.*
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
......@@ -90,11 +92,25 @@ class UIDialogs {
}
fun showAutomaticBackupDialog(context: Context) {
val dialog = AutomaticBackupDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
val dialog = AutomaticBackupDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, "An old backup is available", "Would you like to restore this backup?", null, 0,
UIDialogs.Action("Cancel", {}), //To nothing
UIDialogs.Action("Override", {
dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action("Restore", {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY));
else {
dialogAction();
}
}
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope);
......@@ -134,10 +150,10 @@ class UIDialogs {
val buttonView = TextView(context);
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics);
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
if(actions.size > 1)
this.marginEnd = dp28;
this.marginEnd = if(actions.size > 2) dp14 else dp28;
};
buttonView.setTextColor(Color.WHITE);
buttonView.textSize = 14f;
......@@ -151,8 +167,9 @@ class UIDialogs {
ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red))
else -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary))
}
val paddingSpecialButtons = if(actions.size > 2) dp14 else dp28;
if(act.style != ActionStyle.NONE && act.style != ActionStyle.DANGEROUS_TEXT)
buttonView.setPadding(dp28, dp10, dp28, dp10);
buttonView.setPadding(paddingSpecialButtons, dp10, paddingSpecialButtons, dp10);
else
buttonView.setPadding(dp10, dp10, dp10, dp10);
......
......@@ -6,6 +6,7 @@ import android.content.Intent
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build
import android.os.Looper
import android.os.OperationCanceledException
......@@ -15,6 +16,7 @@ import android.view.WindowInsetsController
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformMultiClientPool
......@@ -75,6 +77,14 @@ fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPool
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
fun DocumentFile.getOutputStream(context: Context, using: ((OutputStream?)->Unit)? = null) = context.contentResolver.openOutputStream(this.uri);
fun DocumentFile.copyTo(context: Context, file: DocumentFile) = this.getInputStream(context).use { input -> file.getOutputStream(context)?.let { output -> input?.copyTo(output) } };
fun DocumentFile.readBytes(context: Context) = this.getInputStream(context).use { input -> input?.readBytes() };
fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.contentResolver.openOutputStream(this.uri)?.use {
it.write(byteArray);
it.flush();
};
fun loadBitmap(url: String): Bitmap {
try {
......
......@@ -11,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton
......@@ -58,13 +59,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
}
clearFocus();
dismiss();
Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
UIDialogs.toast(context, "AutoBackup enabled");
try {
StateBackup.startAutomaticBackup(true);
}
......
......@@ -52,10 +52,9 @@ import java.util.concurrent.TimeUnit
class StateApp {
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
/*
private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay");
fun getExternalRootDirectory(): File? {
if(!externalRootDirectory.exists()) {
val result = externalRootDirectory.mkdirs();
......@@ -65,6 +64,57 @@ class StateApp {
}
else
return externalRootDirectory;
}*/
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri))
return DocumentFile.fromTreeUri(context, generalUri!!);
return null;
}
fun changeExternalGeneralDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
if(context is Context)
requestDirectoryAccess(context, "General Files", "This directory is used to save auto-backups and other persistent files.", null) {
if(it != null)
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external general directory: ${it}");
Settings.instance.storage.storage_general = it.toString();
Settings.instance.save();
onChanged?.invoke(getExternalGeneralDirectory(context));
}
else
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Failed to gain access to\n [${it?.lastPathSegment}]");
};
};
}
fun getExternalDownloadDirectory(context: Context): DocumentFile? {
val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) };
if(isValidStorageUri(context, downloadUri))
return DocumentFile.fromTreeUri(context, downloadUri!!);
return null;
}
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
if(context is Context)
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
if(it != null)
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION.or(Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)));
if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external download directory: ${it}");
Settings.instance.storage.storage_general = it.toString();
Settings.instance.save();
onChanged?.invoke(getExternalDownloadDirectory(context));
}
};
}
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
if(uri == null)
return false;
return context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission };
}
//Scope
......@@ -171,20 +221,20 @@ class StateApp {
return state;
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, path: Uri?, handle: (Uri?)->Unit)
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
{
if(activity is Context)
{
UIDialogs.showDialog(activity, R.drawable.ic_security, "Missing Access", "Please grant access to ${name}", null, 0,
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.and(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.and(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.and(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
......@@ -377,16 +427,32 @@ class StateApp {
val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes();
scheduleBackgroundWork(context, interval != 0, interval);
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
UIDialogs.showAutomaticBackupDialog(context);
StateAnnouncement.instance.deleteAnnouncement("backup");
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
UIDialogs.toast("Missing general directory");
changeExternalGeneralDirectory(context) {
UIDialogs.showAutomaticBackupDialog(context);
StateAnnouncement.instance.deleteAnnouncement("backup");
};
}
else {
UIDialogs.showAutomaticBackupDialog(context);
StateAnnouncement.instance.deleteAnnouncement("backup");
}
}, "No Backup", {
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
});
}
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
if(context is IWithResultLauncher) {
Logger.i(TAG, "Backup set without general directory, please select general external directory");
changeExternalGeneralDirectory(context) {
Logger.i(TAG, "Directory set, Auto-backup should resume to this location");
};
}
}
instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
......
......@@ -17,11 +17,17 @@ import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.getInputStream
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.getOutputStream
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.writeBytes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
......@@ -47,20 +53,22 @@ class StateBackup {
private val _autoBackupLock = Object();
private fun getAutomaticBackupDocumentFiles(context: Context, root: Uri, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
val dir = DocumentFile.fromTreeUri(context, root);
if(dir == null)
throw IllegalStateException("Can't access external document files");
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
if(!Settings.instance.storage.isStorageMainValid(context))
return Pair(null, null);
val uri = Settings.instance.storage.getStorageGeneralUri() ?: return Pair(null, null);
val dir = DocumentFile.fromTreeUri(context, uri) ?: return Pair(null, null);
val mainBackupFile = dir.findFile("GrayjayBackup.ezip") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip") else null;
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
return Pair(mainBackupFile, secondaryBackupFile);
}
/*
private fun getAutomaticBackupFiles(): Pair<File, File> {
val dir = StateApp.instance.getExternalRootDirectory();
if(dir == null)
throw IllegalStateException("Can't access external files");
return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old"))
}
}*/
fun getAllMigrationStores(): List<ManagedStore<*>> = listOf(
......@@ -77,10 +85,11 @@ class StateBackup {
return password.padStart(32, '9');
}
fun hasAutomaticBackup(): Boolean {
if(StateApp.instance.getExternalRootDirectory() == null)
val context = StateApp.instance.contextOrNull ?: return false;
if(!Settings.instance.storage.isStorageMainValid(context))
return false;
val files = getAutomaticBackupFiles();
return files.first.exists() || files.second.exists();
val files = getAutomaticBackupDocumentFiles(context,);
return files.first?.exists() ?: false || files.second?.exists() ?: false;
}
fun startAutomaticBackup(force: Boolean = false) {
val lastBackupHoursAgo = Settings.instance.backup.lastAutoBackupTime.getNowDiffHours();
......@@ -93,20 +102,27 @@ class StateBackup {
try {
Logger.i(TAG, "Starting AutoBackup (Last ${lastBackupHoursAgo} ago)");
synchronized(_autoBackupLock) {
val context = StateApp.instance.contextOrNull ?: return@synchronized;
val data = export();
val zip = data.asZip();
val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
val backupFiles = getAutomaticBackupFiles();
val exportFile = backupFiles.first;
if (exportFile.exists())
exportFile.copyTo(backupFiles.second, true);
exportFile.writeBytes(encryptedZip);
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
Settings.instance.save();
if(!Settings.instance.storage.isStorageMainValid(context)) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
}
}
else {
val backupFiles = getAutomaticBackupDocumentFiles(context, true);
val exportFile = backupFiles.first;
if (exportFile?.exists() == true && backupFiles.second != null)
exportFile!!.copyTo(context, backupFiles.second!!);
exportFile!!.writeBytes(context, encryptedZip);
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
Settings.instance.save();
}
}
Logger.i(TAG, "Finished AutoBackup");
}
......@@ -119,28 +135,22 @@ class StateBackup {
}
}
//TODO: This contains a temporary workaround to make it semi-compatible with > Android 11. By mixing "File" and "DocumentFile" usage.
//TODO: For now this is used to at least recover and gain temporary access to docs after losing access (due to permission lost after reinstall)
//TODO: Should be replaced with a more re-usable system that leverages OPEN_DOCUMENT_TREE once, and somehow persist this content after uninstall
//TODO: DocumentFiles are not compatible with normal files and require its own system.
//TODO: Investigate persistence of DOCUMENT_TREE files after uninstall...
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false, withStream: InputStream? = null) {
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
if(ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring");
return;
}
//TODO: Sadly on reinstalls of app this fails on file permissions.
Logger.i(TAG, "Starting AutoBackup restore");
synchronized(_autoBackupLock) {
val backupFiles = getAutomaticBackupFiles();
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
try {
if (!backupFiles.first.exists() && withStream == null)
if (backupFiles.first?.exists() != true)
throw IllegalStateException("Backup file does not exist");
val backupBytesEncrypted = if(withStream != null) withStream.readBytes() else backupFiles.first.readBytes();
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
importZipBytes(context, scope, backupBytes);
Logger.i(TAG, "Finished AutoBackup restore");
......@@ -154,21 +164,21 @@ class StateBackup {
else null;
if(activity != null) {
if(activity is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", backupFiles.first.parent?.toUri()) {
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
if(it != null) {
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity, it);
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
restoreAutomaticBackup(context, scope, password, ifExists, activity.contentResolver.openInputStream(customFiles.first!!.uri));
restoreAutomaticBackup(context, scope, password, ifExists);
}
};
}
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed main AutoBackup restore", ex)
if (!backupFiles.second.exists())
if (backupFiles.second?.exists() != true)
throw ex;
val backupBytesEncrypted = backupFiles.second.readBytes();
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
importZipBytes(context, scope, backupBytes);
Logger.i(TAG, "Finished AutoBackup restore");
......
......@@ -66,6 +66,7 @@
android:orientation="horizontal"
android:gravity="end"
android:layout_marginTop="28dp"
android:layout_marginBottom="28dp" />
android:layout_marginBottom="28dp">
</LinearLayout>
</LinearLayout>
</LinearLayout>
\ No newline at end of file
......@@ -11,7 +11,7 @@
android:id="@+id/field_group_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16dp"
android:textSize="20dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
android:text="@string/defaults"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment