From 403d1058aad688104bd9aa74b1f5d6703495698c Mon Sep 17 00:00:00 2001 From: mihanentalpo Date: Sun, 24 Aug 2025 21:55:58 +0700 Subject: [PATCH] Automatic public backup: Implementation of SAF for AutoBackup --- gradle/libs.versions.toml | 2 + uhabits-android/build.gradle.kts | 1 + .../activities/settings/SettingsFragment.kt | 64 +++++++++++++++-- .../org/isoron/uhabits/database/AutoBackup.kt | 70 ++++++++++++++----- .../org/isoron/uhabits/tasks/ExportDBTask.kt | 26 ++++++- .../org/isoron/uhabits/utils/DatabaseUtils.kt | 19 +++++ .../src/main/res/xml/preferences.xml | 6 ++ 7 files changed, 165 insertions(+), 23 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index edec855a5..783e87003 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ rules = "1.6.1" shadow = "8.1.1" sqliteJdbc = "3.45.1.0" uiautomator = "2.3.0" +documentfile = "1.0.1" [libraries] annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } @@ -73,6 +74,7 @@ opencsv = { group = "com.opencsv", name = "opencsv", version.ref = "opencsv" } rules = { group = "androidx.test", name = "rules", version.ref = "rules" } sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } [bundles] androidTest = [ diff --git a/uhabits-android/build.gradle.kts b/uhabits-android/build.gradle.kts index bffb150bb..4d1aa7d26 100644 --- a/uhabits-android/build.gradle.kts +++ b/uhabits-android/build.gradle.kts @@ -106,6 +106,7 @@ dependencies { implementation(libs.legacy.preference.v14) implementation(libs.legacy.support.v4) implementation(libs.material) + implementation(libs.documentfile) implementation(libs.opencsv) implementation(libs.konfetti.xml) implementation(project(":uhabits-core")) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt index 0fc1f43c8..457925099 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt @@ -24,6 +24,8 @@ import android.content.SharedPreferences import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.net.Uri import android.os.Bundle +import android.os.Environment +import android.provider.DocumentsContract import android.provider.Settings import android.util.Log import android.view.View @@ -56,10 +58,21 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == RINGTONE_REQUEST_CODE) { - ringtoneManager!!.update(data) - updateRingtoneDescription() - return + when (requestCode) { + RINGTONE_REQUEST_CODE -> { + ringtoneManager!!.update(data) + updateRingtoneDescription() + return + } + PUBLIC_BACKUP_REQUEST_CODE -> { + val uri = data?.data ?: return + val flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + requireContext().contentResolver.takePersistableUriPermission(uri, flags) + sharedPrefs?.edit()?.putString("publicBackupFolder", uri.toString())?.apply() + updatePublicBackupFolderSummary() + return + } } super.onActivityResult(requestCode, resultCode, data) } @@ -114,6 +127,16 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis activity?.startActivitySafely(intent) return true } + "publicBackupFolder" -> { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + startActivityForResult(intent, PUBLIC_BACKUP_REQUEST_CODE) + return true + } } return super.onPreferenceTreeClick(preference) } @@ -128,6 +151,7 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis devCategory.isVisible = false } updateWeekdayPreference() + updatePublicBackupFolderSummary() findPreference("reminderSound").isVisible = false } @@ -192,7 +216,39 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis ringtonePreference.summary = ringtoneName } + private fun updatePublicBackupFolderSummary() { + val pref = findPreference("publicBackupFolder") + val uriString = sharedPrefs?.getString("publicBackupFolder", null) + if (uriString == null) { + pref.summary = getString(R.string.no_public_backup_folder_selected) + return + } + val uri = Uri.parse(uriString) + val path = fullPathFor(uri) + pref.summary = path ?: uriString + } + + private fun fullPathFor(uri: Uri): String? { + return when (uri.scheme) { + "content" -> { + val docId = DocumentsContract.getTreeDocumentId(uri) + val (type, rel) = docId.split(":", limit = 2).let { + it[0] to it.getOrElse(1) { "" } + } + val base = if (type.equals("primary", true)) { + Environment.getExternalStorageDirectory().absolutePath + } else { + "/storage/$type" + } + if (rel.isEmpty()) base else "$base/$rel" + } + "file" -> java.io.File(uri.path!!).absolutePath + else -> null + } + } + companion object { private const val RINGTONE_REQUEST_CODE = 1 + private const val PUBLIC_BACKUP_REQUEST_CODE = 2 } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/database/AutoBackup.kt b/uhabits-android/src/main/java/org/isoron/uhabits/database/AutoBackup.kt index 03480e910..0f3e7cbec 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/database/AutoBackup.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/database/AutoBackup.kt @@ -20,7 +20,10 @@ package org.isoron.uhabits.database import android.content.Context +import android.net.Uri import android.util.Log +import androidx.documentfile.provider.DocumentFile +import androidx.preference.PreferenceManager import org.isoron.uhabits.AndroidDirFinder import org.isoron.uhabits.core.utils.DateUtils import org.isoron.uhabits.utils.DatabaseUtils @@ -28,37 +31,70 @@ import java.io.File class AutoBackup(private val context: Context) { - private val basedir = AndroidDirFinder(context).getFilesDir("Backups")!! + private val backupPattern = Regex("^Loop Habits Backup .+\\.db$") fun run(keep: Int = 5) { Log.i("AutoBackup", "Starting automatic backups...") - val files = listBackupFiles() - var newestTimestamp = 0L - if (files.isNotEmpty()) { - newestTimestamp = files.last().lastModified() + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val uriString = prefs.getString("publicBackupFolder", null) + if (uriString != null) { + val uri = Uri.parse(uriString) + val dir = if (uri.scheme == "content") { + DocumentFile.fromTreeUri(context, uri) + } else { + DocumentFile.fromFile(File(uri.path!!)) + } + if (dir != null) { + runInPublicDir(dir, keep) + return + } } + + val basedir = AndroidDirFinder(context).getFilesDir("Backups") ?: return + runInPrivateDir(basedir, keep) + } + + private fun runInPrivateDir(dir: File, keep: Int) { + val files = dir.listFiles()?.toMutableList() ?: mutableListOf() + files.sortBy { it.lastModified() } + val newestTimestamp = files.lastOrNull()?.lastModified() ?: 0L + removeOldestPrivate(files, keep) val now = DateUtils.getLocalTime() - removeOldest(files, keep) if (now - newestTimestamp > DateUtils.DAY_LENGTH) { - DatabaseUtils.saveDatabaseCopy(context, basedir) + DatabaseUtils.saveDatabaseCopy(context, dir) } else { Log.i("AutoBackup", "Fresh backup found (timestamp=$newestTimestamp)") } } - private fun removeOldest(files: ArrayList, keep: Int) { - files.sortBy { -it.lastModified() } - for (k in keep until files.size) { - Log.i("AutoBackup", "Removing ${files[k]}") - files[k].delete() + private fun runInPublicDir(dir: DocumentFile, keep: Int) { + val files = dir.listFiles() + .filter { it.isFile && it.name?.matches(backupPattern) == true } + .sortedBy { it.lastModified() } + val newestTimestamp = files.lastOrNull()?.lastModified() ?: 0L + removeOldestPublic(files, keep) + val now = DateUtils.getLocalTime() + if (now - newestTimestamp > DateUtils.DAY_LENGTH) { + DatabaseUtils.saveDatabaseCopy(context, dir) + } else { + Log.i("AutoBackup", "Fresh backup found (timestamp=$newestTimestamp)") } } - private fun listBackupFiles(): ArrayList { - val files = ArrayList() - for (path in basedir.list()!!) { - files.add(File("${basedir.path}/$path")) + private fun removeOldestPrivate(files: List, keep: Int) { + for (k in 0 until (files.size - keep)) { + val file = files[k] + Log.i("AutoBackup", "Removing $file") + file.delete() + } + } + + private fun removeOldestPublic(files: List, keep: Int) { + for (k in 0 until (files.size - keep)) { + val file = files[k] + Log.i("AutoBackup", "Removing ${file.uri}") + file.delete() } - return files } } + diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ExportDBTask.kt b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ExportDBTask.kt index 45db42a76..c4385a0b3 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ExportDBTask.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ExportDBTask.kt @@ -19,10 +19,14 @@ package org.isoron.uhabits.tasks import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.preference.PreferenceManager import org.isoron.uhabits.AndroidDirFinder import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.inject.AppContext import org.isoron.uhabits.utils.DatabaseUtils.saveDatabaseCopy +import java.io.File import java.io.IOException class ExportDBTask( @@ -34,8 +38,26 @@ class ExportDBTask( override fun doInBackground() { filename = null filename = try { - val dir = system.getFilesDir("Backups") ?: return - saveDatabaseCopy(context, dir) + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val uriString = prefs.getString("publicBackupFolder", null) + // if public backup folder is selected, use it for backup + if (uriString != null) { + val uri = Uri.parse(uriString) + val dir = if (uri.scheme == "content") { + DocumentFile.fromTreeUri(context, uri) + } else { + DocumentFile.fromFile(File(uri.path!!)) + } + if (dir != null) { + saveDatabaseCopy(context, dir) + } else { + null + } + // if public backup folder is unset, use default system folder to backup + } else { + val dir = system.getFilesDir("Backups") ?: return + saveDatabaseCopy(context, dir) + } } catch (e: IOException) { throw RuntimeException(e) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.kt b/uhabits-android/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.kt index 81ea121db..09a59a2e7 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.kt @@ -21,6 +21,7 @@ package org.isoron.uhabits.utils import android.content.Context import android.database.sqlite.SQLiteDatabase import android.util.Log +import androidx.documentfile.provider.DocumentFile import org.isoron.uhabits.HabitsApplication.Companion.isTestMode import org.isoron.uhabits.HabitsDatabaseOpener import org.isoron.uhabits.core.DATABASE_FILENAME @@ -28,6 +29,7 @@ import org.isoron.uhabits.core.DATABASE_VERSION import org.isoron.uhabits.core.utils.DateFormats.Companion.getBackupDateFormat import org.isoron.uhabits.core.utils.DateUtils.Companion.getLocalTime import java.io.File +import java.io.FileInputStream import java.io.IOException import java.text.SimpleDateFormat @@ -69,6 +71,23 @@ object DatabaseUtils { return dbCopy.absolutePath } + @JvmStatic + @Throws(IOException::class) + fun saveDatabaseCopy(context: Context, dir: DocumentFile): String { + val dateFormat: SimpleDateFormat = getBackupDateFormat() + val date = dateFormat.format(getLocalTime()) + val file = dir.createFile("application/octet-stream", "Loop Habits Backup $date.db") + ?: throw IOException("Unable to create backup file") + Log.i("DatabaseUtils", "Writing: ${file.uri}") + val db = getDatabaseFile(context) + FileInputStream(db).use { input -> + context.contentResolver.openOutputStream(file.uri)?.use { output -> + input.copyTo(output) + } + } + return file.uri.toString() + } + fun openDatabase(): SQLiteDatabase { checkNotNull(opener) return opener!!.writableDatabase diff --git a/uhabits-android/src/main/res/xml/preferences.xml b/uhabits-android/src/main/res/xml/preferences.xml index 6072e711e..9c8180504 100644 --- a/uhabits-android/src/main/res/xml/preferences.xml +++ b/uhabits-android/src/main/res/xml/preferences.xml @@ -136,6 +136,12 @@ android:title="@string/import_data" app:iconSpaceReserved="false" /> + +