Merge pull request #341 from loki-project/backup-file-storage

Use Storage Access Framework for Backups
This commit is contained in:
Niels Andriesse 2020-09-17 13:33:17 +10:00 committed by GitHub
commit 6930e8a3e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 671 additions and 306 deletions

View File

@ -1866,4 +1866,6 @@
<string name="attachment_type_voice_message">Voice Message</string> <string name="attachment_type_voice_message">Voice Message</string>
<string name="details">Details</string> <string name="details">Details</string>
<string name="dialog_backup_activation_failed">Failed to activate backups. Please try again or contact support.</string>
</resources> </resources>

View File

@ -94,7 +94,7 @@
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat <org.thoughtcrime.securesms.components.SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
android:key="pref_backup_enabled" android:key="pref_backup_enabled_v2"
android:title="@string/preferences_chats__chat_backups" android:title="@string/preferences_chats__chat_backups"
android:summary="@string/preferences_chats__backup_chats_to_external_storage" /> android:summary="@string/preferences_chats__backup_chats_to_external_storage" />
@ -102,7 +102,7 @@
android:key="pref_backup_create" android:key="pref_backup_create"
android:title="@string/preferences_chats__create_backup" android:title="@string/preferences_chats__create_backup"
android:persistent="false" android:persistent="false"
android:dependency="pref_backup_enabled" android:dependency="pref_backup_enabled_v2"
tools:summary="Last backup: 3 days ago" /> tools:summary="Last backup: 3 days ago" />
</PreferenceCategory> </PreferenceCategory>

View File

