diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index edec855a5..108ee1229 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" +work = "2.9.0" [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" } +work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } [bundles] androidTest = [ diff --git a/uhabits-android/build.gradle.kts b/uhabits-android/build.gradle.kts index bffb150bb..53c529ab7 100644 --- a/uhabits-android/build.gradle.kts +++ b/uhabits-android/build.gradle.kts @@ -108,6 +108,7 @@ dependencies { implementation(libs.material) implementation(libs.opencsv) implementation(libs.konfetti.xml) + implementation(libs.work.runtime.ktx) implementation(project(":uhabits-core")) ksp(libs.dagger.compiler) 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..9a374bc77 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 @@ -27,10 +27,16 @@ import android.os.Bundle import android.provider.Settings import android.util.Log import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.R import org.isoron.uhabits.activities.habits.list.RESULT_BUG_REPORT @@ -41,18 +47,32 @@ import org.isoron.uhabits.activities.habits.list.RESULT_REPAIR_DB import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.ui.NotificationTray import org.isoron.uhabits.core.utils.DateUtils.Companion.getLongWeekdayNames +import org.isoron.uhabits.database.PublicBackupWorker import org.isoron.uhabits.notifications.AndroidNotificationTray.Companion.createAndroidNotificationChannel import org.isoron.uhabits.notifications.RingtoneManager import org.isoron.uhabits.utils.StyledResources +import org.isoron.uhabits.utils.UriUtils import org.isoron.uhabits.utils.startActivitySafely import org.isoron.uhabits.widgets.WidgetUpdater import java.util.Calendar +import java.util.concurrent.TimeUnit class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeListener { private var sharedPrefs: SharedPreferences? = null private var ringtoneManager: RingtoneManager? = null private lateinit var prefs: Preferences private var widgetUpdater: WidgetUpdater? = null + private val selectFolderLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + val flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + requireContext().contentResolver.takePersistableUriPermission(uri, flags) + prefs.publicBackupUri = uri.toString() + val pref = findPreference("publicBackupFolder") + pref?.summary = UriUtils.getPathFromTreeUri(requireContext(), uri) + } + } @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -114,6 +134,10 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis activity?.startActivitySafely(intent) return true } + "publicBackupFolder" -> { + selectFolderLauncher.launch(null) + return true + } } return super.onPreferenceTreeClick(preference) } @@ -130,6 +154,17 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis updateWeekdayPreference() findPreference("reminderSound").isVisible = false + + val folderPref = findPreference("publicBackupFolder") + val uriStr = prefs.publicBackupUri + folderPref?.summary = + if (uriStr == null) { + getString(R.string.pref_public_backup_folder_summary) + } else { + UriUtils.getPathFromTreeUri(requireContext(), Uri.parse(uriStr)) + } + + scheduleAutoBackup(prefs.isPublicAutoBackupEnabled) } private fun updateWeekdayPreference() { @@ -151,6 +186,24 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis Log.d("SettingsFragment", "updating widgets") widgetUpdater!!.updateWidgets() } + if (key == "pref_public_auto_backup") { + val enabled = sharedPreferences.getBoolean("pref_public_auto_backup", false) + if (enabled && prefs.publicBackupUri == null) { + val pref = findPreference("pref_public_auto_backup") as SwitchPreferenceCompat? + pref?.isChecked = false + Toast.makeText( + context, + getString(R.string.pref_public_backup_folder_summary), + Toast.LENGTH_SHORT + ).show() + return + } + scheduleAutoBackup(enabled) + } + if (key == "pref_public_backup_frequency") { + val enabled = sharedPreferences.getBoolean("pref_public_auto_backup", false) + scheduleAutoBackup(enabled) + } BackupManager.dataChanged("org.isoron.uhabits") updateWeekdayPreference() } @@ -192,6 +245,22 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis ringtonePreference.summary = ringtoneName } + private fun scheduleAutoBackup(enabled: Boolean) { + val wm = WorkManager.getInstance(requireContext()) + if (enabled) { + val interval = prefs.publicAutoBackupFrequency + val request = + PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES).build() + wm.enqueueUniquePeriodicWork( + "public_auto_backup", + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } else { + wm.cancelUniqueWork("public_auto_backup") + } + } + companion object { private const val RINGTONE_REQUEST_CODE = 1 } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/database/PublicBackupWorker.kt b/uhabits-android/src/main/java/org/isoron/uhabits/database/PublicBackupWorker.kt new file mode 100644 index 000000000..d17ed1957 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/database/PublicBackupWorker.kt @@ -0,0 +1,32 @@ +package org.isoron.uhabits.database + +import android.content.Context +import android.net.Uri +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import org.isoron.uhabits.HabitsApplication +import org.isoron.uhabits.utils.DatabaseUtils +import org.isoron.uhabits.utils.UriUtils +import java.io.File +import java.io.IOException + +class PublicBackupWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val app = applicationContext as HabitsApplication + val prefs = app.component.preferences + val uriString = prefs.publicBackupUri ?: return Result.failure() + val path = UriUtils.getPathFromTreeUri(applicationContext, Uri.parse(uriString)) + ?: return Result.failure() + return try { + val addDate = prefs.isPublicBackupAddDateEnabled + DatabaseUtils.saveDatabaseCopy(applicationContext, File(path), addDate) + Result.success() + } catch (e: IOException) { + Result.retry() + } + } +} 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..7ba9ca76a 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 @@ -58,10 +58,14 @@ object DatabaseUtils { @JvmStatic @Throws(IOException::class) - fun saveDatabaseCopy(context: Context, dir: File): String { + fun saveDatabaseCopy(context: Context, dir: File, add_date: Boolean = true): String { val dateFormat: SimpleDateFormat = getBackupDateFormat() - val date = dateFormat.format(getLocalTime()) - val filename = "${dir.absolutePath}/Loop Habits Backup $date.db" + val filename = if (add_date) { + val date = dateFormat.format(getLocalTime()) + "${dir.absolutePath}/Loop Habits Backup $date.db" + } else { + "${dir.absolutePath}/Loop Habits Backup.db" + } Log.i("DatabaseUtils", "Writing: $filename") val db = getDatabaseFile(context) val dbCopy = File(filename) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/utils/UriUtils.kt b/uhabits-android/src/main/java/org/isoron/uhabits/utils/UriUtils.kt new file mode 100644 index 000000000..4fdf1258e --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/utils/UriUtils.kt @@ -0,0 +1,21 @@ +package org.isoron.uhabits.utils + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract + +object UriUtils { + fun getPathFromTreeUri(context: Context, uri: Uri): String? { + val docId = DocumentsContract.getTreeDocumentId(uri) + val parts = docId.split(":") + val type = parts[0] + val relPath = if (parts.size > 1) parts[1] else "" + val base = if ("primary" == type) { + Environment.getExternalStorageDirectory().path + } else { + "/storage/" + type + } + return if (relPath.isEmpty()) base else "$base/$relPath" + } +} diff --git a/uhabits-android/src/main/res/values/constants.xml b/uhabits-android/src/main/res/values/constants.xml index cc5635954..1168f1605 100644 --- a/uhabits-android/src/main/res/values/constants.xml +++ b/uhabits-android/src/main/res/values/constants.xml @@ -94,4 +94,24 @@ 51 0 + + + @string/public_backup_frequency_15m + @string/public_backup_frequency_1h + @string/public_backup_frequency_1d + @string/public_backup_frequency_3d + @string/public_backup_frequency_7d + @string/public_backup_frequency_14d + @string/public_backup_frequency_1m + + + + 15 + 60 + 1440 + 4320 + 10080 + 20160 + 43200 + diff --git a/uhabits-android/src/main/res/xml/preferences.xml b/uhabits-android/src/main/res/xml/preferences.xml index 6072e711e..f678fe87d 100644 --- a/uhabits-android/src/main/res/xml/preferences.xml +++ b/uhabits-android/src/main/res/xml/preferences.xml @@ -136,6 +136,42 @@ android:title="@string/import_data" app:iconSpaceReserved="false" /> + + + + + + + + + + + +