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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user