mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-28 18:57:43 +00:00
Merge pull request #341 from loki-project/backup-file-storage
Use Storage Access Framework for Backups
This commit is contained in:
commit
6930e8a3e8
@ -1866,4 +1866,6 @@
|
||||
<string name="attachment_type_voice_message">Voice Message</string>
|
||||
<string name="details">Details</string>
|
||||
|
||||
<string name="dialog_backup_activation_failed">Failed to activate backups. Please try again or contact support.</string>
|
||||
|
||||
</resources>
|
||||
|
@ -94,7 +94,7 @@
|
||||
|
||||
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:key="pref_backup_enabled"
|
||||
android:key="pref_backup_enabled_v2"
|
||||
android:title="@string/preferences_chats__chat_backups"
|
||||
android:summary="@string/preferences_chats__backup_chats_to_external_storage" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
@ -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<Void, Void, BackupUtil.BackupInfo>() {
|
||||
new AsyncTask<Void, Void, BackupUtilOld.BackupInfo>() {
|
||||
@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) {
|
||||
|
@ -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()
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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<String> 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<String> 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<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));
|
||||
|
||||
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();
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
282
src/org/thoughtcrime/securesms/util/BackupUtil.kt
Normal file
282
src/org/thoughtcrime/securesms/util/BackupUtil.kt
Normal 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)
|
||||
}
|
||||
}
|
61
src/org/thoughtcrime/securesms/util/BackupUtilOld.java
Normal file
61
src/org/thoughtcrime/securesms/util/BackupUtilOld.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
37
src/org/thoughtcrime/securesms/util/ContextProvider.kt
Normal file
37
src/org/thoughtcrime/securesms/util/ContextProvider.kt
Normal 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)
|
||||
}
|
||||
}
|
@ -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<SaveAttachmentTask.Attachment, Void, Pair<Int, String?>> {
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user