Display backup warnings to user

* Warn if backups stored in private storage
* Warn if local backup is stale
* Warn if remote backup is stale
* Add option to disable warnings
pull/1136/head
Alex Baker 5 years ago
parent 40a764112c
commit 3e17bea70b

@ -0,0 +1,28 @@
package org.tasks.preferences
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import org.tasks.R
class IconPreference(context: Context?, attrs: AttributeSet?) : Preference(context, attrs) {
var iconVisible = false
override fun onBindViewHolder(holder: PreferenceViewHolder?) {
super.onBindViewHolder(holder)
(holder?.findViewById(R.id.preference_icon) as ImageView?)?.visibility = if (iconVisible) {
View.VISIBLE
} else {
View.GONE
}
}
init {
widgetLayoutResource = R.layout.preference_icon
}
}

@ -36,6 +36,8 @@ class Preferences @JvmOverloads constructor(private val context: Context, name:
fun androidBackupServiceEnabled() = getBoolean(R.string.p_backups_android_backup_enabled, true) fun androidBackupServiceEnabled() = getBoolean(R.string.p_backups_android_backup_enabled, true)
fun showBackupWarnings() = !getBoolean(R.string.p_backups_ignore_warnings, false)
fun addTasksToTop(): Boolean { fun addTasksToTop(): Boolean {
return getBoolean(R.string.p_add_to_top, true) return getBoolean(R.string.p_add_to_top, true)
} }
@ -330,6 +332,9 @@ class Preferences @JvmOverloads constructor(private val context: Context, name:
val backupDirectory: Uri? val backupDirectory: Uri?
get() = getDirectory(R.string.p_backup_dir, "backups") get() = getDirectory(R.string.p_backup_dir, "backups")
val externalStorage: Uri
get() = root.uri
val attachmentsDirectory: Uri? val attachmentsDirectory: Uri?
get() = getDirectory(R.string.p_attachment_dir, TaskAttachment.FILES_DIRECTORY_DEFAULT) get() = getDirectory(R.string.p_attachment_dir, TaskAttachment.FILES_DIRECTORY_DEFAULT)
@ -351,16 +356,18 @@ class Preferences @JvmOverloads constructor(private val context: Context, name:
} }
} }
} }
val documentFile = DocumentFile.fromFile(context.getExternalFilesDir(null)!!).createDirectory(name) return getDefaultDirectory(name)
if (documentFile != null) {
return documentFile.uri
}
val file = getDefaultFileLocation(name)
return if (file != null) {
Uri.fromFile(file)
} else null
} }
private fun getDefaultDirectory(name: String): Uri? =
root
.createDirectory(name)
?.uri
?: getDefaultFileLocation(name)?.let { Uri.fromFile(it) }
private val root: DocumentFile
get() = DocumentFile.fromFile(context.getExternalFilesDir(null)!!)
private fun getDefaultFileLocation(type: String): File? { private fun getDefaultFileLocation(type: String): File? {
val externalFilesDir = context.getExternalFilesDir(null) ?: return null val externalFilesDir = context.getExternalFilesDir(null) ?: return null
val path = String.format("%s/%s", externalFilesDir.absolutePath, type) val path = String.format("%s/%s", externalFilesDir.absolutePath, type)

@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.R import org.tasks.R
import org.tasks.date.DateTimeUtils.newDateTime
import org.tasks.drive.DriveInvoker import org.tasks.drive.DriveInvoker
import org.tasks.gtasks.GoogleAccountManager import org.tasks.gtasks.GoogleAccountManager
import timber.log.Timber import timber.log.Timber
@ -30,6 +31,22 @@ class PreferencesViewModel @ViewModelInject constructor(
val lastDriveBackup = MutableLiveData<Long?>() val lastDriveBackup = MutableLiveData<Long?>()
val lastAndroidBackup = MutableLiveData<Long>() val lastAndroidBackup = MutableLiveData<Long>()
private fun isStale(timestamp: Long?) =
timestamp != null
&& preferences.showBackupWarnings()
&& timestamp < newDateTime().startOfDay().minusDays(2).millis
val staleLocalBackup: Boolean
get() = isStale(lastBackup.value)
val staleRemoteBackup: Boolean
get() = isStale(lastDriveBackup.value) && isStale(lastAndroidBackup.value)
val usingPrivateStorage: Boolean
get() = preferences.backupDirectory.let {
it == null || it.toString().startsWith(preferences.externalStorage.toString())
}
val driveAccount: String? val driveAccount: String?
get() { get() {
val account = preferences.getStringValue(R.string.p_google_drive_backup_account) val account = preferences.getStringValue(R.string.p_google_drive_backup_account)
@ -42,7 +59,7 @@ class PreferencesViewModel @ViewModelInject constructor(
fun updateDriveBackup() = viewModelScope.launch { fun updateDriveBackup() = viewModelScope.launch {
if (driveAccount.isNullOrBlank()) { if (driveAccount.isNullOrBlank()) {
lastDriveBackup.value = null lastDriveBackup.value = -1L
return@launch return@launch
} }
val files = preferences.getStringValue(R.string.p_google_drive_backup_folder) val files = preferences.getStringValue(R.string.p_google_drive_backup_folder)
@ -56,7 +73,7 @@ class PreferencesViewModel @ViewModelInject constructor(
} }
} }
?: emptyList() ?: emptyList()
lastDriveBackup.value = files.firstOrNull()?.let { BackupConstants.getTimestamp(it) } lastDriveBackup.value = files.firstOrNull()?.let { BackupConstants.getTimestamp(it) } ?: -1
} }
fun updateLocalBackup() = viewModelScope.launch { fun updateLocalBackup() = viewModelScope.launch {
@ -78,13 +95,12 @@ class PreferencesViewModel @ViewModelInject constructor(
else -> emptyList() else -> emptyList()
} }
} }
lastBackup.value = timestamps?.maxOrNull() lastBackup.value = timestamps?.maxOrNull() ?: -1L
} }
private fun updateAndroidBackup() { private fun updateAndroidBackup() {
lastAndroidBackup.value = preferences lastAndroidBackup.value = preferences
.getLong(R.string.p_backups_android_backup_last, -1L) .getLong(R.string.p_backups_android_backup_last, -1L)
.takeIf { it >= 0 }
} }
fun updateBackups() { fun updateBackups() {

@ -43,7 +43,13 @@ class Backups : InjectingPreferenceFragment() {
override fun getPreferenceXml() = R.xml.preferences_backups override fun getPreferenceXml() = R.xml.preferences_backups
override suspend fun setupPreferences(savedInstanceState: Bundle?) { override suspend fun setupPreferences(savedInstanceState: Bundle?) {
initializeBackupDirectory() findPreference(R.string.p_backup_dir)
.setOnPreferenceClickListener {
FileHelper.newDirectoryPicker(
this, REQUEST_CODE_BACKUP_DIR, preferences.backupDirectory
)
false
}
findPreference(R.string.backup_BAc_import) findPreference(R.string.backup_BAc_import)
.setOnPreferenceClickListener { .setOnPreferenceClickListener {
@ -63,42 +69,73 @@ class Backups : InjectingPreferenceFragment() {
findPreference(R.string.google_drive_backup) findPreference(R.string.google_drive_backup)
.setOnPreferenceChangeListener(this@Backups::onGoogleDriveCheckChanged) .setOnPreferenceChangeListener(this@Backups::onGoogleDriveCheckChanged)
findPreference(R.string.p_google_drive_backup_account) findPreference(R.string.p_google_drive_backup_account)
.setOnPreferenceClickListener { .setOnPreferenceClickListener {
requestGoogleDriveLogin() requestGoogleDriveLogin()
false false
} }
findPreference(R.string.p_backups_android_backup_enabled)
.setOnPreferenceChangeListener(this@Backups::onAndroidBackupCheckChanged)
findPreference(R.string.p_backups_ignore_warnings).setOnPreferenceChangeListener { _, newValue ->
if (newValue is Boolean) {
preferences.setBoolean(R.string.p_backups_ignore_warnings, newValue)
updateWarnings()
true
} else {
false
}
}
viewModel.lastBackup.observe(this, this::updateLastBackup) viewModel.lastBackup.observe(this, this::updateLastBackup)
viewModel.lastDriveBackup.observe(this, this::updateDriveBackup) viewModel.lastDriveBackup.observe(this, this::updateDriveBackup)
viewModel.lastAndroidBackup.observe(this, this::updateAndroidBackup) viewModel.lastAndroidBackup.observe(this, this::updateAndroidBackup)
} }
private fun updateLastBackup(timestamp: Long?) { private fun updateLastBackup(timestamp: Long? = viewModel.lastBackup.value) {
findPreference(R.string.backup_BAc_export).summary = findPreference(R.string.backup_BAc_export).summary =
getString( getString(
R.string.last_backup, R.string.last_backup,
timestamp timestamp
?.takeIf { it >= 0 }
?.let { DateUtilities.getLongDateStringWithTime(it, locale) } ?.let { DateUtilities.getLongDateStringWithTime(it, locale) }
?: getString(R.string.last_backup_never) ?: getString(R.string.last_backup_never)
) )
} }
private fun updateDriveBackup(timestamp: Long?) { private fun updateDriveBackup(timestamp: Long? = viewModel.lastDriveBackup.value) {
findPreference(R.string.google_drive_backup).summary = val pref = findPreference(R.string.google_drive_backup)
if (viewModel.staleRemoteBackup) {
pref.setIcon(R.drawable.ic_outline_error_outline_24px)
tintIcons(pref, requireContext().getColor(R.color.overdue))
} else {
pref.icon = null
}
pref.summary =
getString( getString(
R.string.last_backup, R.string.last_backup,
timestamp timestamp
?.takeIf { it >= 0 }
?.let { DateUtilities.getLongDateStringWithTime(it, locale) } ?.let { DateUtilities.getLongDateStringWithTime(it, locale) }
?: getString(R.string.last_backup_never) ?: getString(R.string.last_backup_never)
) )
} }
private fun updateAndroidBackup(timestamp: Long?) { private fun updateAndroidBackup(timestamp: Long? = viewModel.lastAndroidBackup.value) {
findPreference(R.string.p_backups_android_backup_enabled).summary = val pref = findPreference(R.string.p_backups_android_backup_enabled) as SwitchPreferenceCompat
if (viewModel.staleRemoteBackup) {
pref.setIcon(R.drawable.ic_outline_error_outline_24px)
tintIcons(pref, requireContext().getColor(R.color.overdue))
} else {
pref.icon = null
}
pref.summary =
getString( getString(
R.string.last_backup, R.string.last_backup,
timestamp timestamp
?.takeIf { it >= 0 }
?.let { DateUtilities.getLongDateStringWithTime(it, locale) } ?.let { DateUtilities.getLongDateStringWithTime(it, locale) }
?: getString(R.string.last_backup_never) ?: getString(R.string.last_backup_never)
) )
@ -107,6 +144,7 @@ class Backups : InjectingPreferenceFragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
updateWarnings()
updateDriveAccount() updateDriveAccount()
val driveBackup = findPreference(R.string.google_drive_backup) as SwitchPreferenceCompat val driveBackup = findPreference(R.string.google_drive_backup) as SwitchPreferenceCompat
@ -114,6 +152,13 @@ class Backups : InjectingPreferenceFragment() {
driveBackup.isChecked = driveAccount != null driveBackup.isChecked = driveAccount != null
} }
private fun updateWarnings() {
updateLastBackup()
updateDriveBackup()
updateAndroidBackup()
updateBackupDirectory()
}
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,
@ -171,6 +216,14 @@ class Backups : InjectingPreferenceFragment() {
} }
} }
private fun onAndroidBackupCheckChanged(preference: Preference, newValue: Any?): Boolean {
if (newValue is Boolean) {
(preference as SwitchPreferenceCompat).isChecked = newValue
updateAndroidBackup()
}
return true
}
private fun onGoogleDriveCheckChanged(preference: Preference, newValue: Any?) = when { private fun onGoogleDriveCheckChanged(preference: Preference, newValue: Any?) = when {
newValue as Boolean -> { newValue as Boolean -> {
requestGoogleDriveLogin() requestGoogleDriveLogin()
@ -178,8 +231,10 @@ class Backups : InjectingPreferenceFragment() {
} }
else -> { else -> {
preference.summary = null preference.summary = null
preference.icon = null
preferences.setString(R.string.p_google_drive_backup_account, null) preferences.setString(R.string.p_google_drive_backup_account, null)
updateDriveAccount() updateDriveAccount()
viewModel.updateDriveBackup()
true true
} }
} }
@ -203,19 +258,21 @@ class Backups : InjectingPreferenceFragment() {
} }
} }
private fun initializeBackupDirectory() {
findPreference(R.string.p_backup_dir)
.setOnPreferenceClickListener {
FileHelper.newDirectoryPicker(
this, REQUEST_CODE_BACKUP_DIR, preferences.backupDirectory
)
false
}
updateBackupDirectory()
}
private fun updateBackupDirectory() { private fun updateBackupDirectory() {
findPreference(R.string.p_backup_dir).summary = val pref = findPreference(R.string.p_backup_dir)
FileHelper.uri2String(preferences.backupDirectory) val location = FileHelper.uri2String(preferences.backupDirectory)
pref.summary = location
if (preferences.showBackupWarnings() && viewModel.usingPrivateStorage) {
pref.setIcon(R.drawable.ic_outline_error_outline_24px)
tintIcons(pref, requireContext().getColor(R.color.overdue))
pref.summary = """
$location
${requireContext().getString(R.string.backup_location_warning, FileHelper.uri2String(preferences.externalStorage))}
""".trimIndent()
} else {
pref.icon = null
pref.summary = location
}
} }
} }

