New backup util and backup dir selector.

This commit is contained in:
Anton Chekulaev 2020-09-14 23:33:44 +10:00
parent 5a5702302f
commit 019b47b18f
11 changed files with 378 additions and 209 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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