Merge pull request #653 from Merkost/automatic_backups

Automatic backups
This commit is contained in:
Tibor Kaputa 2023-07-30 21:46:31 +02:00 committed by GitHub
commit bdf9448c90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 478 additions and 7 deletions

View File

@ -66,7 +66,7 @@ android {
}
dependencies {
implementation 'com.github.SimpleMobileTools:Simple-Commons:f737f6c38b'
implementation 'com.github.SimpleMobileTools:Simple-Commons:91763fe00f'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.documentfile:documentfile:1.0.1'

View File

@ -6,6 +6,10 @@
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-feature
android:name="android.hardware.faketouch"
@ -107,6 +111,22 @@
android:resource="@xml/widget_info" />
</receiver>
<receiver
android:name=".receivers.BootCompletedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".receivers.AutomaticBackupReceiver"
android:exported="false" />
<activity-alias
android:name=".activities.SplashActivity.Red"
android:enabled="false"

View File

@ -13,10 +13,8 @@ import com.simplemobiletools.commons.helpers.*
import com.simplemobiletools.commons.models.RadioItem
import com.simplemobiletools.notes.pro.R
import com.simplemobiletools.notes.pro.dialogs.ExportNotesDialog
import com.simplemobiletools.notes.pro.extensions.config
import com.simplemobiletools.notes.pro.extensions.requestUnlockNotes
import com.simplemobiletools.notes.pro.extensions.updateWidgets
import com.simplemobiletools.notes.pro.extensions.widgetsDB
import com.simplemobiletools.notes.pro.dialogs.ManageAutoBackupsDialog
import com.simplemobiletools.notes.pro.extensions.*
import com.simplemobiletools.notes.pro.helpers.*
import com.simplemobiletools.notes.pro.models.Note
import com.simplemobiletools.notes.pro.models.Widget
@ -62,6 +60,8 @@ class SettingsActivity : SimpleActivity() {
setupCustomizeWidgetColors()
setupNotesExport()
setupNotesImport()
setupEnableAutomaticBackups()
setupManageAutomaticBackups()
updateTextColors(settings_nested_scrollview)
arrayOf(
@ -71,6 +71,7 @@ class SettingsActivity : SimpleActivity() {
settings_startup_label,
settings_saving_label,
settings_migrating_label,
settings_backups_label,
).forEach {
it.setTextColor(getProperPrimaryColor())
}
@ -355,4 +356,43 @@ class SettingsActivity : SimpleActivity() {
showErrorToast(e)
}
}
private fun setupEnableAutomaticBackups() {
settings_backups_label.beVisibleIf(isRPlus())
settings_enable_automatic_backups_holder.beVisibleIf(isRPlus())
settings_enable_automatic_backups.isChecked = config.autoBackup
settings_enable_automatic_backups_holder.setOnClickListener {
val wasBackupDisabled = !config.autoBackup
if (wasBackupDisabled) {
ManageAutoBackupsDialog(
activity = this,
onSuccess = {
enableOrDisableAutomaticBackups(true)
scheduleNextAutomaticBackup()
}
)
} else {
cancelScheduledAutomaticBackup()
enableOrDisableAutomaticBackups(false)
}
}
}
private fun setupManageAutomaticBackups() {
settings_manage_automatic_backups_holder.beVisibleIf(isRPlus() && config.autoBackup)
settings_manage_automatic_backups_holder.setOnClickListener {
ManageAutoBackupsDialog(
activity = this,
onSuccess = {
scheduleNextAutomaticBackup()
}
)
}
}
private fun enableOrDisableAutomaticBackups(enable: Boolean) {
config.autoBackup = enable
settings_enable_automatic_backups.isChecked = enable
settings_manage_automatic_backups_holder.beVisibleIf(enable)
}
}

View File

@ -0,0 +1,18 @@
package com.simplemobiletools.notes.pro.dialogs
import com.simplemobiletools.commons.activities.BaseSimpleActivity
import com.simplemobiletools.commons.extensions.getAlertDialogBuilder
import com.simplemobiletools.commons.extensions.setupDialogStuff
import com.simplemobiletools.notes.pro.R
class DateTimePatternInfoDialog(activity: BaseSimpleActivity) {
init {
val view = activity.layoutInflater.inflate(R.layout.datetime_pattern_info_layout, null)
activity.getAlertDialogBuilder()
.setPositiveButton(R.string.ok) { _, _ -> { } }
.apply {
activity.setupDialogStuff(view, this)
}
}
}

View File

@ -0,0 +1,99 @@
package com.simplemobiletools.notes.pro.dialogs
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import com.simplemobiletools.commons.dialogs.FilePickerDialog
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.notes.pro.R
import com.simplemobiletools.notes.pro.activities.SimpleActivity
import com.simplemobiletools.notes.pro.extensions.config
import kotlinx.android.synthetic.main.dialog_manage_automatic_backups.view.backup_notes_filename
import kotlinx.android.synthetic.main.dialog_manage_automatic_backups.view.backup_notes_filename_hint
import kotlinx.android.synthetic.main.dialog_manage_automatic_backups.view.backup_notes_folder
import java.io.File
class ManageAutoBackupsDialog(private val activity: SimpleActivity, onSuccess: () -> Unit) {
private val view = (activity.layoutInflater.inflate(R.layout.dialog_manage_automatic_backups, null) as ViewGroup)
private val config = activity.config
private var backupFolder = config.autoBackupFolder
init {
view.apply {
backup_notes_folder.setText(activity.humanizePath(backupFolder))
val filename = config.autoBackupFilename.ifEmpty {
"${activity.getString(R.string.notes)}_%Y%M%D_%h%m%s"
}
backup_notes_filename.setText(filename)
backup_notes_filename_hint.setEndIconOnClickListener {
DateTimePatternInfoDialog(activity)
}
backup_notes_filename_hint.setEndIconOnLongClickListener {
DateTimePatternInfoDialog(activity)
true
}
backup_notes_folder.setOnClickListener {
selectBackupFolder()
}
}
activity.getAlertDialogBuilder()
.setPositiveButton(R.string.ok, null)
.setNegativeButton(R.string.cancel, null)
.apply {
activity.setupDialogStuff(view, this, R.string.manage_automatic_backups) { dialog ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val filename = view.backup_notes_filename.value
when {
filename.isEmpty() -> activity.toast(R.string.empty_name)
filename.isAValidFilename() -> {
val file = File(backupFolder, "$filename.json")
if (file.exists() && !file.canWrite()) {
activity.toast(R.string.name_taken)
return@setOnClickListener
}
ensureBackgroundThread {
config.apply {
autoBackupFolder = backupFolder
autoBackupFilename = filename
}
activity.runOnUiThread {
onSuccess()
}
dialog.dismiss()
}
}
else -> activity.toast(R.string.invalid_name)
}
}
}
}
}
private fun selectBackupFolder() {
activity.hideKeyboard(view.backup_notes_filename)
FilePickerDialog(activity, backupFolder, false, showFAB = true) { path ->
activity.handleSAFDialog(path) { grantedSAF ->
if (!grantedSAF) {
return@handleSAFDialog
}
activity.handleSAFDialogSdk30(path) { grantedSAF30 ->
if (!grantedSAF30) {
return@handleSAFDialogSdk30
}
backupFolder = path
view.backup_notes_folder.setText(activity.humanizePath(path))
}
}
}
}
}

