From ee3d7a9a35e736efa591899f36a38776055c735a Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 15 Oct 2020 16:12:53 -0300 Subject: [PATCH] Implement new workflow for scoped storage backup selection. --- app/build.gradle | 5 +- .../ApplicationPreferencesActivity.java | 5 + .../securesms/backup/BackupDialog.java | 70 ++++- .../securesms/backup/FullBackupExporter.java | 37 ++- .../securesms/backup/FullBackupImporter.java | 29 +- .../WorkManagerFactoryMappings.java | 2 + .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/jobs/LocalBackupJob.java | 7 +- .../securesms/jobs/LocalBackupJobApi29.java | 189 +++++++++++++ .../keyvalue/MiscellaneousValues.java | 4 + .../securesms/keyvalue/SettingsValues.java | 24 ++ .../BackupsPreferenceFragment.java | 259 ++++++++++++++++++ .../preferences/ChatsPreferenceFragment.java | 90 +----- .../fragments/ChooseBackupFragment.java | 76 +++++ .../fragments/RestoreBackupFragment.java | 144 ++++++++-- .../fragments/WelcomeFragment.java | 82 +++++- .../securesms/service/WebRtcCallService.java | 12 - .../securesms/util/BackupUtil.java | 144 +++++++++- .../securesms/util/TextSecurePreferences.java | 3 +- .../webrtc/IncomingPstnCallReceiver.java | 78 ------ .../primary_action_button_background.xml | 19 ++ .../res/drawable/ic_backup_outline_60.xml | 10 + .../res/drawable/ic_folder_outline_24.xml | 10 + .../main/res/drawable/ic_folder_solid_24.xml | 9 + .../primary_action_button_background.xml | 5 + .../layout/backup_choose_location_dialog.xml | 23 ++ .../res/layout/backup_enable_dialog_v29.xml | 126 +++++++++ app/src/main/res/layout/fragment_backups.xml | 180 ++++++++++++ .../fragment_registration_choose_backup.xml | 71 +++++ .../layout/fragment_registration_welcome.xml | 26 +- app/src/main/res/navigation/registration.xml | 39 +++ app/src/main/res/values/attrs.xml | 4 + app/src/main/res/values/strings.xml | 38 +++ app/src/main/res/values/themes.xml | 8 + app/src/main/res/xml/preferences_chats.xml | 21 +- .../sync/FuzzyPhoneNumberHelperTest.java | 5 +- .../groups/v2/ProfileKeySetTest.java | 3 +- .../securesms/util/SqlUtilTest.java | 3 +- app/src/test/resources/robolectric.properties | 1 + 39 files changed, 1582 insertions(+), 280 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java create mode 100644 app/src/main/res/drawable-v21/primary_action_button_background.xml create mode 100644 app/src/main/res/drawable/ic_backup_outline_60.xml create mode 100644 app/src/main/res/drawable/ic_folder_outline_24.xml create mode 100644 app/src/main/res/drawable/ic_folder_solid_24.xml create mode 100644 app/src/main/res/drawable/primary_action_button_background.xml create mode 100644 app/src/main/res/layout/backup_choose_location_dialog.xml create mode 100644 app/src/main/res/layout/backup_enable_dialog_v29.xml create mode 100644 app/src/main/res/layout/fragment_backups.xml create mode 100644 app/src/main/res/layout/fragment_registration_choose_backup.xml create mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/build.gradle b/app/build.gradle index eecef55104..8b62eeafad 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -386,10 +386,11 @@ dependencies { testImplementation 'org.powermock:powermock-classloading-xstream:1.7.4' testImplementation 'androidx.test:core:1.2.0' - testImplementation ('org.robolectric:robolectric:4.2') { + testImplementation ('org.robolectric:robolectric:4.4') { exclude group: 'com.google.protobuf', module: 'protobuf-java' } - testImplementation 'org.robolectric:shadows-multidex:4.2' + testImplementation 'org.robolectric:shadows-multidex:4.4' + testImplementation 'org.hamcrest:hamcrest:2.2' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java index ca252ff426..e8ac440acb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java @@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.preferences.AdvancedPreferenceFragment; import org.thoughtcrime.securesms.preferences.AppProtectionPreferenceFragment; import org.thoughtcrime.securesms.preferences.AppearancePreferenceFragment; +import org.thoughtcrime.securesms.preferences.BackupsPreferenceFragment; import org.thoughtcrime.securesms.preferences.ChatsPreferenceFragment; import org.thoughtcrime.securesms.preferences.CorrectedPreferenceFragment; import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment; @@ -64,6 +65,8 @@ import org.thoughtcrime.securesms.util.ThemeUtil; public class ApplicationPreferencesActivity extends PassphraseRequiredActivity implements SharedPreferences.OnSharedPreferenceChangeListener { + public static final String LAUNCH_TO_BACKUPS_FRAGMENT = "launch.to.backups.fragment"; + @SuppressWarnings("unused") private static final String TAG = ApplicationPreferencesActivity.class.getSimpleName(); @@ -96,6 +99,8 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity if (getIntent() != null && getIntent().getCategories() != null && getIntent().getCategories().contains("android.intent.category.NOTIFICATION_PREFERENCES")) { initFragment(android.R.id.content, new NotificationsPreferenceFragment()); + } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_BACKUPS_FRAGMENT, false)) { + initFragment(android.R.id.content, new BackupsPreferenceFragment()); } else if (icicle == null) { initFragment(android.R.id.content, new ApplicationPreferenceFragment()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java index 9d69056a8b..2715311d54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java @@ -4,6 +4,8 @@ package org.thoughtcrime.securesms.backup; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.content.Intent; +import android.net.Uri; import android.view.LayoutInflater; import android.view.View; import android.widget.Button; @@ -13,10 +15,15 @@ import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment; import org.thoughtcrime.securesms.service.LocalBackupListener; import org.thoughtcrime.securesms.util.BackupUtil; @@ -24,28 +31,53 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import java.util.Objects; + public class BackupDialog { - public static void showEnableBackupDialog(@NonNull Context context, @NonNull SwitchPreferenceCompat preference) { + private static final String TAG = Log.tag(BackupDialog.class); + + public static void showEnableBackupDialog(@NonNull Context context, + @Nullable Intent backupDirectorySelectionIntent, + @Nullable String backupDirectoryDisplayName, + @NonNull Runnable onBackupsEnabled) + { String[] password = BackupUtil.generateBackupPassphrase(); AlertDialog dialog = new AlertDialog.Builder(context) .setTitle(R.string.BackupDialog_enable_local_backups) - .setView(R.layout.backup_enable_dialog) + .setView(backupDirectorySelectionIntent != null ? R.layout.backup_enable_dialog_v29 : R.layout.backup_enable_dialog) .setPositiveButton(R.string.BackupDialog_enable_backups, null) .setNegativeButton(android.R.string.cancel, null) .create(); dialog.setOnShowListener(created -> { + if (backupDirectoryDisplayName != null) { + TextView folderName = dialog.findViewById(R.id.backup_enable_dialog_folder_name); + if (folderName != null) { + folderName.setText(backupDirectoryDisplayName); + } + } + Button button = ((AlertDialog) created).getButton(AlertDialog.BUTTON_POSITIVE); button.setOnClickListener(v -> { CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check); if (confirmationCheckBox.isChecked()) { + if (backupDirectorySelectionIntent != null && backupDirectorySelectionIntent.getData() != null) { + Uri backupDirectoryUri = backupDirectorySelectionIntent.getData(); + int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + SignalStore.settings().setSignalBackupDirectory(backupDirectoryUri); + context.getContentResolver() + .takePersistableUriPermission(backupDirectoryUri, takeFlags); + } + BackupPassphrase.set(context, Util.join(password, " ")); TextSecurePreferences.setNextBackupTime(context, 0); TextSecurePreferences.setBackupEnabled(context, true); LocalBackupListener.schedule(context); - preference.setChecked(true); + onBackupsEnabled.run(); created.dismiss(); } else { Toast.makeText(context, R.string.BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box, Toast.LENGTH_LONG).show(); @@ -76,16 +108,38 @@ public class BackupDialog { } - public static void showDisableBackupDialog(@NonNull Context context, @NonNull SwitchPreferenceCompat preference) { + @RequiresApi(29) + public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, int requestCode) { + new AlertDialog.Builder(fragment.requireContext()) + .setView(R.layout.backup_choose_location_dialog) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + }) + .setPositiveButton(R.string.BackupDialog_choose_folder, ((dialog, which) -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_READ_URI_PERMISSION); + + fragment.startActivityForResult(intent, requestCode); + + dialog.dismiss(); + })) + .create() + .show(); + } + + public static void showDisableBackupDialog(@NonNull Context context, @NonNull Runnable onBackupsDisabled) { new AlertDialog.Builder(context) .setTitle(R.string.BackupDialog_delete_backups) .setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> { - BackupPassphrase.set(context, null); - TextSecurePreferences.setBackupEnabled(context, false); - BackupUtil.deleteAllBackups(); - preference.setChecked(false); + BackupUtil.disableBackups(context); + + onBackupsDisabled.run(); }) .create() .show(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java index 594232b299..3e7fe6b023 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.backup; import android.content.Context; import android.database.Cursor; +import android.text.TextUtils; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.text.TextUtils; +import androidx.annotation.RequiresApi; +import androidx.documentfile.provider.DocumentFile; import com.annimon.stream.function.Consumer; import com.annimon.stream.function.Predicate; @@ -50,6 +53,7 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.Set; import javax.crypto.BadPaddingException; @@ -84,7 +88,32 @@ public class FullBackupExporter extends FullBackupBase { @NonNull String passphrase) throws IOException { - BackupFrameOutputStream outputStream = new BackupFrameOutputStream(output, passphrase); + try (OutputStream outputStream = new FileOutputStream(output)) { + internalExport(context, attachmentSecret, input, outputStream, passphrase); + } + } + + @RequiresApi(29) + public static void export(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull DocumentFile output, + @NonNull String passphrase) + throws IOException + { + try (OutputStream outputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(output.getUri()))) { + internalExport(context, attachmentSecret, input, outputStream, passphrase); + } + } + + private static void internalExport(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull OutputStream fileOutputStream, + @NonNull String passphrase) + throws IOException + { + BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase); int count = 0; try { @@ -322,7 +351,7 @@ public class FullBackupExporter extends FullBackupBase { private byte[] iv; private int counter; - private BackupFrameOutputStream(@NonNull File output, @NonNull String passphrase) throws IOException { + private BackupFrameOutputStream(@NonNull OutputStream output, @NonNull String passphrase) throws IOException { try { byte[] salt = Util.getSecretBytes(32); byte[] key = getBackupKey(passphrase, salt); @@ -334,7 +363,7 @@ public class FullBackupExporter extends FullBackupBase { this.cipher = Cipher.getInstance("AES/CTR/NoPadding"); this.mac = Mac.getInstance("HmacSHA256"); - this.outputStream = new FileOutputStream(output); + this.outputStream = output; this.iv = Util.getSecretBytes(16); this.counter = Conversions.byteArrayToInt(iv); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java index 8a3e616a1c..7012915f0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -6,9 +6,11 @@ import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; -import androidx.annotation.NonNull; +import android.net.Uri; import android.util.Pair; +import androidx.annotation.NonNull; + import net.sqlcipher.database.SQLiteDatabase; import org.greenrobot.eventbus.EventBus; @@ -25,8 +27,8 @@ import org.thoughtcrime.securesms.database.SearchDatabase; import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.profiles.AvatarHelper; -import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.Conversions; import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.Util; @@ -36,7 +38,6 @@ import org.whispersystems.libsignal.util.ByteUtil; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -46,6 +47,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; @@ -61,13 +63,14 @@ public class FullBackupImporter extends FullBackupBase { private static final String TAG = FullBackupImporter.class.getSimpleName(); public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, - @NonNull SQLiteDatabase db, @NonNull File file, @NonNull String passphrase) + @NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase) throws IOException { - BackupRecordInputStream inputStream = new BackupRecordInputStream(file, passphrase); - int count = 0; + int count = 0; + + try (InputStream is = getInputStream(context, uri)) { + BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase); - try { db.beginTransaction(); dropAllTables(db); @@ -93,6 +96,14 @@ public class FullBackupImporter extends FullBackupBase { EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count)); } + private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{ + if (BackupUtil.isUserSelectionRequired(context)) { + return Objects.requireNonNull(context.getContentResolver().openInputStream(uri)); + } else { + return new FileInputStream(new File(Objects.requireNonNull(uri.getPath()))); + } + } + private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException { if (version.getVersion() > db.getVersion()) { throw new DatabaseDowngradeException(db.getVersion(), version.getVersion()); @@ -221,9 +232,9 @@ public class FullBackupImporter extends FullBackupBase { private byte[] iv; private int counter; - private BackupRecordInputStream(@NonNull File file, @NonNull String passphrase) throws IOException { + private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException { try { - this.in = new FileInputStream(file); + this.in = in; byte[] headerLengthBytes = new byte[4]; Util.readFully(in, headerLengthBytes); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerFactoryMappings.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerFactoryMappings.java index 9f90d821ac..55f62d2e36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerFactoryMappings.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerFactoryMappings.java @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.jobs.FailingJob; import org.thoughtcrime.securesms.jobs.FcmRefreshJob; import org.thoughtcrime.securesms.jobs.LocalBackupJob; +import org.thoughtcrime.securesms.jobs.LocalBackupJobApi29; import org.thoughtcrime.securesms.jobs.MmsDownloadJob; import org.thoughtcrime.securesms.jobs.MmsReceiveJob; import org.thoughtcrime.securesms.jobs.MmsSendJob; @@ -60,6 +61,7 @@ public class WorkManagerFactoryMappings { put("DirectoryRefreshJob", DirectoryRefreshJob.KEY); put("FcmRefreshJob", FcmRefreshJob.KEY); put("LocalBackupJob", LocalBackupJob.KEY); + put("LocalBackupJobApi29", LocalBackupJobApi29.KEY); put("MmsDownloadJob", MmsDownloadJob.KEY); put("MmsReceiveJob", MmsReceiveJob.KEY); put("MmsSendJob", MmsSendJob.KEY); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 9edb14d7fd..c11a5f3f2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -71,6 +71,7 @@ public final class JobManagerFactories { put(KbsEnclaveMigrationWorkerJob.KEY, new KbsEnclaveMigrationWorkerJob.Factory()); put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); + put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory()); put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory()); put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory()); put(MmsSendJob.KEY, new MmsSendJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java index c55de2c2e1..d0bbfa0473 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.jobs; import android.Manifest; +import android.content.Context; import androidx.annotation.NonNull; @@ -53,7 +54,11 @@ public final class LocalBackupJob extends BaseJob { parameters.addConstraint(ChargingConstraint.KEY); } - jobManager.add(new LocalBackupJob(parameters.build())); + if (BackupUtil.isUserSelectionRequired(ApplicationDependencies.getApplication())) { + jobManager.add(new LocalBackupJobApi29(parameters.build())); + } else { + jobManager.add(new LocalBackupJob(parameters.build())); + } } private LocalBackupJob(@NonNull Job.Parameters parameters) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java new file mode 100644 index 0000000000..d89f47e611 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.documentfile.provider.DocumentFile; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.backup.FullBackupExporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.BackupUtil; + +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.UUID; + +/** + * Backup Job for installs requiring Scoped Storage. + * + * @see LocalBackupJob#enqueue(boolean) + */ +public final class LocalBackupJobApi29 extends BaseJob { + + public static final String KEY = "LocalBackupJobApi29"; + + private static final String TAG = Log.tag(LocalBackupJobApi29.class); + + private static final short BACKUP_FAILED_ID = 31321; + + public static final String TEMP_BACKUP_FILE_PREFIX = ".backup"; + public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp"; + + LocalBackupJobApi29(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + Log.i(TAG, "Executing backup job..."); + + NotificationManagerCompat.from(context).cancel(BACKUP_FAILED_ID); + + if (!BackupUtil.isUserSelectionRequired(context)) { + throw new IOException("Wrong backup job!"); + } + + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null || backupDirectoryUri.getPath() == null) { + throw new IOException("Backup Directory has not been selected!"); + } + + try (NotificationController notification = GenericForegroundService.startForegroundTask(context, + context.getString(R.string.LocalBackupJob_creating_backup), + NotificationChannels.BACKUPS, + R.drawable.ic_signal_backup)) + { + notification.setIndeterminateProgress(); + + String backupPassword = BackupPassphrase.get(context); + DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri); + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + String fileName = String.format("signal-%s.backup", timestamp); + + if (backupDirectory == null || !backupDirectory.canWrite()) { + BackupUtil.disableBackups(context); + postBackupsDisabledNotification(); + throw new IOException("Cannot write to backup directory location."); + } + + deleteOldTemporaryBackups(backupDirectory); + + if (backupDirectory.findFile(fileName) != null) { + throw new IOException("Backup file already exists!"); + } + + String temporaryName = String.format(Locale.US, "%s%s%s", TEMP_BACKUP_FILE_PREFIX, UUID.randomUUID(), TEMP_BACKUP_FILE_SUFFIX); + DocumentFile temporaryFile = backupDirectory.createFile("application/octet-stream", temporaryName); + + if (temporaryFile == null) { + throw new IOException("Failed to create temporary backup file."); + } + + if (backupPassword == null) { + throw new IOException("Backup password is null"); + } + + try { + FullBackupExporter.export(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + DatabaseFactory.getBackupDatabase(context), + temporaryFile, + backupPassword); + + if (!temporaryFile.renameTo(fileName)) { + Log.w(TAG, "Failed to rename temp file"); + throw new IOException("Renaming temporary backup file failed!"); + } + } finally { + DocumentFile fileToCleanUp = backupDirectory.findFile(temporaryName); + if (fileToCleanUp != null) { + if (fileToCleanUp.delete()) { + Log.w(TAG, "Backup failed. Deleted temp file"); + } else { + Log.w(TAG, "Backup failed. Failed to delete temp file " + temporaryName); + } + } + } + + BackupUtil.deleteOldBackups(); + } + } + + private static void deleteOldTemporaryBackups(@NonNull DocumentFile backupDirectory) { + for (DocumentFile file : backupDirectory.listFiles()) { + if (file.isFile()) { + String name = file.getName(); + if (name != null && name.startsWith(TEMP_BACKUP_FILE_PREFIX) && name.endsWith(TEMP_BACKUP_FILE_SUFFIX)) { + if (file.delete()) { + Log.w(TAG, "Deleted old temporary backup file"); + } else { + Log.w(TAG, "Could not delete old temporary backup file"); + } + } + } + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + private void postBackupsDisabledNotification() { + Intent intent = new Intent(context, ApplicationPreferencesActivity.class); + + intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_BACKUPS_FRAGMENT, true); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, intent, 0); + Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.BACKUPS) + .setSmallIcon(R.drawable.ic_signal_backup) + .setContentTitle(context.getString(R.string.LocalBackupJobApi29_backups_disabled)) + .setContentText(context.getString(R.string.LocalBackupJobApi29_your_backup_directory_has_been_deleted_or_moved)) + .setContentIntent(pendingIntent) + .build(); + + NotificationManagerCompat.from(context) + .notify(BACKUP_FAILED_ID, backupFailedNotification); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull + LocalBackupJobApi29 create(@NonNull Parameters parameters, @NonNull Data data) { + return new LocalBackupJobApi29(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java index e3ceecaac0..c7116a6167 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -1,6 +1,10 @@ package org.thoughtcrime.securesms.keyvalue; +import android.net.Uri; +import android.text.TextUtils; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; public final class MiscellaneousValues extends SignalStoreValues { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java index ed14f118d6..a089c6e819 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java @@ -1,12 +1,18 @@ package org.thoughtcrime.securesms.keyvalue; +import android.net.Uri; +import android.text.TextUtils; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; public final class SettingsValues extends SignalStoreValues { public static final String LINK_PREVIEWS = "settings.link_previews"; public static final String KEEP_MESSAGES_DURATION = "settings.keep_messages_duration"; + private static final String SIGNAL_BACKUP_DIRECTORY = "settings.signal.backup.directory"; + public static final String THREAD_TRIM_LENGTH = "pref_trim_length"; public static final String THREAD_TRIM_ENABLED = "pref_trim_threads"; @@ -53,4 +59,22 @@ public final class SettingsValues extends SignalStoreValues { putInteger(THREAD_TRIM_LENGTH, length); } + public void setSignalBackupDirectory(@NonNull Uri uri) { + putString(SIGNAL_BACKUP_DIRECTORY, uri.toString()); + } + + public @Nullable + Uri getSignalBackupDirectory() { + String uri = getString(SIGNAL_BACKUP_DIRECTORY, ""); + + if (TextUtils.isEmpty(uri)) { + return null; + } else { + return Uri.parse(uri); + } + } + + public void clearSignalBackupDirectory() { + putString(SIGNAL_BACKUP_DIRECTORY, null); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java new file mode 100644 index 0000000000..ec1dd5af57 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java @@ -0,0 +1,259 @@ +package org.thoughtcrime.securesms.preferences; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.text.HtmlCompat; +import androidx.documentfile.provider.DocumentFile; +import androidx.fragment.app.Fragment; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupDialog; +import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.jobs.LocalBackupJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; +import java.util.Objects; + +public class BackupsPreferenceFragment extends Fragment { + + private static final String TAG = Log.tag(BackupsPreferenceFragment.class); + + private static final short CHOOSE_BACKUPS_LOCATION_REQUEST_CODE = 26212; + + private View create; + private View folder; + private View verify; + private TextView toggle; + private TextView info; + private TextView summary; + private TextView folderName; + private ProgressBar progress; + private TextView progressSummary; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_backups, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + create = view.findViewById(R.id.fragment_backup_create); + folder = view.findViewById(R.id.fragment_backup_folder); + verify = view.findViewById(R.id.fragment_backup_verify); + toggle = view.findViewById(R.id.fragment_backup_toggle); + info = view.findViewById(R.id.fragment_backup_info); + summary = view.findViewById(R.id.fragment_backup_create_summary); + folderName = view.findViewById(R.id.fragment_backup_folder_name); + progress = view.findViewById(R.id.fragment_backup_progress); + progressSummary = view.findViewById(R.id.fragment_backup_progress_summary); + + toggle.setOnClickListener(unused -> onToggleClicked()); + create.setOnClickListener(unused -> onCreateClicked()); + verify.setOnClickListener(unused -> BackupDialog.showVerifyBackupPassphraseDialog(requireContext())); + + EventBus.getDefault().register(this); + } + + @SuppressWarnings("ConstantConditions") + @Override + public void onResume() { + super.onResume(); + ((AppCompatActivity) getActivity()).getSupportActionBar().setTitle(R.string.BackupsPreferenceFragment__chat_backups); + + setBackupStatus(); + setBackupSummary(); + setInfo(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + EventBus.getDefault().unregister(this); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == CHOOSE_BACKUPS_LOCATION_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null && data.getData() != null) { + + DocumentFile backupDirectory = DocumentFile.fromTreeUri(requireContext(), data.getData()); + if (backupDirectory == null || !backupDirectory.isDirectory()) { + Log.w(TAG, "Could not open backup directory."); + return; + } + + BackupDialog.showEnableBackupDialog(requireContext(), + data, + backupDirectory.getName(), + this::setBackupsEnabled); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEvent(FullBackupBase.BackupEvent event) { + if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + create.setEnabled(false); + summary.setText(getString(R.string.BackupsPreferenceFragment__in_progress)); + progress.setVisibility(View.VISIBLE); + progressSummary.setVisibility(View.VISIBLE); + progressSummary.setText(getString(R.string.BackupsPreferenceFragment__d_so_far, event.getCount())); + } else if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + create.setEnabled(true); + progress.setVisibility(View.GONE); + progressSummary.setVisibility(View.GONE); + setBackupSummary(); + } + } + + private void setBackupStatus() { + if (TextSecurePreferences.isBackupEnabled(requireContext())) { + if (BackupUtil.canUserAccessBackupDirectory(requireContext())) { + setBackupsEnabled(); + } else { + Log.w(TAG, "Cannot access backup directory. Disabling backups."); + + BackupUtil.disableBackups(requireContext()); + setBackupsDisabled(); + } + } else { + setBackupsDisabled(); + } + } + + private void setBackupSummary() { + summary.setText(getString(R.string.BackupsPreferenceFragment__last_backup, BackupUtil.getLastBackupTime(requireContext(), Locale.getDefault()))); + } + + private void setBackupFolderName() { + folder.setVisibility(View.GONE); + + if (BackupUtil.canUserAccessBackupDirectory(requireContext())) { + if (BackupUtil.isUserSelectionRequired(requireContext()) && + BackupUtil.canUserAccessBackupDirectory(requireContext())) + { + Uri backupUri = Objects.requireNonNull(SignalStore.settings().getSignalBackupDirectory()); + DocumentFile backupFile = Objects.requireNonNull(DocumentFile.fromTreeUri(requireContext(), backupUri)); + + if (backupFile.getName() != null) { + folder.setVisibility(View.VISIBLE); + folderName.setText(backupFile.getName()); + } + } else if (StorageUtil.canWriteInSignalStorageDir()) { + try { + folder.setVisibility(View.VISIBLE); + folderName.setText(StorageUtil.getBackupDirectory().getPath()); + } catch (NoExternalStorageException e) { + Log.w(TAG, "Could not display folder name.", e); + } + } + } + } + + private void setInfo() { + String link = String.format("%s", getString(R.string.backup_support_url), getString(R.string.BackupsPreferenceFragment__learn_more)); + String infoText = getString(R.string.BackupsPreferenceFragment__to_restore_a_backup, link); + + info.setText(HtmlCompat.fromHtml(infoText, 0)); + info.setMovementMethod(LinkMovementMethod.getInstance()); + } + + private void onToggleClicked() { + if (BackupUtil.isUserSelectionRequired(requireContext())) { + onToggleClickedApi29(); + } else { + onToggleClickedLegacy(); + } + } + + @RequiresApi(29) + private void onToggleClickedApi29() { + if (!TextSecurePreferences.isBackupEnabled(requireContext())) { + BackupDialog.showChooseBackupLocationDialog(this, CHOOSE_BACKUPS_LOCATION_REQUEST_CODE); + } else { + BackupDialog.showDisableBackupDialog(requireContext(), this::setBackupsDisabled); + } + } + + private void onToggleClickedLegacy() { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted(() -> { + if (!TextSecurePreferences.isBackupEnabled(requireContext())) { + BackupDialog.showEnableBackupDialog(requireContext(), null, null, this::setBackupsEnabled); + } else { + BackupDialog.showDisableBackupDialog(requireContext(), this::setBackupsDisabled); + } + }) + .withPermanentDenialDialog(getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) + .execute(); + } + + private void onCreateClicked() { + if (BackupUtil.isUserSelectionRequired(requireContext())) { + onCreateClickedApi29(); + } else { + onCreateClickedLegacy(); + } + } + + @RequiresApi(29) + private void onCreateClickedApi29() { + Log.i(TAG, "Queing backup..."); + LocalBackupJob.enqueue(true); + } + + private void onCreateClickedLegacy() { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted(() -> { + Log.i(TAG, "Queuing backup..."); + LocalBackupJob.enqueue(true); + }) + .withPermanentDenialDialog(getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) + .execute(); + } + + private void setBackupsEnabled() { + toggle.setText(R.string.BackupsPreferenceFragment__turn_off); + create.setVisibility(View.VISIBLE); + verify.setVisibility(View.VISIBLE); + setBackupFolderName(); + } + + private void setBackupsDisabled() { + toggle.setText(R.string.BackupsPreferenceFragment__turn_on); + create.setVisibility(View.GONE); + folder.setVisibility(View.GONE); + verify.setVisibility(View.GONE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java index dced0337e4..e9e2bd2502 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -1,33 +1,25 @@ package org.thoughtcrime.securesms.preferences; -import android.Manifest; import android.content.Context; +import android.content.Intent; import android.os.Bundle; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.app.ActivityOptionsCompat; import androidx.preference.ListPreference; import androidx.preference.Preference; import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.backup.BackupDialog; -import org.thoughtcrime.securesms.backup.FullBackupBase.BackupEvent; -import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; -import org.thoughtcrime.securesms.jobs.LocalBackupJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.preferences.widgets.ProgressPreference; -import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Set; public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { @@ -46,16 +38,12 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF) .setOnPreferenceChangeListener(new ListSummaryListener()); - findPreference(TextSecurePreferences.BACKUP_ENABLED) - .setOnPreferenceClickListener(new BackupClickListener()); - findPreference(TextSecurePreferences.BACKUP_NOW) - .setOnPreferenceClickListener(new BackupCreateListener()); - findPreference(TextSecurePreferences.BACKUP_PASSPHRASE_VERIFY) - .setOnPreferenceClickListener(new BackupVerifyListener()); + findPreference(TextSecurePreferences.BACKUP).setOnPreferenceClickListener(unused -> { + goToBackupsPreferenceFragment(); + return true; + }); initializeListSummary((ListPreference) findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF)); - - EventBus.getDefault().register(this); } @Override @@ -68,7 +56,6 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { super.onResume(); ((ApplicationPreferencesActivity)getActivity()).getSupportActionBar().setTitle(R.string.preferences__chats); setMediaDownloadSummaries(); - setBackupSummary(); } @Override @@ -82,24 +69,8 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } - @Subscribe(threadMode = ThreadMode.MAIN) - public void onEvent(BackupEvent event) { - ProgressPreference preference = (ProgressPreference)findPreference(TextSecurePreferences.BACKUP_NOW); - - if (event.getType() == BackupEvent.Type.PROGRESS) { - preference.setEnabled(false); - preference.setSummary(getString(R.string.ChatsPreferenceFragment_in_progress)); - preference.setProgress(event.getCount()); - } else if (event.getType() == BackupEvent.Type.FINISHED) { - preference.setEnabled(true); - preference.setProgressVisible(false); - setBackupSummary(); - } - } - - private void setBackupSummary() { - findPreference(TextSecurePreferences.BACKUP_NOW) - .setSummary(String.format(getString(R.string.ChatsPreferenceFragment_last_backup_s), BackupUtil.getLastBackupTime(getContext(), Locale.getDefault()))); + private void goToBackupsPreferenceFragment() { + ((ApplicationPreferencesActivity) requireActivity()).pushFragment(new BackupsPreferenceFragment()); } private void setMediaDownloadSummaries() { @@ -124,51 +95,6 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { : TextUtils.join(", ", outValues); } - private class BackupClickListener implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(Preference preference) { - Permissions.with(ChatsPreferenceFragment.this) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .ifNecessary() - .onAllGranted(() -> { - if (!((SwitchPreferenceCompat)preference).isChecked()) { - BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference); - } else { - BackupDialog.showDisableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference); - } - }) - .withPermanentDenialDialog(getString(R.string.ChatsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) - .execute(); - - return true; - } - } - - private class BackupCreateListener implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(Preference preference) { - Permissions.with(ChatsPreferenceFragment.this) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .ifNecessary() - .onAllGranted(() -> { - Log.i(TAG, "Starting backup from user"); - LocalBackupJob.enqueue(true); - }) - .withPermanentDenialDialog(getString(R.string.ChatsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) - .execute(); - - return true; - } - } - - private class BackupVerifyListener implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(Preference preference) { - BackupDialog.showVerifyBackupPassphraseDialog(requireContext()); - return true; - } - } - private class MediaDownloadChangeListener implements Preference.OnPreferenceChangeListener { @SuppressWarnings("unchecked") @Override public boolean onPreferenceChange(Preference preference, Object newValue) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java new file mode 100644 index 0000000000..2c5d463eb0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.text.HtmlCompat; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.BackupUtil; + +import java.util.Objects; + +public class ChooseBackupFragment extends BaseRegistrationFragment { + + private static final String TAG = Log.tag(ChooseBackupFragment.class); + + private static final short OPEN_FILE_REQUEST_CODE = 3862; + + private View chooseBackupButton; + private TextView learnMore; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.fragment_registration_choose_backup, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + if (BackupUtil.isUserSelectionRequired(requireContext())) { + chooseBackupButton = view.findViewById(R.id.choose_backup_fragment_button); + chooseBackupButton.setOnClickListener(this::onChooseBackupSelected); + + learnMore = view.findViewById(R.id.choose_backup_fragment_learn_more); + learnMore.setText(HtmlCompat.fromHtml(String.format("%s", getString(R.string.backup_support_url), getString(R.string.ChooseBackupFragment__learn_more)), 0)); + learnMore.setMovementMethod(LinkMovementMethod.getInstance()); + } else { + Log.i(TAG, "User Selection is not required. Skipping."); + Navigation.findNavController(requireView()).navigate(ChooseBackupFragmentDirections.actionSkip()); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == OPEN_FILE_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) { + ChooseBackupFragmentDirections.ActionRestore restore = ChooseBackupFragmentDirections.actionRestore(); + + restore.setUri(data.getData()); + + Navigation.findNavController(requireView()).navigate(restore); + } + } + + @RequiresApi(21) + private void onChooseBackupSelected(@NonNull View view) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + + intent.setType("application/octet-stream"); + + startActivityForResult(intent, OPEN_FILE_REQUEST_CODE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java index 675bbe1b7d..cc0acd75b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.registration.fragments; -import android.Manifest; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; +import android.content.Intent; import android.graphics.Canvas; import android.graphics.Paint; +import android.net.Uri; import android.os.AsyncTask; +import android.os.Build; import android.os.Bundle; import android.text.Editable; import android.text.Spanned; @@ -22,7 +25,9 @@ import android.widget.Toast; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.Lifecycle; import androidx.navigation.Navigation; import com.dd.CircularProgressButton; @@ -40,21 +45,24 @@ import org.thoughtcrime.securesms.backup.FullBackupImporter; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.service.LocalBackupListener; import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import java.io.IOException; import java.util.Locale; +import java.util.Objects; public final class RestoreBackupFragment extends BaseRegistrationFragment { - private static final String TAG = Log.tag(RestoreBackupFragment.class); + private static final String TAG = Log.tag(RestoreBackupFragment.class); + private static final short OPEN_DOCUMENT_TREE_RESULT_CODE = 13782; private TextView restoreBackupSize; private TextView restoreBackupTime; @@ -102,35 +110,68 @@ public final class RestoreBackupFragment extends BaseRegistrationFragment { return; } - if (!Permissions.hasAll(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + RestoreBackupFragmentArgs args = RestoreBackupFragmentArgs.fromBundle(requireArguments()); + if (BackupUtil.isUserSelectionRequired(requireContext()) && args.getUri() != null) { + Log.i(TAG, "Restoring backup from passed uri"); + initializeBackupForUri(view, args.getUri()); + + return; + } + + if (BackupUtil.canUserAccessBackupDirectory(requireContext())) { + initializeBackupDetection(view); + } else { Log.i(TAG, "Skipping backup detection. We don't have the permission."); Navigation.findNavController(view) .navigate(RestoreBackupFragmentDirections.actionSkipNoReturn()); - } else { - initializeBackupDetection(view); } } + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == OPEN_DOCUMENT_TREE_RESULT_CODE && resultCode == Activity.RESULT_OK && data != null && data.getData() != null) { + Uri backupDirectoryUri = data.getData(); + int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + SignalStore.settings().setSignalBackupDirectory(backupDirectoryUri); + requireContext().getContentResolver() + .takePersistableUriPermission(backupDirectoryUri, takeFlags); + + enableBackups(requireContext()); + + Navigation.findNavController(requireView()) + .navigate(RestoreBackupFragmentDirections.actionBackupRestored()); + } + } + + @RequiresApi(29) + private void initializeBackupForUri(@NonNull View view, @NonNull Uri uri) { + getFromUri(requireContext(), uri, backup -> handleBackupInfo(view, backup)); + } + @SuppressLint("StaticFieldLeak") private void initializeBackupDetection(@NonNull View view) { - searchForBackup(backup -> { - Context context = getContext(); - if (context == null) { - Log.i(TAG, "No context on fragment, must have navigated away."); - return; - } + searchForBackup(backup -> handleBackupInfo(view, backup)); + } - if (backup == null) { - Log.i(TAG, "Skipping backup detection. No backup found, or permission revoked since."); - Navigation.findNavController(view) - .navigate(RestoreBackupFragmentDirections.actionNoBackupFound()); - } else { - restoreBackupSize.setText(getString(R.string.RegistrationActivity_backup_size_s, Util.getPrettyFileSize(backup.getSize()))); - restoreBackupTime.setText(getString(R.string.RegistrationActivity_backup_timestamp_s, DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), backup.getTimestamp()))); + private void handleBackupInfo(@NonNull View view, @Nullable BackupUtil.BackupInfo backup) { + Context context = getContext(); + if (context == null) { + Log.i(TAG, "No context on fragment, must have navigated away."); + return; + } - restoreButton.setOnClickListener((v) -> handleRestore(v.getContext(), backup)); - } - }); + if (backup == null) { + Log.i(TAG, "Skipping backup detection. No backup found, or permission revoked since."); + Navigation.findNavController(view) + .navigate(RestoreBackupFragmentDirections.actionNoBackupFound()); + } else { + restoreBackupSize.setText(getString(R.string.RegistrationActivity_backup_size_s, Util.getPrettyFileSize(backup.getSize()))); + restoreBackupTime.setText(getString(R.string.RegistrationActivity_backup_timestamp_s, DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), backup.getTimestamp()))); + + restoreButton.setOnClickListener((v) -> handleRestore(v.getContext(), backup)); + } } interface OnBackupSearchResultListener { @@ -159,6 +200,15 @@ public final class RestoreBackupFragment extends BaseRegistrationFragment { }.execute(); } + @RequiresApi(29) + static void getFromUri(@NonNull Context context, + @NonNull Uri backupUri, + @NonNull OnBackupSearchResultListener listener) + { + SimpleTask.run(() -> BackupUtil.getBackupInfoForUri(context, backupUri), + listener::run); + } + private void handleRestore(@NonNull Context context, @NonNull BackupUtil.BackupInfo backup) { View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null); EditText prompt = view.findViewById(R.id.restore_passphrase_input); @@ -198,19 +248,18 @@ public final class RestoreBackupFragment extends BaseRegistrationFragment { SQLiteDatabase database = DatabaseFactory.getBackupDatabase(context); + BackupPassphrase.set(context, passphrase); FullBackupImporter.importFile(context, AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), database, - backup.getFile(), + backup.getUri(), passphrase); DatabaseFactory.upgradeRestored(context, database); NotificationChannels.restoreContactNotificationChannels(context); - LocalBackupListener.setNextBackupTimeToIntervalFromNow(context); - BackupPassphrase.set(context, passphrase); - TextSecurePreferences.setBackupEnabled(context, true); - LocalBackupListener.schedule(context); + enableBackups(context); + AppInitialization.onPostBackupRestore(context); Log.i(TAG, "Backup restore complete."); @@ -272,11 +321,48 @@ public final class RestoreBackupFragment extends BaseRegistrationFragment { skipRestoreButton.setVisibility(View.INVISIBLE); if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { - Navigation.findNavController(requireView()) - .navigate(RestoreBackupFragmentDirections.actionBackupRestored()); + if (BackupUtil.isUserSelectionRequired(requireContext()) && !BackupUtil.canUserAccessBackupDirectory(requireContext())) { + displayConfirmationDialog(requireContext()); + } else { + Navigation.findNavController(requireView()) + .navigate(RestoreBackupFragmentDirections.actionBackupRestored()); + } } } + private void enableBackups(@NonNull Context context) { + if (BackupUtil.canUserAccessBackupDirectory(context)) { + LocalBackupListener.setNextBackupTimeToIntervalFromNow(context); + TextSecurePreferences.setBackupEnabled(context, true); + LocalBackupListener.schedule(context); + } + } + + @RequiresApi(29) + private void displayConfirmationDialog(@NonNull Context context) { + new AlertDialog.Builder(context) + .setTitle(R.string.RestoreBackupFragment__re_enable_backups) + .setMessage(R.string.RestoreBackupFragment__to_continue_using) + .setPositiveButton(R.string.RestoreBackupFragment__choose_folder, (dialog, which) -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_READ_URI_PERMISSION); + + startActivityForResult(intent, OPEN_DOCUMENT_TREE_RESULT_CODE); + }) + .setNegativeButton(R.string.RestoreBackupFragment__keep_disabled, (dialog, which) -> { + BackupPassphrase.set(context, null); + dialog.dismiss(); + + Navigation.findNavController(requireView()) + .navigate(RestoreBackupFragmentDirections.actionBackupRestored()); + }) + .setCancelable(false) + .show(); + } + private enum BackupImportResult { SUCCESS, FAILURE_VERSION_DOWNGRADE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java index 5f4e2765a4..29369d6333 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java @@ -8,9 +8,12 @@ import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; import androidx.navigation.ActivityNavigator; import androidx.navigation.Navigation; @@ -23,6 +26,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; +import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -32,7 +36,21 @@ public final class WelcomeFragment extends BaseRegistrationFragment { private static final String TAG = Log.tag(WelcomeFragment.class); + private static final String[] PERMISSIONS = { Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.READ_PHONE_STATE }; + private static final String[] PERMISSIONS_API_29 = { Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.READ_PHONE_STATE }; + private static final @StringRes int RATIONALE = R.string.RegistrationActivity_signal_needs_access_to_your_contacts_and_media_in_order_to_connect_with_friends; + private static final @StringRes int RATIONALE_API_29 = R.string.RegistrationActivity_signal_needs_access_to_your_contacts_in_order_to_connect_with_friends; + private static final int[] HEADERS = { R.drawable.ic_contacts_white_48dp, R.drawable.ic_folder_white_48dp }; + private static final int[] HEADERS_API_29 = { R.drawable.ic_contacts_white_48dp }; + private CircularProgressButton continueButton; + private View restoreFromBackup; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, @@ -75,7 +93,16 @@ public final class WelcomeFragment extends BaseRegistrationFragment { continueButton = view.findViewById(R.id.welcome_continue_button); continueButton.setOnClickListener(this::continueClicked); - view.findViewById(R.id.welcome_terms_button).setOnClickListener(v -> onTermsClicked()); + restoreFromBackup = view.findViewById(R.id.welcome_restore_backup); + restoreFromBackup.setOnClickListener(this::restoreFromBackupClicked); + + TextView welcomeTermsButton = view.findViewById(R.id.welcome_terms_button); + welcomeTermsButton.setOnClickListener(v -> onTermsClicked()); + + if (canUserSelectBackup()) { + restoreFromBackup.setVisibility(View.VISIBLE); + welcomeTermsButton.setTextColor(ContextCompat.getColor(requireActivity(), R.color.core_grey_60)); + } } } @@ -85,18 +112,24 @@ public final class WelcomeFragment extends BaseRegistrationFragment { } private void continueClicked(@NonNull View view) { + boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()); + Permissions.with(this) - .request(Manifest.permission.WRITE_CONTACTS, - Manifest.permission.READ_CONTACTS, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.READ_PHONE_STATE) + .request(getContinuePermissions(isUserSelectionRequired)) .ifNecessary() - .withRationaleDialog(getString(R.string.RegistrationActivity_signal_needs_access_to_your_contacts_and_media_in_order_to_connect_with_friends), - R.drawable.ic_contacts_white_48dp, R.drawable.ic_folder_white_48dp) - .onAnyResult(() -> { - gatherInformationAndContinue(continueButton); - }) + .withRationaleDialog(getString(getContinueRationale(isUserSelectionRequired)), getContinueHeaders(isUserSelectionRequired)) + .onAnyResult(() -> gatherInformationAndContinue(continueButton)) + .execute(); + } + + private void restoreFromBackupClicked(@NonNull View view) { + boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()); + + Permissions.with(this) + .request(getContinuePermissions(isUserSelectionRequired)) + .ifNecessary() + .withRationaleDialog(getString(getContinueRationale(isUserSelectionRequired)), getContinueHeaders(isUserSelectionRequired)) + .onAnyResult(() -> gatherInformationAndChooseBackup(continueButton)) .execute(); } @@ -127,6 +160,15 @@ public final class WelcomeFragment extends BaseRegistrationFragment { }); } + private void gatherInformationAndChooseBackup(@NonNull View view) { + TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true); + + initializeNumber(); + + Navigation.findNavController(view) + .navigate(WelcomeFragmentDirections.actionChooseBackup()); + } + @SuppressLint("MissingPermission") private void initializeNumber() { Optional localNumber = Optional.absent(); @@ -149,4 +191,22 @@ public final class WelcomeFragment extends BaseRegistrationFragment { private void onTermsClicked() { CommunicationActions.openBrowserLink(requireContext(), RegistrationConstants.TERMS_AND_CONDITIONS_URL); } + + private boolean canUserSelectBackup() { + return BackupUtil.isUserSelectionRequired(requireContext()) && + !isReregister() && + !TextSecurePreferences.isBackupEnabled(requireContext()); + } + + private static String[] getContinuePermissions(boolean isUserSelectionRequired) { + return isUserSelectionRequired ? PERMISSIONS_API_29 : PERMISSIONS; + } + + private static @StringRes int getContinueRationale(boolean isUserSelectionRequired) { + return isUserSelectionRequired ? RATIONALE_API_29 : RATIONALE; + } + + private static int[] getContinueHeaders(boolean isUserSelectionRequired) { + return isUserSelectionRequired ? HEADERS_API_29 : HEADERS; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java index 877e272514..bc04d493cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java @@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.util.TelephonyUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; -import org.thoughtcrime.securesms.webrtc.IncomingPstnCallReceiver; import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager; import org.thoughtcrime.securesms.webrtc.audio.BluetoothStateManager; import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger; @@ -193,7 +192,6 @@ public class WebRtcCallService extends Service implements CallManager.Observer, private WiredHeadsetStateReceiver wiredHeadsetStateReceiver; private PowerButtonReceiver powerButtonReceiver; private LockManager lockManager; - private IncomingPstnCallReceiver callReceiver; private UncaughtExceptionHandlerManager uncaughtExceptionHandlerManager; @Nullable private CallManager callManager; @@ -220,7 +218,6 @@ public class WebRtcCallService extends Service implements CallManager.Observer, initializeResources(); - registerIncomingPstnCallReceiver(); registerUncaughtExceptionHandler(); registerWiredHeadsetStateReceiver(); @@ -298,10 +295,6 @@ public class WebRtcCallService extends Service implements CallManager.Observer, callManager = null; } - if (callReceiver != null) { - unregisterReceiver(callReceiver); - } - if (uncaughtExceptionHandlerManager != null) { uncaughtExceptionHandlerManager.unregister(); } @@ -363,11 +356,6 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } } - private void registerIncomingPstnCallReceiver() { - callReceiver = new IncomingPstnCallReceiver(); - registerReceiver(callReceiver, new IntentFilter("android.intent.action.PHONE_STATE")); - } - private void registerUncaughtExceptionHandler() { uncaughtExceptionHandlerManager = new UncaughtExceptionHandlerManager(); uncaughtExceptionHandlerManager.registerHandler(new ProximityLockRelease(lockManager)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java index 8bb85c96b7..73d62f61fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java @@ -1,14 +1,24 @@ package org.thoughtcrime.securesms.util; +import android.Manifest; import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.documentfile.provider.DocumentFile; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupPassphrase; import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.permissions.Permissions; import org.whispersystems.libsignal.util.ByteUtil; import java.io.File; @@ -18,6 +28,7 @@ import java.util.Calendar; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Objects; public class BackupUtil { @@ -37,6 +48,24 @@ public class BackupUtil { } } + public static boolean isUserSelectionRequired(@NonNull Context context) { + return Build.VERSION.SDK_INT >= 29 && !Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + public static boolean canUserAccessBackupDirectory(@NonNull Context context) { + if (isUserSelectionRequired(context)) { + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null) { + return false; + } + + DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri); + return backupDirectory != null && backupDirectory.exists() && backupDirectory.canRead() && backupDirectory.canWrite(); + } else { + return Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + } + public static @Nullable BackupInfo getLatestBackup() throws NoExternalStorageException { List backups = getAllBackupsNewestFirst(); @@ -71,17 +100,96 @@ public class BackupUtil { } } + public static void disableBackups(@NonNull Context context) { + BackupPassphrase.set(context, null); + TextSecurePreferences.setBackupEnabled(context, false); + BackupUtil.deleteAllBackups(); + + if (BackupUtil.isUserSelectionRequired(context)) { + Uri backupLocationUri = SignalStore.settings().getSignalBackupDirectory(); + + if (backupLocationUri == null) { + return; + } + + SignalStore.settings().clearSignalBackupDirectory(); + + try { + context.getContentResolver() + .releasePersistableUriPermission(Objects.requireNonNull(backupLocationUri), + Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } catch (SecurityException e) { + Log.w(TAG, "Could not release permissions", e); + } + } + } + private static List getAllBackupsNewestFirst() throws NoExternalStorageException { + if (isUserSelectionRequired(ApplicationDependencies.getApplication())) { + return getAllBackupsNewestFirstApi29(); + } else { + return getAllBackupsNewestFirstLegacy(); + } + } + + @RequiresApi(29) + private static List getAllBackupsNewestFirstApi29() { + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null) { + Log.i(TAG, "Backup directory is not set. Returning an empty list."); + return Collections.emptyList(); + } + + DocumentFile backupDirectory = DocumentFile.fromTreeUri(ApplicationDependencies.getApplication(), backupDirectoryUri); + if (backupDirectory == null || !backupDirectory.exists() || !backupDirectory.canRead()) { + Log.w(TAG, "Backup directory is inaccessible. Returning an empty list."); + return Collections.emptyList(); + } + + DocumentFile[] files = backupDirectory.listFiles(); + List backups = new ArrayList<>(files.length); + + for (DocumentFile file : files) { + if (file.isFile() && file.getName() != null && file.getName().endsWith(".backup")) { + long backupTimestamp = getBackupTimestamp(file.getName()); + + if (backupTimestamp != -1) { + backups.add(new BackupInfo(backupTimestamp, file.length(), file.getUri())); + } + } + } + + Collections.sort(backups, (a, b) -> Long.compare(b.timestamp, a.timestamp)); + + return backups; + } + + @RequiresApi(29) + public static @Nullable BackupInfo getBackupInfoForUri(@NonNull Context context, @NonNull Uri uri) { + DocumentFile documentFile = DocumentFile.fromSingleUri(context, uri); + + if (documentFile != null && documentFile.exists() && documentFile.canRead() && documentFile.canWrite() && documentFile.getName().endsWith(".backup")) { + long backupTimestamp = getBackupTimestamp(documentFile.getName()); + + return new BackupInfo(backupTimestamp, documentFile.length(), documentFile.getUri()); + } else { + Log.w(TAG, "Could not load backup info."); + return null; + } + } + + private static List getAllBackupsNewestFirstLegacy() throws NoExternalStorageException { File backupDirectory = StorageUtil.getBackupDirectory(); File[] files = backupDirectory.listFiles(); List backups = new ArrayList<>(files.length); for (File file : files) { if (file.isFile() && file.getAbsolutePath().endsWith(".backup")) { - long backupTimestamp = getBackupTimestamp(file); + long backupTimestamp = getBackupTimestamp(file.getName()); if (backupTimestamp != -1) { - backups.add(new BackupInfo(backupTimestamp, file.length(), file)); + backups.add(new BackupInfo(backupTimestamp, file.length(), Uri.fromFile(file))); } } } @@ -104,9 +212,8 @@ public class BackupUtil { return result; } - private static long getBackupTimestamp(File backup) { - String name = backup.getName(); - String[] prefixSuffix = name.split("[.]"); + private static long getBackupTimestamp(@NonNull String backupName) { + String[] prefixSuffix = backupName.split("[.]"); if (prefixSuffix.length == 2) { String[] parts = prefixSuffix[0].split("\\-"); @@ -136,12 +243,12 @@ public class BackupUtil { private final long timestamp; private final long size; - private final File file; + private final Uri uri; - BackupInfo(long timestamp, long size, File file) { + BackupInfo(long timestamp, long size, Uri uri) { this.timestamp = timestamp; this.size = size; - this.file = file; + this.uri = uri; } public long getTimestamp() { @@ -152,16 +259,27 @@ public class BackupUtil { return size; } - public File getFile() { - return file; + public Uri getUri() { + return uri; } private void delete() { - Log.i(TAG, "Deleting: " + file.getAbsolutePath()); + DocumentFile document = DocumentFile.fromSingleUri(ApplicationDependencies.getApplication(), uri); + if (document != null && document.exists()) { + Log.i(TAG, "Deleting: " + uri); - if (!file.delete()) { - Log.w(TAG, "Delete failed: " + file.getAbsolutePath()); + if (!document.delete()) { + Log.w(TAG, "Delete failed: " + uri); + } + } else { + File file = new File(uri.toString()); + Log.i(TAG, "Deleting: " + file.getAbsolutePath()); + + if (!file.delete()) { + Log.w(TAG, "Delete failed: " + file.getAbsolutePath()); + } } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 8c388ec075..381cae7ed0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -141,12 +141,11 @@ public class TextSecurePreferences { private static final String ACTIVE_SIGNED_PRE_KEY_ID = "pref_active_signed_pre_key_id"; private static final String NEXT_SIGNED_PRE_KEY_ID = "pref_next_signed_pre_key_id"; + public static final String BACKUP = "pref_backup"; public static final String BACKUP_ENABLED = "pref_backup_enabled"; private static final String BACKUP_PASSPHRASE = "pref_backup_passphrase"; private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase"; private static final String BACKUP_TIME = "pref_backup_next_time"; - public static final String BACKUP_NOW = "pref_backup_create"; - public static final String BACKUP_PASSPHRASE_VERIFY = "pref_backup_passphrase_verify"; public static final String SCREEN_LOCK = "pref_android_screen_lock"; public static final String SCREEN_LOCK_TIMEOUT = "pref_android_screen_lock_timeout"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java deleted file mode 100644 index 03cfa757be..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.thoughtcrime.securesms.webrtc; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Handler; -import android.os.ResultReceiver; -import android.telephony.TelephonyManager; -import org.thoughtcrime.securesms.logging.Log; - -import org.thoughtcrime.securesms.service.WebRtcCallService; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -/** - * Listens for incoming PSTN calls and rejects them if a RedPhone call is already in progress. - * - * Unstable use of reflection employed to gain access to ITelephony. - * - */ -public class IncomingPstnCallReceiver extends BroadcastReceiver { - - private static final String TAG = IncomingPstnCallReceiver.class.getSimpleName(); - - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "Checking incoming call..."); - - if (intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) == null) { - Log.w(TAG, "Telephony event does not contain number..."); - return; - } - - if (!intent.getStringExtra(TelephonyManager.EXTRA_STATE).equals(TelephonyManager.EXTRA_STATE_RINGING)) { - Log.w(TAG, "Telephony event is not state ringing..."); - return; - } - - InCallListener listener = new InCallListener(context, new Handler()); - - WebRtcCallService.isCallActive(context, listener); - } - - private static class InCallListener extends ResultReceiver { - - private final Context context; - - InCallListener(Context context, Handler handler) { - super(handler); - this.context = context.getApplicationContext(); - } - - protected void onReceiveResult(int resultCode, Bundle resultData) { - if (resultCode == 1) { - Log.i(TAG, "Attempting to deny incoming PSTN call."); - - TelephonyManager tm = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); - - try { - Method getTelephony = tm.getClass().getDeclaredMethod("getITelephony"); - getTelephony.setAccessible(true); - Object telephonyService = getTelephony.invoke(tm); - Method endCall = telephonyService.getClass().getDeclaredMethod("endCall"); - endCall.invoke(telephonyService); - Log.i(TAG, "Denied Incoming Call."); - } catch (NoSuchMethodException e) { - Log.w(TAG, "Unable to access ITelephony API", e); - } catch (IllegalAccessException e) { - Log.w(TAG, "Unable to access ITelephony API", e); - } catch (InvocationTargetException e) { - Log.w(TAG, "Unable to access ITelephony API", e); - } - } - } - } -} diff --git a/app/src/main/res/drawable-v21/primary_action_button_background.xml b/app/src/main/res/drawable-v21/primary_action_button_background.xml new file mode 100644 index 0000000000..c9cab10f3b --- /dev/null +++ b/app/src/main/res/drawable-v21/primary_action_button_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_backup_outline_60.xml b/app/src/main/res/drawable/ic_backup_outline_60.xml new file mode 100644 index 0000000000..98266d63f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_outline_60.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_folder_outline_24.xml b/app/src/main/res/drawable/ic_folder_outline_24.xml new file mode 100644 index 0000000000..49a6b2a620 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_outline_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_folder_solid_24.xml b/app/src/main/res/drawable/ic_folder_solid_24.xml new file mode 100644 index 0000000000..f29a69e5dd --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/primary_action_button_background.xml b/app/src/main/res/drawable/primary_action_button_background.xml new file mode 100644 index 0000000000..02cce1012b --- /dev/null +++ b/app/src/main/res/drawable/primary_action_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/backup_choose_location_dialog.xml b/app/src/main/res/layout/backup_choose_location_dialog.xml new file mode 100644 index 0000000000..d474bbce5a --- /dev/null +++ b/app/src/main/res/layout/backup_choose_location_dialog.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/backup_enable_dialog_v29.xml b/app/src/main/res/layout/backup_enable_dialog_v29.xml new file mode 100644 index 0000000000..01d5e540e4 --- /dev/null +++ b/app/src/main/res/layout/backup_enable_dialog_v29.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_backups.xml b/app/src/main/res/layout/fragment_backups.xml new file mode 100644 index 0000000000..0d3a5937d4 --- /dev/null +++ b/app/src/main/res/layout/fragment_backups.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_registration_choose_backup.xml b/app/src/main/res/layout/fragment_registration_choose_backup.xml new file mode 100644 index 0000000000..4525a59d12 --- /dev/null +++ b/app/src/main/res/layout/fragment_registration_choose_backup.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_registration_welcome.xml b/app/src/main/res/layout/fragment_registration_welcome.xml index 4d1ce5459b..956b6668f2 100644 --- a/app/src/main/res/layout/fragment_registration_welcome.xml +++ b/app/src/main/res/layout/fragment_registration_welcome.xml @@ -1,6 +1,7 @@ @@ -47,10 +48,29 @@ android:layout_height="wrap_content" android:layout_marginStart="32dp" android:layout_marginEnd="32dp" - android:layout_marginBottom="@dimen/registration_button_bottom_margin" + android:layout_marginBottom="17dp" app:cpb_textIdle="@string/RegistrationActivity_continue" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@id/welcome_restore_backup" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_goneMarginBottom="@dimen/registration_button_bottom_margin" /> + + \ No newline at end of file diff --git a/app/src/main/res/navigation/registration.xml b/app/src/main/res/navigation/registration.xml index ac06ad38dd..ae841b0147 100644 --- a/app/src/main/res/navigation/registration.xml +++ b/app/src/main/res/navigation/registration.xml @@ -27,6 +27,40 @@ app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ce70d0d78..d4ce35f051 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ https://signal.org/install https://signal.org/donate + https://support.signal.org/hc/articles/360007059752 Yes No @@ -385,6 +386,36 @@ Your profile is end-to-end encrypted. Your profile and changes to it will be visible to your contacts, when you initiate or accept new conversations, and when you join new groups. Set avatar + + Restore from Backup? + Restore your messages and media from a local backup. If you don\'t restore now, you won\'t be able to restore later. + Restore from backup icon + Choose backup + Learn more + + + Re-enable Backups? + To continue using backups, please choose where they should be saved." + Choose folder + Keep disabled + + + Chat backups + Backups are encrypted with a passphrase and stored on your device + Create backup + Last backup: %1$s + Backup folder + Verify backup passphrase + Test your backup passphrase and verify that it matches + Turn on + Turn off + To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup folder. %1$s + Learn more + In progress… + %1$d so far… + Signal requires external storage permission in order to create backups, but it has been permanently denied. Please continue to app settings, select \"Permissions\" and enable \"Storage\". + + Using custom: %s Using default: %s @@ -1267,6 +1298,7 @@ More information Less information Signal needs access to your contacts and media in order to connect with friends, exchange messages, and make secure calls + Signal needs access to your contacts in order to connect with friends, exchange messages, and make secure calls You\'ve made too many attempts to register this number. Please try again later. Unable to connect to service. Please check network connection and try again. To easily verify your phone number, Signal can automatically detect your verification code if you allow Signal to view SMS messages. @@ -2549,6 +2581,8 @@ Unlock to view pending messages Backup passphrase Backups will be saved to external storage and encrypted with the passphrase below. You must have this passphrase in order to restore a backup. + You must have this passphrase in order to restore a backup. + Folder I have written down this passphrase. Without it, I will be unable to restore a backup. Restore backup Skip @@ -2574,6 +2608,8 @@ Delete backups? Disable and delete all local backups? Delete backups + To enable backups, choose a folder. Backups will be saved to this location. + Choose folder Copied to clipboard Enter your backup passphrase to verify Verify @@ -2583,6 +2619,8 @@ Last backup: %s In progress Creating backup… + Backups disabled. + Your backup directory has been deleted or moved. %d messages so far Please enter the verification code sent to %s. Wrong number diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 65c8ca111c..b806e89f03 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -163,6 +163,10 @@ @color/core_grey_75 @color/core_grey_15 + @drawable/ic_folder_outline_24 + @color/core_grey_20 + @color/core_grey_65 + @drawable/insights_modal_background @color/core_grey_10 @color/core_grey_90 @@ -494,6 +498,10 @@ @color/core_grey_15 ?icon_tint + @drawable/ic_folder_solid_24 + @color/core_grey_60 + @color/core_grey_25 + @drawable/insights_modal_background_dark @color/core_grey_60 @color/core_grey_25 diff --git a/app/src/main/res/xml/preferences_chats.xml b/app/src/main/res/xml/preferences_chats.xml index 4d2f658df9..645a7b4098 100644 --- a/app/src/main/res/xml/preferences_chats.xml +++ b/app/src/main/res/xml/preferences_chats.xml @@ -62,26 +62,11 @@ - - - - - + android:title="@string/preferences_chats__chat_backups" /> diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java b/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java index b527b3d35e..adf7209f33 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelperTest.java @@ -6,15 +6,12 @@ import org.thoughtcrime.securesms.contacts.sync.FuzzyPhoneNumberHelper.OutputRes import org.thoughtcrime.securesms.contacts.sync.FuzzyPhoneNumberHelper.OutputResultV2; import org.whispersystems.signalservice.api.util.UuidUtil; +import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; import java.util.Set; import java.util.UUID; -import edu.emory.mathcs.backport.java.util.Arrays; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.thoughtcrime.securesms.testutil.TestHelpers.mapOf; diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java index 35e517a6a3..54dfe0e784 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java @@ -6,10 +6,9 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.testutil.LogRecorder; +import java.util.Collections; import java.util.UUID; -import edu.emory.mathcs.backport.java.util.Collections; - import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java index ee7b17f4cd..ef874393b4 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/util/SqlUtilTest.java @@ -9,8 +9,7 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.util.Arrays; - -import edu.emory.mathcs.backport.java.util.Collections; +import java.util.Collections; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..89a6c8b4c2 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 \ No newline at end of file