Automatic public backup: Implementation of SAF for AutoBackup

pull/2209/head
mihanentalpo 1 month ago
parent a9acbd6cab
commit 403d1058aa

@ -32,6 +32,7 @@ rules = "1.6.1"
shadow = "8.1.1" shadow = "8.1.1"
sqliteJdbc = "3.45.1.0" sqliteJdbc = "3.45.1.0"
uiautomator = "2.3.0" uiautomator = "2.3.0"
documentfile = "1.0.1"
[libraries] [libraries]
annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } 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" } rules = { group = "androidx.test", name = "rules", version.ref = "rules" }
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" }
uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
[bundles] [bundles]
androidTest = [ androidTest = [

@ -106,6 +106,7 @@ dependencies {
implementation(libs.legacy.preference.v14) implementation(libs.legacy.preference.v14)
implementation(libs.legacy.support.v4) implementation(libs.legacy.support.v4)
implementation(libs.material) implementation(libs.material)
implementation(libs.documentfile)
implementation(libs.opencsv) implementation(libs.opencsv)
implementation(libs.konfetti.xml) implementation(libs.konfetti.xml)
implementation(project(":uhabits-core")) implementation(project(":uhabits-core"))

@ -24,6 +24,8 @@ import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.View import android.view.View
@ -56,10 +58,21 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == RINGTONE_REQUEST_CODE) { when (requestCode) {
ringtoneManager!!.update(data) RINGTONE_REQUEST_CODE -> {
updateRingtoneDescription() ringtoneManager!!.update(data)
return 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) super.onActivityResult(requestCode, resultCode, data)
} }
@ -114,6 +127,16 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis
activity?.startActivitySafely(intent) activity?.startActivitySafely(intent)
return true 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) return super.onPreferenceTreeClick(preference)
} }
@ -128,6 +151,7 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis
devCategory.isVisible = false devCategory.isVisible = false
} }
updateWeekdayPreference() updateWeekdayPreference()
updatePublicBackupFolderSummary()
findPreference("reminderSound").isVisible = false findPreference("reminderSound").isVisible = false
} }
@ -192,7 +216,39 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis
ringtonePreference.summary = ringtoneName 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 { companion object {
private const val RINGTONE_REQUEST_CODE = 1 private const val RINGTONE_REQUEST_CODE = 1
private const val PUBLIC_BACKUP_REQUEST_CODE = 2
} }
} }

@ -20,7 +20,10 @@
package org.isoron.uhabits.database package org.isoron.uhabits.database
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import org.isoron.uhabits.AndroidDirFinder import org.isoron.uhabits.AndroidDirFinder
import org.isoron.uhabits.core.utils.DateUtils import org.isoron.uhabits.core.utils.DateUtils
import org.isoron.uhabits.utils.DatabaseUtils import org.isoron.uhabits.utils.DatabaseUtils
@ -28,37 +31,70 @@ import java.io.File
class AutoBackup(private val context: Context) { 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) { fun run(keep: Int = 5) {
Log.i("AutoBackup", "Starting automatic backups...") Log.i("AutoBackup", "Starting automatic backups...")
val files = listBackupFiles() val prefs = PreferenceManager.getDefaultSharedPreferences(context)
var newestTimestamp = 0L val uriString = prefs.getString("publicBackupFolder", null)
if (files.isNotEmpty()) { if (uriString != null) {
newestTimestamp = files.last().lastModified() 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() val now = DateUtils.getLocalTime()
removeOldest(files, keep)
if (now - newestTimestamp > DateUtils.DAY_LENGTH) { if (now - newestTimestamp > DateUtils.DAY_LENGTH) {
DatabaseUtils.saveDatabaseCopy(context, basedir) DatabaseUtils.saveDatabaseCopy(context, dir)
} else { } else {
Log.i("AutoBackup", "Fresh backup found (timestamp=$newestTimestamp)") Log.i("AutoBackup", "Fresh backup found (timestamp=$newestTimestamp)")
} }
} }
private fun removeOldest(files: ArrayList<File>, keep: Int) { private fun runInPublicDir(dir: DocumentFile, keep: Int) {
files.sortBy { -it.lastModified() } val files = dir.listFiles()
for (k in keep until files.size) { .filter { it.isFile && it.name?.matches(backupPattern) == true }
Log.i("AutoBackup", "Removing ${files[k]}") .sortedBy { it.lastModified() }
files[k].delete() 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<File> { private fun removeOldestPrivate(files: List<File>, keep: Int) {
val files = ArrayList<File>() for (k in 0 until (files.size - keep)) {
for (path in basedir.list()!!) { val file = files[k]
files.add(File("${basedir.path}/$path")) Log.i("AutoBackup", "Removing $file")
file.delete()
}
}
private fun removeOldestPublic(files: List<DocumentFile>, keep: Int) {
for (k in 0 until (files.size - keep)) {
val file = files[k]
Log.i("AutoBackup", "Removing ${file.uri}")
file.delete()
} }
return files
} }
} }

@ -19,10 +19,14 @@
package org.isoron.uhabits.tasks package org.isoron.uhabits.tasks
import android.content.Context 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.AndroidDirFinder
import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.inject.AppContext import org.isoron.uhabits.inject.AppContext
import org.isoron.uhabits.utils.DatabaseUtils.saveDatabaseCopy import org.isoron.uhabits.utils.DatabaseUtils.saveDatabaseCopy
import java.io.File
import java.io.IOException import java.io.IOException
class ExportDBTask( class ExportDBTask(
@ -34,8 +38,26 @@ class ExportDBTask(
override fun doInBackground() { override fun doInBackground() {
filename = null filename = null
filename = try { filename = try {
val dir = system.getFilesDir("Backups") ?: return val prefs = PreferenceManager.getDefaultSharedPreferences(context)
saveDatabaseCopy(context, dir) 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) { } catch (e: IOException) {
throw RuntimeException(e) throw RuntimeException(e)
} }

@ -21,6 +21,7 @@ package org.isoron.uhabits.utils
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.isoron.uhabits.HabitsApplication.Companion.isTestMode import org.isoron.uhabits.HabitsApplication.Companion.isTestMode
import org.isoron.uhabits.HabitsDatabaseOpener import org.isoron.uhabits.HabitsDatabaseOpener
import org.isoron.uhabits.core.DATABASE_FILENAME 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.DateFormats.Companion.getBackupDateFormat
import org.isoron.uhabits.core.utils.DateUtils.Companion.getLocalTime import org.isoron.uhabits.core.utils.DateUtils.Companion.getLocalTime
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.IOException import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -69,6 +71,23 @@ object DatabaseUtils {
return dbCopy.absolutePath 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 { fun openDatabase(): SQLiteDatabase {
checkNotNull(opener) checkNotNull(opener)
return opener!!.writableDatabase return opener!!.writableDatabase

@ -136,6 +136,12 @@
android:title="@string/import_data" android:title="@string/import_data"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<Preference
android:key="publicBackupFolder"
android:title="@string/select_public_backup_folder"
android:summary="@string/no_public_backup_folder_selected"
app:iconSpaceReserved="false" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory <PreferenceCategory

Loading…
Cancel
Save