View File

@ -1,18 +1,28 @@
package com.simplemobiletools.notes.pro.extensions
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.app.AlarmManagerCompat
import com.simplemobiletools.commons.activities.BaseSimpleActivity
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.ExportResult
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.helpers.isRPlus
import com.simplemobiletools.notes.pro.R
import com.simplemobiletools.notes.pro.databases.NotesDatabase
import com.simplemobiletools.notes.pro.dialogs.UnlockNotesDialog
import com.simplemobiletools.notes.pro.helpers.Config
import com.simplemobiletools.notes.pro.helpers.MyWidgetProvider
import com.simplemobiletools.notes.pro.helpers.*
import com.simplemobiletools.notes.pro.interfaces.NotesDao
import com.simplemobiletools.notes.pro.interfaces.WidgetsDao
import com.simplemobiletools.notes.pro.models.Note
import com.simplemobiletools.notes.pro.receivers.AutomaticBackupReceiver
import org.joda.time.DateTime
import java.io.File
import java.io.FileOutputStream
val Context.config: Config get() = Config.newInstance(applicationContext)
@ -43,3 +53,111 @@ fun BaseSimpleActivity.requestUnlockNotes(notes: List<Note>, callback: (unlocked
callback(emptyList())
}
}
fun Context.getAutomaticBackupIntent(): PendingIntent {
val intent = Intent(this, AutomaticBackupReceiver::class.java)
return PendingIntent.getBroadcast(this, AUTOMATIC_BACKUP_REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
fun Context.scheduleNextAutomaticBackup() {
if (config.autoBackup) {
val backupAtMillis = getNextAutoBackupTime().millis
val pendingIntent = getAutomaticBackupIntent()
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
try {
AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, backupAtMillis, pendingIntent)
} catch (e: Exception) {
showErrorToast(e)
}
}
}
fun Context.cancelScheduledAutomaticBackup() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.cancel(getAutomaticBackupIntent())
}
fun Context.checkAndBackupNotesOnBoot() {
if (config.autoBackup) {
val previousRealBackupTime = config.lastAutoBackupTime
val previousScheduledBackupTime = getPreviousAutoBackupTime().millis
val missedPreviousBackup = previousRealBackupTime < previousScheduledBackupTime
if (missedPreviousBackup) {
// device was probably off at the scheduled time so backup now
backupNotes()
}
}
}
fun Context.backupNotes() {
require(isRPlus())
ensureBackgroundThread {
val config = config
NotesHelper(this).getNotes { notesToBackup ->
if (notesToBackup.isEmpty()) {
toast(R.string.no_entries_for_exporting)
config.lastAutoBackupTime = DateTime.now().millis
scheduleNextAutomaticBackup()
return@getNotes
}
val now = DateTime.now()
val year = now.year.toString()
val month = now.monthOfYear.ensureTwoDigits()
val day = now.dayOfMonth.ensureTwoDigits()
val hours = now.hourOfDay.ensureTwoDigits()
val minutes = now.minuteOfHour.ensureTwoDigits()
val seconds = now.secondOfMinute.ensureTwoDigits()
val filename = config.autoBackupFilename
.replace("%Y", year, false)
.replace("%M", month, false)
.replace("%D", day, false)
.replace("%h", hours, false)
.replace("%m", minutes, false)
.replace("%s", seconds, false)
val outputFolder = File(config.autoBackupFolder).apply {
mkdirs()
}
var exportFile = File(outputFolder, "$filename.json")
var exportFilePath = exportFile.absolutePath
val outputStream = try {
if (hasProperStoredFirstParentUri(exportFilePath)) {
val exportFileUri = createDocumentUriUsingFirstParentTreeUri(exportFilePath)
if (!getDoesFilePathExist(exportFilePath)) {
createSAFFileSdk30(exportFilePath)
}
applicationContext.contentResolver.openOutputStream(exportFileUri, "wt") ?: FileOutputStream(exportFile)
} else {
var num = 0
while (getDoesFilePathExist(exportFilePath) && !exportFile.canWrite()) {
num++
exportFile = File(outputFolder, "${filename}_${num}.json")
exportFilePath = exportFile.absolutePath
}
FileOutputStream(exportFile)
}
} catch (e: Exception) {
showErrorToast(e)
scheduleNextAutomaticBackup()
return@getNotes
}
val exportResult = try {
NotesHelper(this).exportNotes(notesToBackup, outputStream)
} catch (e: Exception) {
showErrorToast(e)
}
if (exportResult == ExportResult.EXPORT_FAIL) {
toast(R.string.exporting_failed)
}
config.lastAutoBackupTime = DateTime.now().millis
scheduleNextAutomaticBackup()
}
}
}

View File

@ -1,6 +1,7 @@
package com.simplemobiletools.notes.pro.helpers
import android.graphics.Color
import org.joda.time.DateTime
const val NOTE_ID = "note_id"
const val OPEN_NOTE_ID = "open_note_id"
@ -39,6 +40,26 @@ const val FONT_SIZE_PERCENTAGE = "font_size_percentage"
const val EXPORT_MIME_TYPE = "text/plain"
const val ADD_NEW_CHECKLIST_ITEMS_TOP = "add_new_checklist_items_top"
// auto backups
const val AUTOMATIC_BACKUP_REQUEST_CODE = 10001
const val AUTO_BACKUP_INTERVAL_IN_DAYS = 1
// 6 am is the hardcoded automatic backup time, intervals shorter than 1 day are not yet supported.
fun getNextAutoBackupTime(): DateTime {
val now = DateTime.now()
val sixHour = now.withHourOfDay(6)
return if (now.millis < sixHour.millis) {
sixHour
} else {
sixHour.plusDays(AUTO_BACKUP_INTERVAL_IN_DAYS)
}
}
fun getPreviousAutoBackupTime(): DateTime {
val nextBackupTime = getNextAutoBackupTime()
return nextBackupTime.minusDays(AUTO_BACKUP_INTERVAL_IN_DAYS)
}
// gravity
const val GRAVITY_START = 0
const val GRAVITY_CENTER = 1

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.os.Handler
import android.os.Looper
import com.simplemobiletools.commons.activities.BaseSimpleActivity
import com.simplemobiletools.commons.helpers.ExportResult
import com.simplemobiletools.commons.helpers.PROTECTION_NONE
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.notes.pro.R
@ -11,7 +12,10 @@ import com.simplemobiletools.notes.pro.extensions.config
import com.simplemobiletools.notes.pro.extensions.notesDB
import com.simplemobiletools.notes.pro.models.Note
import com.simplemobiletools.notes.pro.models.NoteType
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.OutputStream
class NotesHelper(val context: Context) {
fun getNotes(callback: (notes: List<Note>) -> Unit) {
@ -124,6 +128,18 @@ class NotesHelper(val context: Context) {
}
}
fun exportNotes(notesToBackup: List<Note>, outputStream: OutputStream): ExportResult {
return try {
val jsonString = Json.encodeToString(notesToBackup)
outputStream.use {
it.write(jsonString.toByteArray())
}
ExportResult.EXPORT_OK
} catch (_: Error) {
ExportResult.EXPORT_FAIL
}
}
enum class ImportResult {
IMPORT_FAIL, IMPORT_OK, IMPORT_PARTIAL, IMPORT_NOTHING_NEW
}

View File

@ -0,0 +1,17 @@
package com.simplemobiletools.notes.pro.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import com.simplemobiletools.notes.pro.extensions.backupNotes
class AutomaticBackupReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakelock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "simplenotes:automaticbackupreceiver")
wakelock.acquire(3000)
context.backupNotes()
}
}

