diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5c4563d7..6bfa8854 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,11 +4,15 @@ package="com.simplemobiletools.contacts.pro" android:installLocation="auto"> + + + + + + + + + + + + + + + + , - private val displayContactSources: ArrayList + private val displayContactSources: List ) : RecyclerView.Adapter() { private val selectedKeys = HashSet() diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/dialogs/DateTimePatternInfoDialog.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/dialogs/DateTimePatternInfoDialog.kt new file mode 100644 index 00000000..ad5cd722 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/dialogs/DateTimePatternInfoDialog.kt @@ -0,0 +1,18 @@ +package com.simplemobiletools.contacts.pro.dialogs + +import com.simplemobiletools.commons.activities.BaseSimpleActivity +import com.simplemobiletools.commons.extensions.getAlertDialogBuilder +import com.simplemobiletools.commons.extensions.setupDialogStuff +import com.simplemobiletools.contacts.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) + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/dialogs/ManageAutoBackupsDialog.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/dialogs/ManageAutoBackupsDialog.kt new file mode 100644 index 00000000..9e999647 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/dialogs/ManageAutoBackupsDialog.kt @@ -0,0 +1,157 @@ +package com.simplemobiletools.contacts.pro.dialogs + +import android.view.View +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.ContactsHelper +import com.simplemobiletools.commons.helpers.ensureBackgroundThread +import com.simplemobiletools.commons.models.contacts.Contact +import com.simplemobiletools.commons.models.contacts.ContactSource +import com.simplemobiletools.contacts.pro.R +import com.simplemobiletools.contacts.pro.activities.SimpleActivity +import com.simplemobiletools.contacts.pro.adapters.FilterContactSourcesAdapter +import com.simplemobiletools.contacts.pro.extensions.config +import kotlinx.android.synthetic.main.dialog_manage_automatic_backups.view.backup_contact_sources_list +import kotlinx.android.synthetic.main.dialog_manage_automatic_backups.view.backup_contacts_filename +import kotlinx.android.synthetic.main.dialog_manage_automatic_backups.view.backup_contacts_filename_hint +import kotlinx.android.synthetic.main.dialog_manage_automatic_backups.view.backup_contacts_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 + private var contactSources = mutableListOf() + private var selectedContactSources = config.autoBackupContactSources + private var contacts = ArrayList() + private var isContactSourcesReady = false + private var isContactsReady = false + + init { + view.apply { + backup_contacts_folder.setText(activity.humanizePath(backupFolder)) + val filename = config.autoBackupFilename.ifEmpty { + "${activity.getString(R.string.contacts)}_%Y%M%D_%h%m%s" + } + + backup_contacts_filename.setText(filename) + backup_contacts_filename_hint.setEndIconOnClickListener { + DateTimePatternInfoDialog(activity) + } + + backup_contacts_filename_hint.setEndIconOnLongClickListener { + DateTimePatternInfoDialog(activity) + true + } + + backup_contacts_folder.setOnClickListener { + selectBackupFolder() + } + + ContactsHelper(activity).getContactSources { sources -> + contactSources = sources + isContactSourcesReady = true + processDataIfReady(this) + } + + ContactsHelper(activity).getContacts(getAll = true) { receivedContacts -> + contacts = receivedContacts + isContactsReady = true + processDataIfReady(this) + } + } + + 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 { + if (view.backup_contact_sources_list.adapter == null) { + return@setOnClickListener + } + val filename = view.backup_contacts_filename.value + when { + filename.isEmpty() -> activity.toast(R.string.empty_name) + filename.isAValidFilename() -> { + val file = File(backupFolder, "$filename.vcf") + if (file.exists() && !file.canWrite()) { + activity.toast(R.string.name_taken) + return@setOnClickListener + } + + val selectedSources = (view.backup_contact_sources_list.adapter as FilterContactSourcesAdapter).getSelectedContactSources() + if (selectedSources.isEmpty()) { + activity.toast(R.string.no_entries_for_exporting) + return@setOnClickListener + } + + config.autoBackupContactSources = selectedSources.map { it.name }.toSet() + + ensureBackgroundThread { + config.apply { + autoBackupFolder = backupFolder + autoBackupFilename = filename + } + + activity.runOnUiThread { + onSuccess() + } + + dialog.dismiss() + } + } + + else -> activity.toast(R.string.invalid_name) + } + } + } + } + } + + private fun processDataIfReady(view: View) { + if (!isContactSourcesReady || !isContactsReady) { + return + } + + if (selectedContactSources.isEmpty()) { + selectedContactSources = contactSources.map { it.name }.toSet() + } + + val contactSourcesWithCount = mutableListOf() + for (source in contactSources) { + val count = contacts.count { it.source == source.name } + contactSourcesWithCount.add(source.copy(count = count)) + } + + contactSources.clear() + contactSources.addAll(contactSourcesWithCount) + + activity.runOnUiThread { + view.backup_contact_sources_list.adapter = FilterContactSourcesAdapter(activity, contactSourcesWithCount, selectedContactSources.toList()) + } + } + + private fun selectBackupFolder() { + activity.hideKeyboard(view.backup_contacts_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_contacts_folder.setText(activity.humanizePath(path)) + } + } + } + } +} + diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt index b4f18f1a..1ed82973 100644 --- a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt @@ -1,18 +1,25 @@ package com.simplemobiletools.contacts.pro.extensions import android.annotation.SuppressLint +import android.app.AlarmManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.graphics.drawable.Drawable +import androidx.core.app.AlarmManagerCompat import androidx.core.content.FileProvider -import com.simplemobiletools.commons.extensions.getCachePhoto -import com.simplemobiletools.commons.helpers.SIGNAL_PACKAGE -import com.simplemobiletools.commons.helpers.TELEGRAM_PACKAGE -import com.simplemobiletools.commons.helpers.VIBER_PACKAGE -import com.simplemobiletools.commons.helpers.WHATSAPP_PACKAGE +import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.contacts.pro.BuildConfig import com.simplemobiletools.contacts.pro.R +import com.simplemobiletools.contacts.pro.helpers.AUTOMATIC_BACKUP_REQUEST_CODE import com.simplemobiletools.contacts.pro.helpers.Config +import com.simplemobiletools.contacts.pro.helpers.getNextAutoBackupTime +import com.simplemobiletools.contacts.pro.helpers.getPreviousAutoBackupTime +import com.simplemobiletools.contacts.pro.receivers.AutomaticBackupReceiver +import org.joda.time.DateTime import java.io.File +import java.io.FileOutputStream val Context.config: Config get() = Config.newInstance(applicationContext) fun Context.getCachePhotoUri(file: File = getCachePhoto()) = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.provider", file) @@ -29,3 +36,113 @@ fun Context.getPackageDrawable(packageName: String): Drawable { }, theme ) } + +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.checkAndBackupContactsOnBoot() { + 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 + backupContacts() + } + } +} + +fun Context.backupContacts() { + require(isRPlus()) + ensureBackgroundThread { + val config = config + ContactsHelper(this).getContactsToExport(selectedContactSources = config.autoBackupContactSources) { contactsToBackup -> + if (contactsToBackup.isEmpty()) { + toast(R.string.no_entries_for_exporting) + config.lastAutoBackupTime = DateTime.now().millis + scheduleNextAutomaticBackup() + return@getContactsToExport + } + + + 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.vcf") + 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}.vcf") + exportFilePath = exportFile.absolutePath + } + FileOutputStream(exportFile) + } + } catch (e: Exception) { + showErrorToast(e) + scheduleNextAutomaticBackup() + return@getContactsToExport + } + + val exportResult = try { + ContactsHelper(this).exportContacts(contactsToBackup, outputStream) + } catch (e: Exception) { + showErrorToast(e) + } + + when (exportResult) { + ExportResult.EXPORT_OK -> toast(R.string.exporting_successful) + else -> toast(R.string.exporting_failed) + } + + config.lastAutoBackupTime = DateTime.now().millis + scheduleNextAutomaticBackup() + } + } +} + diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Config.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Config.kt index 6fd98956..21822187 100644 --- a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Config.kt +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Config.kt @@ -12,4 +12,9 @@ class Config(context: Context) : BaseConfig(context) { var showTabs: Int get() = prefs.getInt(SHOW_TABS, ALL_TABS_MASK) set(showTabs) = prefs.edit().putInt(SHOW_TABS, showTabs).apply() + + var autoBackupContactSources: Set + get() = prefs.getStringSet(AUTO_BACKUP_CONTACT_SOURCES, setOf())!! + set(autoBackupContactSources) = prefs.edit().remove(AUTO_BACKUP_CONTACT_SOURCES).putStringSet(AUTO_BACKUP_CONTACT_SOURCES, autoBackupContactSources).apply() + } diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Constants.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Constants.kt index 6ea62bfb..665ff482 100644 --- a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Constants.kt +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/Constants.kt @@ -3,16 +3,20 @@ package com.simplemobiletools.contacts.pro.helpers import com.simplemobiletools.commons.helpers.TAB_CONTACTS import com.simplemobiletools.commons.helpers.TAB_FAVORITES import com.simplemobiletools.commons.helpers.TAB_GROUPS +import org.joda.time.DateTime const val GROUP = "group" const val IS_FROM_SIMPLE_CONTACTS = "is_from_simple_contacts" const val ADD_NEW_CONTACT_NUMBER = "add_new_contact_number" -const val FIRST_CONTACT_ID = 1000000 -const val FIRST_GROUP_ID = 10000L const val DEFAULT_FILE_NAME = "contacts.vcf" const val AVOID_CHANGING_TEXT_TAG = "avoid_changing_text_tag" const val AVOID_CHANGING_VISIBILITY_TAG = "avoid_changing_visibility_tag" +const val AUTOMATIC_BACKUP_REQUEST_CODE = 10001 +const val AUTO_BACKUP_INTERVAL_IN_DAYS = 1 + +const val AUTO_BACKUP_CONTACT_SOURCES = "auto_backup_contact_sources" + // extras used at third party intents const val KEY_NAME = "name" const val KEY_EMAIL = "email" @@ -53,3 +57,20 @@ const val SIGNAL = "signal" const val VIBER = "viber" const val TELEGRAM = "telegram" const val THREEMA = "threema" + + +// 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) +} diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/receivers/AutomaticBackupReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/receivers/AutomaticBackupReceiver.kt new file mode 100644 index 00000000..6de77a0d --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/receivers/AutomaticBackupReceiver.kt @@ -0,0 +1,17 @@ +package com.simplemobiletools.contacts.pro.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import com.simplemobiletools.contacts.pro.extensions.backupContacts + +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, "simplecontacts:automaticbackupreceiver") + wakelock.acquire(3000) + context.backupContacts() + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/receivers/BootCompletedReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/receivers/BootCompletedReceiver.kt new file mode 100644 index 00000000..c0972986 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/receivers/BootCompletedReceiver.kt @@ -0,0 +1,18 @@ +package com.simplemobiletools.contacts.pro.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.simplemobiletools.commons.helpers.ensureBackgroundThread +import com.simplemobiletools.contacts.pro.extensions.checkAndBackupContactsOnBoot + +class BootCompletedReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + ensureBackgroundThread { + context.apply { + checkAndBackupContactsOnBoot() + } + } + } +} diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index c120cb1a..d225ec4e 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -341,6 +341,48 @@ android:text="@string/start_name_with_surname" /> + + + + + + + + + + + + + + + + + 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..21690ff2 --- /dev/null +++ b/app/src/main/res/layout/dialog_manage_automatic_backups.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + +