mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 01:08:50 -06:00
Automatic public backup: Implementation of SAF for AutoBackup
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<File>, 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<File> {
|
||||
val files = ArrayList<File>()
|
||||
for (path in basedir.list()!!) {
|
||||
files.add(File("${basedir.path}/$path"))
|
||||
private fun removeOldestPrivate(files: List<File>, 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<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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
android:title="@string/import_data"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user