diff --git a/app/build.gradle b/app/build.gradle index c2f55d37..e21d2d02 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6fbba142..29ccdc42 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ + + + + + + + + + + + + + + + + { } } + .apply { + activity.setupDialogStuff(view, this) + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/notes/pro/dialogs/ManageAutoBackupsDialog.kt b/app/src/main/kotlin/com/simplemobiletools/notes/pro/dialogs/ManageAutoBackupsDialog.kt new file mode 100644 index 00000000..b03cc3e2 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/notes/pro/dialogs/ManageAutoBackupsDialog.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/notes/pro/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/notes/pro/extensions/Context.kt index 554728ef..38792a57 100644 --- a/app/src/main/kotlin/com/simplemobiletools/notes/pro/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/notes/pro/extensions/Context.kt @@ -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, 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() + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/Constants.kt b/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/Constants.kt index 5d7e2b19..a61b28d5 100644 --- a/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/Constants.kt +++ b/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/Constants.kt @@ -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 diff --git a/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/NotesHelper.kt b/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/NotesHelper.kt index bf25adb5..53fb7227 100644 --- a/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/NotesHelper.kt +++ b/app/src/main/kotlin/com/simplemobiletools/notes/pro/helpers/NotesHelper.kt @@ -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) -> Unit) { @@ -124,6 +128,18 @@ class NotesHelper(val context: Context) { } } + fun exportNotes(notesToBackup: List, 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 } diff --git a/app/src/main/kotlin/com/simplemobiletools/notes/pro/receivers/AutomaticBackupReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/notes/pro/receivers/AutomaticBackupReceiver.kt new file mode 100644 index 00000000..7d4949be --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/notes/pro/receivers/AutomaticBackupReceiver.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/notes/pro/receivers/BootCompletedReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/notes/pro/receivers/BootCompletedReceiver.kt new file mode 100644 index 00000000..8cc03250 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/notes/pro/receivers/BootCompletedReceiver.kt @@ -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() + } + } + } +} diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 93fc0d18..857ee1ec 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -386,6 +386,47 @@ android:text="@string/import_notes" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/datetime_pattern_info_layout.xml b/app/src/main/res/layout/datetime_pattern_info_layout.xml new file mode 100644 index 00000000..36ee34d1 --- /dev/null +++ b/app/src/main/res/layout/datetime_pattern_info_layout.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/dialog_manage_automatic_backups.xml b/app/src/main/res/layout/dialog_manage_automatic_backups.xml new file mode 100644 index 00000000..9aef64b4 --- /dev/null +++ b/app/src/main/res/layout/dialog_manage_automatic_backups.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + +