mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-24 10:35:19 +00:00
New backup util and backup dir selector.
This commit is contained in:
parent
5a5702302f
commit
019b47b18f
@ -6,22 +6,28 @@ 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 org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.util.BackupDirSelector;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
import org.thoughtcrime.securesms.util.BackupUtilOld;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
public class BackupDialog {
|
||||
|
||||
public static void showEnableBackupDialog(@NonNull Context context, @NonNull SwitchPreferenceCompat preference) {
|
||||
public static void showEnableBackupDialog(
|
||||
@NonNull Context context,
|
||||
@NonNull SwitchPreferenceCompat preference,
|
||||
@NonNull BackupDirSelector backupDirSelector) {
|
||||
|
||||
String[] password = BackupUtil.generateBackupPassphrase();
|
||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.BackupDialog_enable_local_backups)
|
||||
@ -35,12 +41,14 @@ 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 -> {
|
||||
BackupPassphrase.set(context, Util.join(password, " "));
|
||||
TextSecurePreferences.setBackupEnabled(context, true);
|
||||
LocalBackupListener.schedule(context);
|
||||
|
||||
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();
|
||||
}
|
||||
@ -78,7 +86,8 @@ public class BackupDialog {
|
||||
.setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> {
|
||||
BackupPassphrase.set(context, null);
|
||||
TextSecurePreferences.setBackupEnabled(context, false);
|
||||
BackupUtilOld.deleteAllBackups(context);
|
||||
BackupUtil.deleteAllBackupFiles(context);
|
||||
BackupUtil.setBackupDirUri(context, null);
|
||||
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);
|
||||
|
||||
|
@ -5,6 +5,8 @@ import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.annimon.stream.function.Consumer;
|
||||
@ -36,9 +38,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 +67,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 +236,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 +277,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 +289,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 +301,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 +417,12 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
outputStream.close();
|
||||
}
|
||||
|
@ -4,8 +4,10 @@ import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.annotation.NonNull
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.BackupFileRecord
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@ -56,7 +58,7 @@ class LokiBackupFilesDatabase(context: Context?, databaseHelper: SQLCipherOpenHe
|
||||
fun getBackupFiles(): List<BackupFileRecord> {
|
||||
databaseHelper.readableDatabase.query(TABLE_NAME, allColumns, null, null, null, null, null).use {
|
||||
val records = ArrayList<BackupFileRecord>()
|
||||
while (it != null && it.moveToFirst()) {
|
||||
while (it != null && it.moveToNext()) {
|
||||
val record = mapCursorToRecord(it)
|
||||
records.add(record)
|
||||
}
|
||||
@ -64,9 +66,10 @@ class LokiBackupFilesDatabase(context: Context?, databaseHelper: SQLCipherOpenHe
|
||||
}
|
||||
}
|
||||
|
||||
fun insertBackupFile(record: BackupFileRecord): Long {
|
||||
fun insertBackupFile(record: BackupFileRecord): BackupFileRecord {
|
||||
val contentValues = mapRecordToValues(record)
|
||||
return databaseHelper.writableDatabase.insertOrThrow(TABLE_NAME, null, contentValues)
|
||||
val id = databaseHelper.writableDatabase.insertOrThrow(TABLE_NAME, null, contentValues)
|
||||
return BackupFileRecord(id, record.uri, record.fileSize, record.timestamp)
|
||||
}
|
||||
|
||||
fun getLastBackupFileTime(): Date? {
|
||||
@ -102,4 +105,15 @@ class LokiBackupFilesDatabase(context: Context?, databaseHelper: SQLCipherOpenHe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,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.database.model.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.BackupUtilOld;
|
||||
import org.thoughtcrime.securesms.util.ExternalStorageUtil;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
|
||||
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!");
|
||||
}
|
||||
|
||||
BackupUtilOld.deleteOldBackups(context);
|
||||
} finally {
|
||||
GenericForegroundService.stopForegroundTask(context);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ 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;
|
||||
|
||||
@ -24,7 +25,9 @@ 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.FragmentContextProvider;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Trimmer;
|
||||
|
||||
@ -38,6 +41,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);
|
||||
@ -65,6 +70,8 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
|
||||
|
||||
// initializeListSummary((ListPreference) findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF));
|
||||
|
||||
backupDirSelector = new BackupDirSelector(this);
|
||||
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@ -91,6 +98,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);
|
||||
@ -143,7 +156,7 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.onAllGranted(() -> {
|
||||
if (!((SwitchPreferenceCompat)preference).isChecked()) {
|
||||
BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference);
|
||||
BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference, backupDirSelector);
|
||||
} else {
|
||||
BackupDialog.showDisableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference);
|
||||
}
|
||||
|
@ -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,14 +1,31 @@
|
||||
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.database.model.BackupFileRecord
|
||||
import org.whispersystems.libsignal.util.ByteUtil
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object BackupUtil {
|
||||
private const val TAG = "BackupUtil"
|
||||
|
||||
@JvmStatic
|
||||
fun getLastBackupTimeString(context: Context, locale: Locale): String {
|
||||
@ -35,4 +52,195 @@ object BackupUtil {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as Array<String>
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
@ -4,6 +4,8 @@ package org.thoughtcrime.securesms.util;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.BackupFileRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
@ -16,125 +18,29 @@ 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.
|
||||
//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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {
|
||||
File backupDirectory = ExternalStorageUtil.getBackupDir(context);
|
||||
File[] backups = backupDirectory.listFiles();
|
||||
BackupInfo latestBackup = null;
|
||||
BackupFileRecord backup = BackupUtil.getLastBackup(context);
|
||||
if (backup == null) return 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;
|
||||
return new BackupInfo(
|
||||
backup.getTimestamp().getTime(),
|
||||
backup.getFileSize(),
|
||||
new File(backup.getUri().getPath()));
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public static class BackupInfo {
|
||||
|
||||
private final long timestamp;
|
||||
|
35
src/org/thoughtcrime/securesms/util/ContextProvider.kt
Normal file
35
src/org/thoughtcrime/securesms/util/ContextProvider.kt
Normal file
@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -146,6 +146,7 @@ public class TextSecurePreferences {
|
||||
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…
Reference in New Issue
Block a user