@ -1,10 +1,14 @@
package org.tasks.preferences.fragments package org.tasks.preferences.fragments
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.R import org.tasks.R
import org.tasks.injection.InjectingPreferenceFragment import org.tasks.injection.InjectingPreferenceFragment
import org.tasks.preferences.IconPreference
import org.tasks.preferences.Preferences
import org.tasks.preferences.PreferencesViewModel
import org.tasks.widget.AppWidgetManager import org.tasks.widget.AppWidgetManager
import javax.inject.Inject import javax.inject.Inject
@ -12,12 +16,38 @@ import javax.inject.Inject
class MainSettingsFragment : InjectingPreferenceFragment() { class MainSettingsFragment : InjectingPreferenceFragment() {
@Inject lateinit var appWidgetManager: AppWidgetManager @Inject lateinit var appWidgetManager: AppWidgetManager
@Inject lateinit var preferences: Preferences
private val viewModel: PreferencesViewModel by activityViewModels()
override fun getPreferenceXml() = R.xml.preferences override fun getPreferenceXml() = R.xml.preferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.lastBackup.observe(this) { updateBackupWarning() }
viewModel.lastAndroidBackup.observe(this) { updateBackupWarning() }
viewModel.lastDriveBackup.observe(this) { updateBackupWarning() }
}
override fun onResume() {
super.onResume()
updateBackupWarning()
}
override suspend fun setupPreferences(savedInstanceState: Bundle?) { override suspend fun setupPreferences(savedInstanceState: Bundle?) {
requires(BuildConfig.DEBUG, R.string.debug) requires(BuildConfig.DEBUG, R.string.debug)
requires(appWidgetManager.widgetIds.isNotEmpty(), R.string.widget_settings) requires(appWidgetManager.widgetIds.isNotEmpty(), R.string.widget_settings)
} }
private fun updateBackupWarning() {
val backupWarning =
preferences.showBackupWarnings()
&& (viewModel.usingPrivateStorage
|| viewModel.staleLocalBackup
|| viewModel.staleRemoteBackup)
(findPreference(R.string.backup_BPr_header) as IconPreference).iconVisible = backupWarning
}
} }

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2015 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:gravity="center_vertical"
android:paddingLeft="?android:attr/listPreferredItemPaddingLeft"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingRight="?android:attr/listPreferredItemPaddingRight"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="?android:attr/selectableItemBackground"
android:clipToPadding="false"
android:baselineAligned="false">
<include layout="@layout/image_frame"/>
<RelativeLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:ellipsize="marquee"/>
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignLeft="@android:id/title"
android:layout_alignStart="@android:id/title"
android:layout_gravity="start"
android:textAlignment="viewStart"
android:textColor="?android:attr/textColorSecondary"
android:maxLines="10"
style="@style/PreferenceSummaryTextStyle"/>
</RelativeLayout>
<!-- Preference should place its actual preference widget here. -->
<ImageView
android:src="@drawable/ic_outline_error_outline_24px"
android:id="@+id/preference_icon"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="end|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="0dp"
android:alpha="@dimen/alpha_primary"
android:visibility="gone"
app:tint="@color/overdue"/>
</LinearLayout>