@ -57,7 +57,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.push.AccountManagerFactory; import org.thoughtcrime.securesms.push.AccountManagerFactory;
import org.thoughtcrime.securesms.registration.CaptchaActivity; import org.thoughtcrime.securesms.registration.CaptchaActivity;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; 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.DateUtils;
import org.thoughtcrime.securesms.util.Dialogs; import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.ServiceUtil; 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 org.whispersystems.signalservice.internal.push.LockedException;
import java.io.IOException; import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -275,11 +274,11 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif
if (getIntent().getBooleanExtra(RE_REGISTRATION_EXTRA, false)) return; if (getIntent().getBooleanExtra(RE_REGISTRATION_EXTRA, false)) return;
new AsyncTask<Void, Void, BackupUtil.BackupInfo>() { new AsyncTask<Void, Void, BackupUtilOld.BackupInfo>() {
@Override @Override
protected @Nullable BackupUtil.BackupInfo doInBackground(Void... voids) { protected @Nullable BackupUtilOld.BackupInfo doInBackground(Void... voids) {
try { try {
return BackupUtil.getLatestBackup(RegistrationActivity.this); return BackupUtilOld.getLatestBackup(RegistrationActivity.this);
} catch (NoExternalStorageException e) { } catch (NoExternalStorageException e) {
Log.w(TAG, e); Log.w(TAG, e);
return null; return null;
@ -287,7 +286,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif
} }
@Override @Override
protected void onPostExecute(@Nullable BackupUtil.BackupInfo backup) { protected void onPostExecute(@Nullable BackupUtilOld.BackupInfo backup) {
if (backup != null) displayRestoreView(backup); if (backup != null) displayRestoreView(backup);
} }
}.execute(); }.execute();
@ -304,7 +303,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif
} }
@SuppressLint("StaticFieldLeak") @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); View view = LayoutInflater.from(this).inflate(R.layout.enter_backup_passphrase_dialog, null);
EditText prompt = view.findViewById(R.id.restore_passphrase_input); EditText prompt = view.findViewById(R.id.restore_passphrase_input);
@ -661,7 +660,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif
finish(); 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() { title.animate().translationX(title.getWidth()).setDuration(SCENE_TRANSITION_DURATION).setListener(new AnimationCompleteListener() {
@Override @Override
public void onAnimationEnd(Animator animation) { public void onAnimationEnd(Animator animation) {

View File

@ -1,28 +1,38 @@
package org.thoughtcrime.securesms.backup; package org.thoughtcrime.securesms.backup;
import android.content.ClipData; import android.content.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import android.widget.Button; import android.widget.Button;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; 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.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.BackupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
public class BackupDialog { import java.io.IOException;
public static void showEnableBackupDialog(@NonNull Context context, @NonNull SwitchPreferenceCompat preference) { import network.loki.messenger.R;
String[] password = BackupUtil.generateBackupPassphrase();
AlertDialog dialog = new AlertDialog.Builder(context) 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) .setTitle(R.string.BackupDialog_enable_local_backups)
.setView(R.layout.backup_enable_dialog) .setView(R.layout.backup_enable_dialog)
.setPositiveButton(R.string.BackupDialog_enable_backups, null) .setPositiveButton(R.string.BackupDialog_enable_backups, null)
@ -34,12 +44,21 @@ public class BackupDialog {
button.setOnClickListener(v -> { button.setOnClickListener(v -> {
CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check); CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check);
if (confirmationCheckBox.isChecked()) { if (confirmationCheckBox.isChecked()) {
BackupPassphrase.set(context, Util.join(password, " ")); backupDirSelector.selectBackupDir(true, uri -> {
TextSecurePreferences.setBackupEnabled(context, true); try {
LocalBackupListener.schedule(context); 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); preference.setChecked(true);
created.dismiss(); created.dismiss();
});
} else { } else {
Toast.makeText(context, R.string.BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box, Toast.LENGTH_LONG).show(); 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()); textView.setOnClickListener(v -> checkBox.toggle());
dialog.findViewById(R.id.number_table).setOnClickListener(v -> { 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(); 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) .setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> { .setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> {
BackupPassphrase.set(context, null); BackupUtil.disableBackups(context, true);
TextSecurePreferences.setBackupEnabled(context, false);
BackupUtil.deleteAllBackups(context);
preference.setChecked(false); preference.setChecked(false);
}) })
.create() .create()

View File

@ -16,7 +16,7 @@ public class BackupPassphrase {
private static final String TAG = BackupPassphrase.class.getSimpleName(); 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 passphrase = TextSecurePreferences.getBackupPassphrase(context);
String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context); String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);

View File

@ -1,11 +1,12 @@
package org.thoughtcrime.securesms.backup; package org.thoughtcrime.securesms.backup;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import android.text.TextUtils;
import com.annimon.stream.function.Consumer; import com.annimon.stream.function.Consumer;
import com.annimon.stream.function.Predicate; 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.kdf.HKDFv3;
import org.whispersystems.libsignal.util.ByteUtil; import org.whispersystems.libsignal.util.ByteUtil;
import java.io.Closeable;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.Flushable;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
@ -64,49 +66,54 @@ public class FullBackupExporter extends FullBackupBase {
public static void export(@NonNull Context context, public static void export(@NonNull Context context,
@NonNull AttachmentSecret attachmentSecret, @NonNull AttachmentSecret attachmentSecret,
@NonNull SQLiteDatabase input, @NonNull SQLiteDatabase input,
@NonNull File output, @NonNull Uri fileUri,
@NonNull String passphrase) @NonNull String passphrase)
throws IOException throws IOException
{ {
BackupFrameOutputStream outputStream = new BackupFrameOutputStream(output, passphrase); OutputStream baseOutputStream = context.getContentResolver().openOutputStream(fileUri);
outputStream.writeDatabaseVersion(input.getVersion()); if (baseOutputStream == null) {
throw new IOException("Cannot open an output stream for the file URI: " + fileUri.toString());
}
List<String> tables = exportSchema(input, outputStream); try (BackupFrameOutputStream outputStream = new BackupFrameOutputStream(baseOutputStream, passphrase)) {
int count = 0; outputStream.writeDatabaseVersion(input.getVersion());
for (String table : tables) { List<String> tables = exportSchema(input, outputStream);
if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) { int count = 0;
count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count);
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { for (String table : tables) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count); if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) {
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) { count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count);
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(GroupReceiptDatabase.TABLE_NAME)) {
} else if (table.equals(StickerDatabase.TABLE_NAME)) { count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count);
count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count); } else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
} else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) && count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count);
!table.equals(OneTimePreKeyDatabase.TABLE_NAME) && } else if (table.equals(StickerDatabase.TABLE_NAME)) {
!table.equals(SessionDatabase.TABLE_NAME) && count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count);
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) && } else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) &&
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) && !table.equals(OneTimePreKeyDatabase.TABLE_NAME) &&
!table.startsWith("sqlite_")) !table.equals(SessionDatabase.TABLE_NAME) &&
{ !table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
count = exportTable(table, input, outputStream, null, null, count); !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)) { for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
outputStream.write(preference); outputStream.write(preference);
} }
for (File avatar : AvatarHelper.getAvatarFiles(context)) { for (File avatar : AvatarHelper.getAvatarFiles(context)) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length()); outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length());
} }
outputStream.writeEnd(); outputStream.writeEnd();
outputStream.close(); EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count)); }
} }
private static List<String> exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream) private static List<String> 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)); byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
if (!TextUtils.isEmpty(data) && size > 0) { if (!TextUtils.isEmpty(data) && size > 0) {
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0); try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
outputStream.writeSticker(rowId, inputStream, size); outputStream.writeSticker(rowId, inputStream, size);
}
} }
} catch (IOException e) { } catch (IOException e) {
Log.w(TAG, 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 OutputStream outputStream;
private final Cipher cipher; private final Cipher cipher;
@ -280,7 +288,7 @@ public class FullBackupExporter extends FullBackupBase {
private byte[] iv; private byte[] iv;
private int counter; private int counter;
private BackupFrameOutputStream(@NonNull File output, @NonNull String passphrase) throws IOException { private BackupFrameOutputStream(@NonNull OutputStream outputStream, @NonNull String passphrase) throws IOException {
try { try {
byte[] salt = Util.getSecretBytes(32); byte[] salt = Util.getSecretBytes(32);
byte[] key = getBackupKey(passphrase, salt); byte[] key = getBackupKey(passphrase, salt);
@ -292,7 +300,7 @@ public class FullBackupExporter extends FullBackupBase {
this.cipher = Cipher.getInstance("AES/CTR/NoPadding"); this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
this.mac = Mac.getInstance("HmacSHA256"); this.mac = Mac.getInstance("HmacSHA256");
this.outputStream = new FileOutputStream(output); this.outputStream = outputStream;
this.iv = Util.getSecretBytes(16); this.iv = Util.getSecretBytes(16);
this.counter = Conversions.byteArrayToInt(iv); 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 { public void close() throws IOException {
outputStream.close(); outputStream.close();
} }

View File

@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.database.helpers.ClassicOpenHelper;
import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase; 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.LokiMessageDatabase;
import org.thoughtcrime.securesms.loki.database.LokiPreKeyBundleDatabase; import org.thoughtcrime.securesms.loki.database.LokiPreKeyBundleDatabase;
import org.thoughtcrime.securesms.loki.database.LokiPreKeyRecordDatabase; import org.thoughtcrime.securesms.loki.database.LokiPreKeyRecordDatabase;
@ -74,6 +75,7 @@ public class DatabaseFactory {
private final LokiMessageDatabase lokiMessageDatabase; private final LokiMessageDatabase lokiMessageDatabase;
private final LokiThreadDatabase lokiThreadDatabase; private final LokiThreadDatabase lokiThreadDatabase;
private final LokiUserDatabase lokiUserDatabase; private final LokiUserDatabase lokiUserDatabase;
private final LokiBackupFilesDatabase lokiBackupFilesDatabase;
private final SharedSenderKeysDatabase sskDatabase; private final SharedSenderKeysDatabase sskDatabase;
public static DatabaseFactory getInstance(Context context) { public static DatabaseFactory getInstance(Context context) {
@ -190,6 +192,10 @@ public class DatabaseFactory {
return getInstance(context).lokiUserDatabase; return getInstance(context).lokiUserDatabase;
} }
public static LokiBackupFilesDatabase getLokiBackupFilesDatabase(Context context) {
return getInstance(context).lokiBackupFilesDatabase;
}
public static SharedSenderKeysDatabase getSSKDatabase(Context context) { public static SharedSenderKeysDatabase getSSKDatabase(Context context) {
return getInstance(context).sskDatabase; return getInstance(context).sskDatabase;
} }
@ -232,6 +238,7 @@ public class DatabaseFactory {
this.lokiMessageDatabase = new LokiMessageDatabase(context, databaseHelper); this.lokiMessageDatabase = new LokiMessageDatabase(context, databaseHelper);
this.lokiThreadDatabase = new LokiThreadDatabase(context, databaseHelper); this.lokiThreadDatabase = new LokiThreadDatabase(context, databaseHelper);
this.lokiUserDatabase = new LokiUserDatabase(context, databaseHelper); this.lokiUserDatabase = new LokiUserDatabase(context, databaseHelper);
this.lokiBackupFilesDatabase = new LokiBackupFilesDatabase(context, databaseHelper);
this.sskDatabase = new SharedSenderKeysDatabase(context, databaseHelper); this.sskDatabase = new SharedSenderKeysDatabase(context, databaseHelper);
} }

View File

@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
import org.thoughtcrime.securesms.database.PushDatabase; 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 lokiV11 = 32;
private static final int lokiV12 = 33; private static final int lokiV12 = 33;
private static final int lokiV13 = 34; 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 static final String DATABASE_NAME = "signal.db";
private final Context context; private final Context context;
@ -161,6 +163,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand()); db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand());
db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand()); db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand());
db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand()); db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand());
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupRatchetTableCommand()); db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupRatchetTableCommand());
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand()); db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand());
@ -619,6 +622,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.getCreateReceivedMessageHashValuesTable3Command()); db.execSQL(LokiAPIDatabase.getCreateReceivedMessageHashValuesTable3Command());
} }
if (oldVersion < lokiV14_BACKUP_FILES) {
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -1,32 +1,22 @@
package org.thoughtcrime.securesms.jobs; package org.thoughtcrime.securesms.jobs;
import android.Manifest;
import androidx.annotation.NonNull; 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.database.NoExternalStorageException;
import org.thoughtcrime.securesms.loki.database.BackupFileRecord;
import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.service.GenericForegroundService; import org.thoughtcrime.securesms.service.GenericForegroundService;
import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.ExternalStorageUtil;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.text.SimpleDateFormat; import java.util.Collections;
import java.util.Date;
import java.util.Locale;
import network.loki.messenger.R; 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 class LocalBackupJob extends BaseJob {
public static final String KEY = "LocalBackupJob"; public static final String KEY = "LocalBackupJob";
@ -59,10 +49,6 @@ public class LocalBackupJob extends BaseJob {
public void onRun() throws NoExternalStorageException, IOException { public void onRun() throws NoExternalStorageException, IOException {
Log.i(TAG, "Executing backup job..."); 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, GenericForegroundService.startForegroundTask(context,
context.getString(R.string.LocalBackupJob_creating_backup), context.getString(R.string.LocalBackupJob_creating_backup),
NotificationChannels.BACKUPS, NotificationChannels.BACKUPS,
@ -71,34 +57,9 @@ public class LocalBackupJob extends BaseJob {
// TODO: Maybe create a new backup icon like ic_signal_backup? // TODO: Maybe create a new backup icon like ic_signal_backup?
try { try {
String backupPassword = BackupPassphrase.get(context); BackupFileRecord record = BackupUtil.createBackupFile(context);
File backupDirectory = ExternalStorageUtil.getBackupDir(context); BackupUtil.deleteAllBackupFiles(context, Collections.singletonList(record));
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);
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 { } finally {
GenericForegroundService.stopForegroundTask(context); GenericForegroundService.stopForegroundTask(context);
} }

View File

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

View File

@ -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<BackupFileRecord> {
databaseHelper.readableDatabase.query(TABLE_NAME, allColumns, null, null, null, null, null).use {
val records = ArrayList<BackupFileRecord>()
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
}
}

View File

@ -1,16 +1,17 @@
package org.thoughtcrime.securesms.preferences; package org.thoughtcrime.securesms.preferences;
import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.preference.EditTextPreference; import androidx.preference.EditTextPreference;
import androidx.preference.Preference; import androidx.preference.Preference;
import android.text.TextUtils;
import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe; 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.logging.Log;
import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.preferences.widgets.ProgressPreference; import org.thoughtcrime.securesms.preferences.widgets.ProgressPreference;
import org.thoughtcrime.securesms.util.BackupDirSelector;
import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Trimmer; import org.thoughtcrime.securesms.util.Trimmer;
@ -37,6 +39,8 @@ import network.loki.messenger.R;
public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
private static final String TAG = ChatsPreferenceFragment.class.getSimpleName(); private static final String TAG = ChatsPreferenceFragment.class.getSimpleName();
private BackupDirSelector backupDirSelector;
@Override @Override
public void onCreate(Bundle paramBundle) { public void onCreate(Bundle paramBundle) {
super.onCreate(paramBundle); super.onCreate(paramBundle);
@ -64,6 +68,8 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
// initializeListSummary((ListPreference) findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF)); // initializeListSummary((ListPreference) findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF));
backupDirSelector = new BackupDirSelector(this);
EventBus.getDefault().register(this); EventBus.getDefault().register(this);
} }
@ -90,6 +96,12 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); 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) @Subscribe(threadMode = ThreadMode.MAIN)
public void onEvent(BackupEvent event) { public void onEvent(BackupEvent event) {
ProgressPreference preference = (ProgressPreference)findPreference(TextSecurePreferences.BACKUP_NOW); ProgressPreference preference = (ProgressPreference)findPreference(TextSecurePreferences.BACKUP_NOW);
@ -107,7 +119,7 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
private void setBackupSummary() { private void setBackupSummary() {
findPreference(TextSecurePreferences.BACKUP_NOW) 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() { private void setMediaDownloadSummaries() {
@ -137,17 +149,11 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
private class BackupClickListener implements Preference.OnPreferenceClickListener { private class BackupClickListener implements Preference.OnPreferenceClickListener {
@Override @Override
public boolean onPreferenceClick(Preference preference) { public boolean onPreferenceClick(Preference preference) {
Permissions.with(ChatsPreferenceFragment.this) if (!((SwitchPreferenceCompat) preference).isChecked()) {
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference, backupDirSelector);
.onAllGranted(() -> { } else {
if (!((SwitchPreferenceCompat)preference).isChecked()) { BackupDialog.showDisableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference);
BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference); }
} else {
BackupDialog.showDisableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference);
}
})
.withPermanentDenialDialog(getString(R.string.ChatsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups))
.execute();
return true; return true;
} }
@ -157,17 +163,10 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
@Override @Override
public boolean onPreferenceClick(Preference preference) { public boolean onPreferenceClick(Preference preference) {
Permissions.with(ChatsPreferenceFragment.this) Log.i(TAG, "Queuing backup...");
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) ApplicationContext.getInstance(getContext())
.onAllGranted(() -> { .getJobManager()
Log.i(TAG, "Queing backup..."); .add(new LocalBackupJob());
ApplicationContext.getInstance(getContext())
.getJobManager()
.add(new LocalBackupJob());
})
.withPermanentDenialDialog(getString(R.string.ChatsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups))
.execute();
return true; return true;
} }
} }

View File

@ -52,9 +52,9 @@ public class AvatarHelper {
if (data == null) { if (data == null) {
delete(context, address); delete(context, address);
} else { } else {
FileOutputStream out = new FileOutputStream(getAvatarFile(context, address)); try (FileOutputStream out = new FileOutputStream(getAvatarFile(context, address))) {
out.write(data); out.write(data);
out.close(); }
} }
} }

View File

@ -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<backups.length;i++) {
Log.i(TAG, "Deleting: " + backups[i].getAbsolutePath());
if (!backups[i].delete()) {
Log.w(TAG, "Delete failed: " + backups[i].getAbsolutePath());
}
}
}
} catch (NoExternalStorageException e) {
Log.w(TAG, e);
}
}
public static @NonNull String[] generateBackupPassphrase() {
String[] result = new String[6];
byte[] random = new byte[30];
new SecureRandom().nextBytes(random);
for (int i=0;i<30;i+=5) {
result[i/5] = String.format("%05d", ByteUtil.byteArray5ToLong(random, i) % 100000);
}
return result;
}
private static long getBackupTimestamp(File backup) {
String name = backup.getName();
String[] prefixSuffix = name.split("[.]");
if (prefixSuffix.length == 2) {
String[] parts = prefixSuffix[0].split("\\-");
if (parts.length == 7) {
try {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.YEAR, Integer.parseInt(parts[1]));
calendar.set(Calendar.MONTH, Integer.parseInt(parts[2]) - 1);
calendar.set(Calendar.DAY_OF_MONTH, Integer.parseInt(parts[3]));
calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(parts[4]));
calendar.set(Calendar.MINUTE, Integer.parseInt(parts[5]));
calendar.set(Calendar.SECOND, Integer.parseInt(parts[6]));
calendar.set(Calendar.MILLISECOND, 0);
return calendar.getTimeInMillis();
} catch (NumberFormatException e) {
Log.w(TAG, e);
}
}
}
return -1;
}
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;
}
}
}

View File

@ -0,0 +1,282 @@
package org.thoughtcrime.securesms.util
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.util.Log
import android.widget.Toast
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.backup.BackupPassphrase
import org.thoughtcrime.securesms.backup.FullBackupExporter
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.database.BackupFileRecord
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.whispersystems.libsignal.util.ByteUtil
import java.io.IOException
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.*
import kotlin.jvm.Throws
object BackupUtil {
private const val TAG = "BackupUtil"
/**
* Set app-wide configuration to enable the backups and schedule them.
*
* Make sure that the backup dir is selected prior activating the backup.
* Use [BackupDirSelector] or [setBackupDirUri] manually.
*/
@JvmStatic
@Throws(IOException::class)
fun enableBackups(context: Context, password: String) {
val backupDir = getBackupDirUri(context)
if (backupDir == null || !validateDirAccess(context, backupDir)) {
throw IOException("Backup dir is not set or invalid.")
}
BackupPassphrase.set(context, password)
TextSecurePreferences.setBackupEnabled(context, true)
LocalBackupListener.schedule(context)
}
/**
* Set app-wide configuration to disable the backups.
*
* This call resets the backup dir value.
* Make sure to call [setBackupDirUri] prior next call to [enableBackups].
*
* @param deleteBackupFiles if true, deletes all the previously created backup files
* (if the app has access to them)
*/
@JvmStatic
fun disableBackups(context: Context, deleteBackupFiles: Boolean) {
BackupPassphrase.set(context, null)
TextSecurePreferences.setBackupEnabled(context, false)
if (deleteBackupFiles) {
deleteAllBackupFiles(context)
}
setBackupDirUri(context, null)
}
@JvmStatic
fun getLastBackupTimeString(context: Context, locale: Locale): String {
val timestamp = DatabaseFactory.getLokiBackupFilesDatabase(context).getLastBackupFileTime()
if (timestamp == null) {
return context.getString(R.string.BackupUtil_never)
}
return DateUtils.getExtendedRelativeTimeSpanString(context, locale, timestamp.time)
}
@JvmStatic
fun getLastBackup(context: Context): BackupFileRecord? {
return DatabaseFactory.getLokiBackupFilesDatabase(context).getLastBackupFile()
}
@JvmStatic
fun generateBackupPassphrase(): Array<String> {
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<BackupFileRecord>? = 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)
}
}

View File

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

View File

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

View File

@ -18,9 +18,11 @@ import java.io.File
import java.io.IOException import java.io.IOException
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import kotlin.jvm.Throws
/** /**
* Saves attachment files to an external storage using [MediaStore] API. * 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<SaveAttachmentTask.Attachment, Void, Pair<Int, String?>> { class SaveAttachmentTask : ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Int, String?>> {

View File

@ -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 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"; 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 BACKUP_PASSPHRASE = "pref_backup_passphrase";
private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase"; private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase";
private static final String BACKUP_TIME = "pref_backup_next_time"; private static final String BACKUP_TIME = "pref_backup_next_time";
public static final String BACKUP_NOW = "pref_backup_create"; 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 = "pref_android_screen_lock";
public static final String SCREEN_LOCK_TIMEOUT = "pref_android_screen_lock_timeout"; 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); 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) { public static int getNextPreKeyId(@NonNull Context context) {
return getIntegerPreference(context, NEXT_PRE_KEY_ID, new SecureRandom().nextInt(Medium.MAX_VALUE)); return getIntegerPreference(context, NEXT_PRE_KEY_ID, new SecureRandom().nextInt(Medium.MAX_VALUE));
} }