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));
}