@ -374,6 +374,7 @@
<string name="p_just_updated">just_updated</string> <string name="p_just_updated">just_updated</string>
<string name="p_clicked_rate">clicked_rate</string> <string name="p_clicked_rate">clicked_rate</string>
<string name="p_backups_enabled">backups_enabled</string> <string name="p_backups_enabled">backups_enabled</string>
<string name="p_backups_ignore_warnings">backups_ignore_warnings</string>
<string name="p_backups_android_backup_enabled">backups_android_backup_enabled</string> <string name="p_backups_android_backup_enabled">backups_android_backup_enabled</string>
<string name="p_backups_android_backup_last">backups_android_backup_last</string> <string name="p_backups_android_backup_last">backups_android_backup_last</string>
<string name="p_current_version">cv</string> <string name="p_current_version">cv</string>

@ -644,4 +644,7 @@ File %1$s contained %2$s.\n\n
<string name="account">Account</string> <string name="account">Account</string>
<string name="foreground_location">Foreground location</string> <string name="foreground_location">Foreground location</string>
<string name="background_location">Background location</string> <string name="background_location">Background location</string>
<string name="backups_ignore_warnings">Ignore warnings</string>
<string name="backups_ignore_warnings_summary">Ignore backup warnings if you do not need backups or have your own backup solution</string>
<string name="backup_location_warning">WARNING: Files located in %s will be deleted if Tasks is uninstalled! Please choose a custom location to prevent Android from deleting your files.</string>
</resources> </resources>

@ -32,7 +32,9 @@
app:icon="@drawable/ic_outline_menu_24px" app:icon="@drawable/ic_outline_menu_24px"
app:title="@string/navigation_drawer" /> app:title="@string/navigation_drawer" />
<Preference <org.tasks.preferences.IconPreference
android:key="@string/backup_BPr_header"
android:layout="@layout/preference_icon"
app:fragment="org.tasks.preferences.fragments.Backups" app:fragment="org.tasks.preferences.fragments.Backups"
app:icon="@drawable/ic_outline_sd_storage_24px" app:icon="@drawable/ic_outline_sd_storage_24px"
app:title="@string/backup_BPr_header" /> app:title="@string/backup_BPr_header" />

@ -66,4 +66,15 @@
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory
android:title="@string/preferences_advanced">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="@string/p_backups_ignore_warnings"
android:title="@string/backups_ignore_warnings"
android:summary="@string/backups_ignore_warnings_summary" />
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>
Loading…
Cancel
Save