diff --git a/res/values/strings.xml b/res/values/strings.xml index 70b662aba8..98298e8f98 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1866,4 +1866,6 @@ Voice Message Details + Failed to activate backups. Please try again or contact support. + diff --git a/res/xml/preferences_chats.xml b/res/xml/preferences_chats.xml index 9d0cac23f9..4b1d69e8f0 100644 --- a/res/xml/preferences_chats.xml +++ b/res/xml/preferences_chats.xml @@ -94,7 +94,7 @@ @@ -102,7 +102,7 @@ android:key="pref_backup_create" android:title="@string/preferences_chats__create_backup" android:persistent="false" - android:dependency="pref_backup_enabled" + android:dependency="pref_backup_enabled_v2" tools:summary="Last backup: 3 days ago" /> diff --git a/src/org/thoughtcrime/securesms/RegistrationActivity.java b/src/org/thoughtcrime/securesms/RegistrationActivity.java index fdabe5d229..42307afe83 100644 --- a/src/org/thoughtcrime/securesms/RegistrationActivity.java +++ b/src/org/thoughtcrime/securesms/RegistrationActivity.java @@ -57,7 +57,7 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.push.AccountManagerFactory; import org.thoughtcrime.securesms.registration.CaptchaActivity; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; -import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.BackupUtilOld; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.Dialogs; import org.thoughtcrime.securesms.util.ServiceUtil; @@ -76,7 +76,6 @@ import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; import org.whispersystems.signalservice.internal.push.LockedException; import java.io.IOException; -import java.lang.ref.WeakReference; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -275,11 +274,11 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif if (getIntent().getBooleanExtra(RE_REGISTRATION_EXTRA, false)) return; - new AsyncTask() { + new AsyncTask() { @Override - protected @Nullable BackupUtil.BackupInfo doInBackground(Void... voids) { + protected @Nullable BackupUtilOld.BackupInfo doInBackground(Void... voids) { try { - return BackupUtil.getLatestBackup(RegistrationActivity.this); + return BackupUtilOld.getLatestBackup(RegistrationActivity.this); } catch (NoExternalStorageException e) { Log.w(TAG, e); return null; @@ -287,7 +286,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif } @Override - protected void onPostExecute(@Nullable BackupUtil.BackupInfo backup) { + protected void onPostExecute(@Nullable BackupUtilOld.BackupInfo backup) { if (backup != null) displayRestoreView(backup); } }.execute(); @@ -304,7 +303,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif } @SuppressLint("StaticFieldLeak") - private void handleRestore(BackupUtil.BackupInfo backup) { + private void handleRestore(BackupUtilOld.BackupInfo backup) { View view = LayoutInflater.from(this).inflate(R.layout.enter_backup_passphrase_dialog, null); EditText prompt = view.findViewById(R.id.restore_passphrase_input); @@ -661,7 +660,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif finish(); } - private void displayRestoreView(@NonNull BackupUtil.BackupInfo backup) { + private void displayRestoreView(@NonNull BackupUtilOld.BackupInfo backup) { title.animate().translationX(title.getWidth()).setDuration(SCENE_TRANSITION_DURATION).setListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { diff --git a/src/org/thoughtcrime/securesms/backup/BackupDialog.java b/src/org/thoughtcrime/securesms/backup/BackupDialog.java index 1da08039f8..177e75e1dd 100644 --- a/src/org/thoughtcrime/securesms/backup/BackupDialog.java +++ b/src/org/thoughtcrime/securesms/backup/BackupDialog.java @@ -1,28 +1,38 @@ package org.thoughtcrime.securesms.backup; - import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; import android.widget.Button; import android.widget.CheckBox; import android.widget.TextView; import android.widget.Toast; -import network.loki.messenger.R; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; -import org.thoughtcrime.securesms.service.LocalBackupListener; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.BackupDirSelector; import org.thoughtcrime.securesms.util.BackupUtil; -import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; -public class BackupDialog { +import java.io.IOException; - public static void showEnableBackupDialog(@NonNull Context context, @NonNull SwitchPreferenceCompat preference) { - String[] password = BackupUtil.generateBackupPassphrase(); - AlertDialog dialog = new AlertDialog.Builder(context) +import network.loki.messenger.R; + +public class BackupDialog { + private static final String TAG = "BackupDialog"; + + public static void showEnableBackupDialog( + @NonNull Context context, + @NonNull SwitchPreferenceCompat preference, + @NonNull BackupDirSelector backupDirSelector) { + + String[] password = BackupUtil.generateBackupPassphrase(); + String passwordSt = Util.join(password, " "); + + AlertDialog dialog = new AlertDialog.Builder(context) .setTitle(R.string.BackupDialog_enable_local_backups) .setView(R.layout.backup_enable_dialog) .setPositiveButton(R.string.BackupDialog_enable_backups, null) @@ -34,12 +44,21 @@ public class BackupDialog { button.setOnClickListener(v -> { CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check); if (confirmationCheckBox.isChecked()) { - BackupPassphrase.set(context, Util.join(password, " ")); - TextSecurePreferences.setBackupEnabled(context, true); - LocalBackupListener.schedule(context); + backupDirSelector.selectBackupDir(true, uri -> { + try { + BackupUtil.enableBackups(context, passwordSt); + } catch (IOException e) { + Log.e(TAG, "Failed to activate backups.", e); + Toast.makeText(context, + context.getString(R.string.dialog_backup_activation_failed), + Toast.LENGTH_LONG) + .show(); + return; + } - preference.setChecked(true); - created.dismiss(); + preference.setChecked(true); + created.dismiss(); + }); } else { Toast.makeText(context, R.string.BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box, Toast.LENGTH_LONG).show(); } @@ -62,7 +81,7 @@ public class BackupDialog { textView.setOnClickListener(v -> checkBox.toggle()); dialog.findViewById(R.id.number_table).setOnClickListener(v -> { - ((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", Util.join(password, " "))); + ((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", passwordSt)); Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_LONG).show(); }); @@ -75,9 +94,7 @@ public class BackupDialog { .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(context); + BackupUtil.disableBackups(context, true); preference.setChecked(false); }) .create() diff --git a/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java b/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java index 0f24f378a0..aede7c441d 100644 --- a/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java +++ b/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java @@ -16,7 +16,7 @@ public class BackupPassphrase { private static final String TAG = BackupPassphrase.class.getSimpleName(); - public static String get(@NonNull Context context) { + public static @Nullable String get(@NonNull Context context) { String passphrase = TextSecurePreferences.getBackupPassphrase(context); String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context); diff --git a/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java index bd0864f065..a7f1fb1582 100644 --- a/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -1,11 +1,12 @@ package org.thoughtcrime.securesms.backup; - import android.content.Context; import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.text.TextUtils; import com.annimon.stream.function.Consumer; import com.annimon.stream.function.Predicate; @@ -36,9 +37,10 @@ import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.kdf.HKDFv3; import org.whispersystems.libsignal.util.ByteUtil; +import java.io.Closeable; import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; +import java.io.Flushable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -64,49 +66,54 @@ public class FullBackupExporter extends FullBackupBase { public static void export(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase input, - @NonNull File output, + @NonNull Uri fileUri, @NonNull String passphrase) throws IOException { - BackupFrameOutputStream outputStream = new BackupFrameOutputStream(output, passphrase); - outputStream.writeDatabaseVersion(input.getVersion()); + OutputStream baseOutputStream = context.getContentResolver().openOutputStream(fileUri); + if (baseOutputStream == null) { + throw new IOException("Cannot open an output stream for the file URI: " + fileUri.toString()); + } - List tables = exportSchema(input, outputStream); - int count = 0; + try (BackupFrameOutputStream outputStream = new BackupFrameOutputStream(baseOutputStream, passphrase)) { + outputStream.writeDatabaseVersion(input.getVersion()); - for (String table : tables) { - if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count); - } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count); - } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count); - } else if (table.equals(StickerDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count); - } else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) && - !table.equals(OneTimePreKeyDatabase.TABLE_NAME) && - !table.equals(SessionDatabase.TABLE_NAME) && - !table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) && - !table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) && - !table.startsWith("sqlite_")) - { - count = exportTable(table, input, outputStream, null, null, count); + List tables = exportSchema(input, outputStream); + int count = 0; + + for (String table : tables) { + if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count); + } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count); + } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count); + } else if (table.equals(StickerDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count); + } else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) && + !table.equals(OneTimePreKeyDatabase.TABLE_NAME) && + !table.equals(SessionDatabase.TABLE_NAME) && + !table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) && + !table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) && + !table.startsWith("sqlite_")) + { + count = exportTable(table, input, outputStream, null, null, count); + } } - } - for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); - outputStream.write(preference); - } + for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); + outputStream.write(preference); + } - for (File avatar : AvatarHelper.getAvatarFiles(context)) { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); - outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length()); - } + for (File avatar : AvatarHelper.getAvatarFiles(context)) { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); + outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length()); + } - outputStream.writeEnd(); - outputStream.close(); - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count)); + outputStream.writeEnd(); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count)); + } } private static List exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream) @@ -228,8 +235,9 @@ public class FullBackupExporter extends FullBackupBase { byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM)); if (!TextUtils.isEmpty(data) && size > 0) { - InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0); - outputStream.writeSticker(rowId, inputStream, size); + try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) { + outputStream.writeSticker(rowId, inputStream, size); + } } } catch (IOException e) { Log.w(TAG, e); @@ -268,7 +276,7 @@ public class FullBackupExporter extends FullBackupBase { } - private static class BackupFrameOutputStream extends BackupStream { + private static class BackupFrameOutputStream extends BackupStream implements Closeable, Flushable { private final OutputStream outputStream; private final Cipher cipher; @@ -280,7 +288,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 outputStream, @NonNull String passphrase) throws IOException { try { byte[] salt = Util.getSecretBytes(32); byte[] key = getBackupKey(passphrase, salt); @@ -292,7 +300,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 = outputStream; this.iv = Util.getSecretBytes(16); this.counter = Conversions.byteArrayToInt(iv); @@ -408,7 +416,12 @@ public class FullBackupExporter extends FullBackupBase { } } + @Override + public void flush() throws IOException { + outputStream.flush(); + } + @Override public void close() throws IOException { outputStream.close(); } diff --git a/src/org/thoughtcrime/securesms/database/DatabaseFactory.java b/src/org/thoughtcrime/securesms/database/DatabaseFactory.java index 164f60762f..3df26109b8 100644 --- a/src/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/src/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.database.helpers.ClassicOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase; +import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase; import org.thoughtcrime.securesms.loki.database.LokiMessageDatabase; import org.thoughtcrime.securesms.loki.database.LokiPreKeyBundleDatabase; import org.thoughtcrime.securesms.loki.database.LokiPreKeyRecordDatabase; @@ -74,6 +75,7 @@ public class DatabaseFactory { private final LokiMessageDatabase lokiMessageDatabase; private final LokiThreadDatabase lokiThreadDatabase; private final LokiUserDatabase lokiUserDatabase; + private final LokiBackupFilesDatabase lokiBackupFilesDatabase; private final SharedSenderKeysDatabase sskDatabase; public static DatabaseFactory getInstance(Context context) { @@ -190,6 +192,10 @@ public class DatabaseFactory { return getInstance(context).lokiUserDatabase; } + public static LokiBackupFilesDatabase getLokiBackupFilesDatabase(Context context) { + return getInstance(context).lokiBackupFilesDatabase; + } + public static SharedSenderKeysDatabase getSSKDatabase(Context context) { return getInstance(context).sskDatabase; } @@ -232,6 +238,7 @@ public class DatabaseFactory { this.lokiMessageDatabase = new LokiMessageDatabase(context, databaseHelper); this.lokiThreadDatabase = new LokiThreadDatabase(context, databaseHelper); this.lokiUserDatabase = new LokiUserDatabase(context, databaseHelper); + this.lokiBackupFilesDatabase = new LokiBackupFilesDatabase(context, databaseHelper); this.sskDatabase = new SharedSenderKeysDatabase(context, databaseHelper); } diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 698910d674..21ddb9f1a0 100644 --- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.JobDatabase; +import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; import org.thoughtcrime.securesms.database.PushDatabase; @@ -89,8 +90,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV11 = 32; private static final int lokiV12 = 33; private static final int lokiV13 = 34; + private static final int lokiV14_BACKUP_FILES = 35; - private static final int DATABASE_VERSION = lokiV13; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes + private static final int DATABASE_VERSION = lokiV14_BACKUP_FILES; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -161,6 +163,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand()); db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand()); db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand()); + db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand()); db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupRatchetTableCommand()); db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand()); @@ -619,6 +622,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.getCreateReceivedMessageHashValuesTable3Command()); } + if (oldVersion < lokiV14_BACKUP_FILES) { + db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java index ba19d8e2a5..3e378a1fb5 100644 --- a/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ b/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -1,32 +1,22 @@ package org.thoughtcrime.securesms.jobs; -import android.Manifest; import androidx.annotation.NonNull; -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.database.NoExternalStorageException; +import org.thoughtcrime.securesms.loki.database.BackupFileRecord; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.service.GenericForegroundService; import org.thoughtcrime.securesms.util.BackupUtil; -import org.thoughtcrime.securesms.util.ExternalStorageUtil; -import java.io.File; import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; +import java.util.Collections; import network.loki.messenger.R; -//TODO AC: Needs to be refactored to use Storage Access Framework or Media Store API. public class LocalBackupJob extends BaseJob { public static final String KEY = "LocalBackupJob"; @@ -59,10 +49,6 @@ public class LocalBackupJob extends BaseJob { public void onRun() throws NoExternalStorageException, IOException { Log.i(TAG, "Executing backup job..."); - if (!Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - throw new IOException("No external storage permission!"); - } - GenericForegroundService.startForegroundTask(context, context.getString(R.string.LocalBackupJob_creating_backup), NotificationChannels.BACKUPS, @@ -71,34 +57,9 @@ public class LocalBackupJob extends BaseJob { // TODO: Maybe create a new backup icon like ic_signal_backup? try { - String backupPassword = BackupPassphrase.get(context); - File backupDirectory = ExternalStorageUtil.getBackupDir(context); - String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); - String fileName = String.format("session-%s.backup", timestamp); - File backupFile = new File(backupDirectory, fileName); + BackupFileRecord record = BackupUtil.createBackupFile(context); + BackupUtil.deleteAllBackupFiles(context, Collections.singletonList(record)); - if (backupFile.exists()) { - throw new IOException("Backup file already exists?"); - } - - if (backupPassword == null) { - throw new IOException("Backup password is null"); - } - - File tempFile = File.createTempFile("backup", "tmp", ExternalStorageUtil.getCacheDir(context)); - - FullBackupExporter.export(context, - AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), - DatabaseFactory.getBackupDatabase(context), - tempFile, - backupPassword); - - if (!tempFile.renameTo(backupFile)) { - tempFile.delete(); - throw new IOException("Renaming temporary backup file failed!"); - } - - BackupUtil.deleteOldBackups(context); } finally { GenericForegroundService.stopForegroundTask(context); } diff --git a/src/org/thoughtcrime/securesms/loki/database/BackupFileRecord.kt b/src/org/thoughtcrime/securesms/loki/database/BackupFileRecord.kt new file mode 100644 index 0000000000..0d3fc63bcf --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/database/BackupFileRecord.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.loki.database + +import android.net.Uri +import java.util.* + +/** + * Represents a record for a backup file in the [org.thoughtcrime.securesms.database.LokiBackupFilesDatabase]. + */ +data class BackupFileRecord(val id: Long, val uri: Uri, val fileSize: Long, val timestamp: Date) { + + constructor(uri: Uri, fileSize: Long, timestamp: Date) : this(-1, uri, fileSize, timestamp) +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/database/LokiBackupFilesDatabase.kt b/src/org/thoughtcrime/securesms/loki/database/LokiBackupFilesDatabase.kt new file mode 100644 index 0000000000..65ed0cc8bd --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/database/LokiBackupFilesDatabase.kt @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.loki.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.Uri +import org.thoughtcrime.securesms.database.Database +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import java.lang.IllegalArgumentException +import java.util.* +import kotlin.collections.ArrayList + +/** + * Keeps track of the backup files saved by the app. + * Uses [BackupFileRecord] as an entry data projection. + */ +class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) + : Database(context, databaseHelper) { + + companion object { + private const val TABLE_NAME = "backup_files" + private const val COLUMN_ID = "_id" + private const val COLUMN_URI = "uri" + private const val COLUMN_FILE_SIZE = "file_size" + private const val COLUMN_TIMESTAMP = "timestamp" + + private val allColumns = arrayOf(COLUMN_ID, COLUMN_URI, COLUMN_FILE_SIZE, COLUMN_TIMESTAMP) + + @JvmStatic + val createTableCommand = """ + CREATE TABLE $TABLE_NAME ( + $COLUMN_ID INTEGER PRIMARY KEY, + $COLUMN_URI TEXT NOT NULL, + $COLUMN_FILE_SIZE INTEGER NOT NULL, + $COLUMN_TIMESTAMP INTEGER NOT NULL + ); + """.trimIndent() + + private fun mapCursorToRecord(cursor: Cursor): BackupFileRecord { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_ID)) + val uriRaw = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_URI)) + val fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_FILE_SIZE)) + val timestampRaw = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_TIMESTAMP)) + return BackupFileRecord(id, Uri.parse(uriRaw), fileSize, Date(timestampRaw)) + } + + private fun mapRecordToValues(record: BackupFileRecord): ContentValues { + val contentValues = ContentValues() + if (record.id >= 0) { contentValues.put(COLUMN_ID, record.id) } + contentValues.put(COLUMN_URI, record.uri.toString()) + contentValues.put(COLUMN_FILE_SIZE, record.fileSize) + contentValues.put(COLUMN_TIMESTAMP, record.timestamp.time) + return contentValues + } + } + + fun getBackupFiles(): List { + databaseHelper.readableDatabase.query(TABLE_NAME, allColumns, null, null, null, null, null).use { + val records = ArrayList() + while (it != null && it.moveToNext()) { + val record = mapCursorToRecord(it) + records.add(record) + } + return records + } + } + + fun insertBackupFile(record: BackupFileRecord): BackupFileRecord { + val contentValues = mapRecordToValues(record) + val id = databaseHelper.writableDatabase.insertOrThrow(TABLE_NAME, null, contentValues) + return BackupFileRecord(id, record.uri, record.fileSize, record.timestamp) + } + + fun getLastBackupFileTime(): Date? { + // SELECT $COLUMN_TIMESTAMP FROM $TABLE_NAME ORDER BY $COLUMN_TIMESTAMP DESC LIMIT 1 + databaseHelper.readableDatabase.query( + TABLE_NAME, + arrayOf(COLUMN_TIMESTAMP), + null, null, null, null, + "$COLUMN_TIMESTAMP DESC", + "1" + ).use { + if (it !== null && it.moveToFirst()) { + return Date(it.getLong(0)) + } else { + return null + } + } + } + + fun getLastBackupFile(): BackupFileRecord? { + // SELECT * FROM $TABLE_NAME ORDER BY $COLUMN_TIMESTAMP DESC LIMIT 1 + databaseHelper.readableDatabase.query( + TABLE_NAME, + allColumns, + null, null, null, null, + "$COLUMN_TIMESTAMP DESC", + "1" + ).use { + if (it != null && it.moveToFirst()) { + return mapCursorToRecord(it) + } else { + return null + } + } + } + + fun deleteBackupFile(record: BackupFileRecord): Boolean { + return deleteBackupFile(record.id) + } + + fun deleteBackupFile(id: Long): Boolean { + if (id < 0) { + throw IllegalArgumentException("ID must be zero or a positive number.") + } + return databaseHelper.writableDatabase.delete(TABLE_NAME, "$COLUMN_ID = $id", null) > 0 + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java index 97c14b4279..53e9b5078e 100644 --- a/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java +++ b/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -1,16 +1,17 @@ package org.thoughtcrime.securesms.preferences; -import android.Manifest; import android.annotation.SuppressLint; import android.content.Context; import android.content.DialogInterface; +import android.content.Intent; import android.os.Bundle; +import android.text.TextUtils; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.preference.EditTextPreference; import androidx.preference.Preference; -import android.text.TextUtils; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; @@ -23,6 +24,7 @@ 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.BackupDirSelector; import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Trimmer; @@ -37,6 +39,8 @@ import network.loki.messenger.R; public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { private static final String TAG = ChatsPreferenceFragment.class.getSimpleName(); + private BackupDirSelector backupDirSelector; + @Override public void onCreate(Bundle paramBundle) { super.onCreate(paramBundle); @@ -64,6 +68,8 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { // initializeListSummary((ListPreference) findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF)); + backupDirSelector = new BackupDirSelector(this); + EventBus.getDefault().register(this); } @@ -90,6 +96,12 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + backupDirSelector.onActivityResult(requestCode, resultCode, data); + } + @Subscribe(threadMode = ThreadMode.MAIN) public void onEvent(BackupEvent event) { ProgressPreference preference = (ProgressPreference)findPreference(TextSecurePreferences.BACKUP_NOW); @@ -107,7 +119,7 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { private void setBackupSummary() { findPreference(TextSecurePreferences.BACKUP_NOW) - .setSummary(String.format(getString(R.string.ChatsPreferenceFragment_last_backup_s), BackupUtil.getLastBackupTime(getContext(), Locale.US))); + .setSummary(String.format(getString(R.string.ChatsPreferenceFragment_last_backup_s), BackupUtil.getLastBackupTimeString(getContext(), Locale.getDefault()))); } private void setMediaDownloadSummaries() { @@ -137,17 +149,11 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { private class BackupClickListener implements Preference.OnPreferenceClickListener { @Override public boolean onPreferenceClick(Preference preference) { - Permissions.with(ChatsPreferenceFragment.this) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .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(); + if (!((SwitchPreferenceCompat) preference).isChecked()) { + BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference, backupDirSelector); + } else { + BackupDialog.showDisableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference); + } return true; } @@ -157,17 +163,10 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { @SuppressLint("StaticFieldLeak") @Override public boolean onPreferenceClick(Preference preference) { - Permissions.with(ChatsPreferenceFragment.this) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .onAllGranted(() -> { - Log.i(TAG, "Queing backup..."); - ApplicationContext.getInstance(getContext()) - .getJobManager() - .add(new LocalBackupJob()); - }) - .withPermanentDenialDialog(getString(R.string.ChatsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) - .execute(); - + Log.i(TAG, "Queuing backup..."); + ApplicationContext.getInstance(getContext()) + .getJobManager() + .add(new LocalBackupJob()); return true; } } diff --git a/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java b/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java index 572ae4ab6d..5a082f532b 100644 --- a/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java +++ b/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java @@ -52,9 +52,9 @@ public class AvatarHelper { if (data == null) { delete(context, address); } else { - FileOutputStream out = new FileOutputStream(getAvatarFile(context, address)); - out.write(data); - out.close(); + try (FileOutputStream out = new FileOutputStream(getAvatarFile(context, address))) { + out.write(data); + } } } diff --git a/src/org/thoughtcrime/securesms/util/BackupUtil.java b/src/org/thoughtcrime/securesms/util/BackupUtil.java deleted file mode 100644 index 5df025a3bc..0000000000 --- a/src/org/thoughtcrime/securesms/util/BackupUtil.java +++ /dev/null @@ -1,161 +0,0 @@ -package org.thoughtcrime.securesms.util; - - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import org.thoughtcrime.securesms.logging.Log; - -import network.loki.messenger.R; -import org.thoughtcrime.securesms.database.NoExternalStorageException; -import org.whispersystems.libsignal.util.ByteUtil; - -import java.io.File; -import java.security.SecureRandom; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Locale; - -//TODO AC: Needs to be refactored to use Storage Access Framework or Media Store API. -public class BackupUtil { - - private static final String TAG = BackupUtil.class.getSimpleName(); - - public static @NonNull String getLastBackupTime(@NonNull Context context, @NonNull Locale locale) { - try { - BackupInfo backup = getLatestBackup(context); - - if (backup == null) return context.getString(R.string.BackupUtil_never); - else return DateUtils.getExtendedRelativeTimeSpanString(context, locale, backup.getTimestamp()); - } catch (NoExternalStorageException e) { - Log.w(TAG, e); - return context.getString(R.string.BackupUtil_unknown); - } - } - - public static @Nullable BackupInfo getLatestBackup(Context context) throws NoExternalStorageException { - File backupDirectory = ExternalStorageUtil.getBackupDir(context); - File[] backups = backupDirectory.listFiles(); - BackupInfo latestBackup = null; - - for (File backup : backups) { - long backupTimestamp = getBackupTimestamp(backup); - - if (latestBackup == null || (backupTimestamp != -1 && backupTimestamp > latestBackup.getTimestamp())) { - latestBackup = new BackupInfo(backupTimestamp, backup.length(), backup); - } - } - - return latestBackup; - } - - @SuppressWarnings("ResultOfMethodCallIgnored") - public static void deleteAllBackups(Context context) { - try { - File backupDirectory = ExternalStorageUtil.getBackupDir(context); - File[] backups = backupDirectory.listFiles(); - - for (File backup : backups) { - if (backup.isFile()) backup.delete(); - } - } catch (NoExternalStorageException e) { - Log.w(TAG, e); - } - } - - public static void deleteOldBackups(Context context) { - try { - File backupDirectory = ExternalStorageUtil.getBackupDir(context); - File[] backups = backupDirectory.listFiles(); - - if (backups != null && backups.length > 2) { - Arrays.sort(backups, (left, right) -> { - long leftTimestamp = getBackupTimestamp(left); - long rightTimestamp = getBackupTimestamp(right); - - if (leftTimestamp == -1 && rightTimestamp == -1) return 0; - else if (leftTimestamp == -1) return 1; - else if (rightTimestamp == -1) return -1; - - return (int)(rightTimestamp - leftTimestamp); - }); - - for (int i=2;i { + val random = ByteArray(30).also { SecureRandom().nextBytes(it) } + return Array(6) {i -> + String.format("%05d", ByteUtil.byteArray5ToLong(random, i * 5) % 100000) + } + } + + @JvmStatic + fun validateDirAccess(context: Context, dirUri: Uri): Boolean { + val hasWritePermission = context.contentResolver.persistedUriPermissions.any { + it.isWritePermission && it.uri == dirUri + } + if (!hasWritePermission) return false + + val document = DocumentFile.fromTreeUri(context, dirUri) + if (document == null || !document.exists()) { + return false + } + + return true + } + + @JvmStatic + fun getBackupDirUri(context: Context): Uri? { + val dirUriString = TextSecurePreferences.getBackupSaveDir(context) ?: return null + return Uri.parse(dirUriString) + } + + @JvmStatic + fun setBackupDirUri(context: Context, uriString: String?) { + TextSecurePreferences.setBackupSaveDir(context, uriString) + } + + /** + * @return The selected backup directory if it's valid (exists, is writable). + */ + @JvmStatic + fun getSelectedBackupDirIfValid(context: Context): Uri? { + val dirUri = getBackupDirUri(context) + + if (dirUri == null) { + Log.v(TAG, "The backup dir wasn't selected yet.") + return null + } + if (!validateDirAccess(context, dirUri)) { + Log.v(TAG, "Cannot validate the access to the dir $dirUri.") + return null + } + + return dirUri; + } + + @JvmStatic + @WorkerThread + @Throws(IOException::class) + fun createBackupFile(context: Context): BackupFileRecord { + val backupPassword = BackupPassphrase.get(context) + ?: throw IOException("Backup password is null") + + val dirUri = getSelectedBackupDirIfValid(context) + ?: throw IOException("Backup save directory is not selected or invalid") + + val date = Date() + val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(date) + val fileName = String.format("session-%s.backup", timestamp) + + val fileUri = DocumentsContract.createDocument( + context.contentResolver, + DocumentFile.fromTreeUri(context, dirUri)!!.uri, + "application/x-binary", + fileName) + + if (fileUri == null) { + Toast.makeText(context, "Cannot create writable file in the dir $dirUri", Toast.LENGTH_LONG).show() + throw IOException("Cannot create writable file in the dir $dirUri") + } + + FullBackupExporter.export(context, + AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret, + DatabaseFactory.getBackupDatabase(context), + fileUri, + backupPassword) + + //TODO Use real file size. + val record = DatabaseFactory.getLokiBackupFilesDatabase(context) + .insertBackupFile(BackupFileRecord(fileUri, -1, date)) + + Log.v(TAG, "Backup file was created: $fileUri") + + return record + } + + @JvmStatic + @JvmOverloads + fun deleteAllBackupFiles(context: Context, except: Collection? = null) { + val db = DatabaseFactory.getLokiBackupFilesDatabase(context) + db.getBackupFiles().forEach { record -> + if (except != null && except.contains(record)) return@forEach + + // Try to delete the related file. The operation may fail in many cases + // (the user moved/deleted the file, revoked the write permission, etc), so that's OK. + try { + val result = DocumentsContract.deleteDocument(context.contentResolver, record.uri) + if (!result) { + Log.w(TAG, "Failed to delete backup file: ${record.uri}") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to delete backup file: ${record.uri}", e) + } + + db.deleteBackupFile(record) + + Log.v(TAG, "Backup file was deleted: ${record.uri}") + } + } +} + +/** + * An utility class to help perform backup directory selection requests. + * + * An instance of this class should be created per an [Activity] or [Fragment] + * and [onActivityResult] should be called appropriately. + */ +class BackupDirSelector(private val contextProvider: ContextProvider) { + + companion object { + private const val REQUEST_CODE_SAVE_DIR = 7844 + } + + private val context: Context get() = contextProvider.getContext() + + private var listener: Listener? = null + + constructor(activity: Activity) : + this(ActivityContextProvider(activity)) + + constructor(fragment: Fragment) : + this(FragmentContextProvider(fragment)) + + /** + * Performs ACTION_OPEN_DOCUMENT_TREE intent to select backup directory URI. + * If the directory is already selected and valid, the request will be skipped. + * @param force if true, the previous selection is ignored and the user is requested to select another directory. + * @param onSelectedListener an optional action to perform once the directory is selected. + */ + fun selectBackupDir(force: Boolean, onSelectedListener: Listener? = null) { + if (!force) { + val dirUri = BackupUtil.getSelectedBackupDirIfValid(context) + if (dirUri != null && onSelectedListener != null) { + onSelectedListener.onBackupDirSelected(dirUri) + } + return + } + + // Let user pick the dir. + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + // Request read/write permission grant for the dir. + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + + // Set the default dir. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val dirUri = BackupUtil.getBackupDirUri(context) + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, dirUri + ?: Uri.fromFile(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS))) + } + + if (onSelectedListener != null) { + this.listener = onSelectedListener + } + + contextProvider.startActivityForResult(intent, REQUEST_CODE_SAVE_DIR) + } + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != REQUEST_CODE_SAVE_DIR) return + + if (resultCode == Activity.RESULT_OK && data != null && data.data != null) { + // Acquire persistent access permissions for the file selected. + val persistentFlags: Int = data.flags and + (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + context.contentResolver.takePersistableUriPermission(data.data!!, persistentFlags) + + BackupUtil.setBackupDirUri(context, data.dataString) + + listener?.onBackupDirSelected(data.data!!) + } + + listener = null + } + + @FunctionalInterface + interface Listener { + fun onBackupDirSelected(uri: Uri) + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/util/BackupUtilOld.java b/src/org/thoughtcrime/securesms/util/BackupUtilOld.java new file mode 100644 index 0000000000..4f4cb92374 --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/BackupUtilOld.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util; + + +import android.content.Context; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.loki.database.BackupFileRecord; + +import org.thoughtcrime.securesms.database.NoExternalStorageException; + +import java.io.File; + +//TODO AC: Delete this class when its functionality is +// fully replaced by the BackupUtil.kt and related classes. +/** @deprecated in favor of {@link BackupUtil} */ +public class BackupUtilOld { + + private static final String TAG = BackupUtilOld.class.getSimpleName(); + + /** + * @deprecated this method exists only for the backward compatibility with the legacy Signal backup code. + * Use {@link BackupUtil} if possible. + */ + public static @Nullable BackupInfo getLatestBackup(Context context) throws NoExternalStorageException { + BackupFileRecord backup = BackupUtil.getLastBackup(context); + if (backup == null) return null; + + + return new BackupInfo( + backup.getTimestamp().getTime(), + backup.getFileSize(), + new File(backup.getUri().getPath())); + } + + @Deprecated + public static class BackupInfo { + + private final long timestamp; + private final long size; + private final File file; + + BackupInfo(long timestamp, long size, File file) { + this.timestamp = timestamp; + this.size = size; + this.file = file; + } + + public long getTimestamp() { + return timestamp; + } + + public long getSize() { + return size; + } + + public File getFile() { + return file; + } + } +} diff --git a/src/org/thoughtcrime/securesms/util/ContextProvider.kt b/src/org/thoughtcrime/securesms/util/ContextProvider.kt new file mode 100644 index 0000000000..4bc8e104dd --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/ContextProvider.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.util + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.fragment.app.Fragment + +/** + * A simplified version of [android.content.ContextWrapper], + * but properly supports [startActivityForResult] for the implementations. + */ +interface ContextProvider { + fun getContext(): Context + fun startActivityForResult(intent: Intent, requestCode: Int) +} + +class ActivityContextProvider(private val activity: Activity): ContextProvider { + + override fun getContext(): Context { + return activity + } + + override fun startActivityForResult(intent: Intent, requestCode: Int) { + activity.startActivityForResult(intent, requestCode) + } +} + +class FragmentContextProvider(private val fragment: Fragment): ContextProvider { + + override fun getContext(): Context { + return fragment.requireContext() + } + + override fun startActivityForResult(intent: Intent, requestCode: Int) { + fragment.startActivityForResult(intent, requestCode) + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt b/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt index d4dc58eb7b..1c57af91bc 100644 --- a/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -18,9 +18,11 @@ import java.io.File import java.io.IOException import java.lang.ref.WeakReference import java.text.SimpleDateFormat +import kotlin.jvm.Throws /** * Saves attachment files to an external storage using [MediaStore] API. + * Requires [android.Manifest.permission.WRITE_EXTERNAL_STORAGE] on API 28 and below. */ class SaveAttachmentTask : ProgressDialogAsyncTask> { diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 6b897607a1..11b6dbb251 100644 --- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -141,11 +141,12 @@ 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_ENABLED = "pref_backup_enabled"; + public static final String BACKUP_ENABLED = "pref_backup_enabled_v2"; 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"; + private static final String BACKUP_SAVE_DIR = "pref_save_dir"; public static final String SCREEN_LOCK = "pref_android_screen_lock"; public static final String SCREEN_LOCK_TIMEOUT = "pref_android_screen_lock_timeout"; @@ -296,6 +297,14 @@ public class TextSecurePreferences { return getLongPreference(context, BACKUP_TIME, -1); } + public static void setBackupSaveDir(@NonNull Context context, String dirUri) { + setStringPreference(context, BACKUP_SAVE_DIR, dirUri); + } + + public static String getBackupSaveDir(@NonNull Context context) { + return getStringPreference(context, BACKUP_SAVE_DIR, null); + } + public static int getNextPreKeyId(@NonNull Context context) { return getIntegerPreference(context, NEXT_PRE_KEY_ID, new SecureRandom().nextInt(Medium.MAX_VALUE)); }