View File

@ -0,0 +1,18 @@
package com.simplemobiletools.notes.pro.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.notes.pro.extensions.checkAndBackupNotesOnBoot
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
ensureBackgroundThread {
context.apply {
checkAndBackupNotesOnBoot()
}
}
}
}

View File

@ -386,6 +386,47 @@
android:text="@string/import_notes" />
</RelativeLayout>
<include
android:id="@+id/settings_migrating_divider"
layout="@layout/divider" />
<TextView
android:id="@+id/settings_backups_label"
style="@style/SettingsSectionLabelStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/backups" />
<RelativeLayout
android:id="@+id/settings_enable_automatic_backups_holder"
style="@style/SettingsHolderCheckboxStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.simplemobiletools.commons.views.MyAppCompatCheckbox
android:id="@+id/settings_enable_automatic_backups"
style="@style/SettingsCheckboxStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/enable_automatic_backups" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/settings_manage_automatic_backups_holder"
style="@style/SettingsHolderTextViewOneLinerStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/settings_manage_automatic_backups"
style="@style/SettingsTextLabelStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manage_automatic_backups" />
</RelativeLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<com.simplemobiletools.commons.views.MyTextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/date_time_pattern_info"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="@dimen/big_margin"
android:paddingTop="@dimen/big_margin"
android:text="@string/date_time_pattern_info"
android:textIsSelectable="true" />

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/backup_notes_scrollview"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/backup_notes_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="@dimen/activity_margin">
<com.simplemobiletools.commons.views.MyTextInputLayout
android:id="@+id/backup_notes_folder_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin"
android:layout_marginEnd="@dimen/activity_margin"
android:layout_marginBottom="@dimen/activity_margin"
android:hint="@string/folder">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/backup_notes_folder"
style="@style/UnclickableEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.simplemobiletools.commons.views.MyTextInputLayout>
<com.simplemobiletools.commons.views.MyTextInputLayout
android:id="@+id/backup_notes_filename_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin"
android:layout_marginEnd="@dimen/activity_margin"
android:hint="@string/filename_without_json"
app:endIconDrawable="@drawable/ic_info_vector"
app:endIconMode="custom">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/backup_notes_filename"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapWords"
android:singleLine="true"
android:textCursorDrawable="@null"
android:textSize="@dimen/bigger_text_size" />
</com.simplemobiletools.commons.views.MyTextInputLayout>
</LinearLayout>
</ScrollView>