diff --git a/src/org/thoughtcrime/securesms/backup/BackupDialog.java b/src/org/thoughtcrime/securesms/backup/BackupDialog.java index 7f5a68f597..053aec7673 100644 --- a/src/org/thoughtcrime/securesms/backup/BackupDialog.java +++ b/src/org/thoughtcrime/securesms/backup/BackupDialog.java @@ -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() diff --git a/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java b/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java index 0f24f378a0..aede7c441d 100644 --- a/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java +++ b/src/org/thoughtcrime/securesms/backup/BackupPassphrase.java @@ -16,7 +16,7 @@ public class BackupPassphrase { private static final String TAG = BackupPassphrase.class.getSimpleName(); - public static String get(@NonNull Context context) { + public static @Nullable String get(@NonNull Context context) { String passphrase = TextSecurePreferences.getBackupPassphrase(context); String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context); diff --git a/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java index bd0864f065..8d361b89c9 100644 --- a/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/src/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -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 tables = exportSchema(input, outputStream); - int count = 0; + try (BackupFrameOutputStream outputStream = new BackupFrameOutputStream(baseOutputStream, passphrase)) { + outputStream.writeDatabaseVersion(input.getVersion()); - for (String table : tables) { - if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count); - } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count); - } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count); - } else if (table.equals(StickerDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count); - } else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) && - !table.equals(OneTimePreKeyDatabase.TABLE_NAME) && - !table.equals(SessionDatabase.TABLE_NAME) && - !table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) && - !table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) && - !table.startsWith("sqlite_")) - { - count = exportTable(table, input, outputStream, null, null, count); + List tables = exportSchema(input, outputStream); + int count = 0; + + for (String table : tables) { + if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count); + } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count); + } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count); + } else if (table.equals(StickerDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count); + } else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) && + !table.equals(OneTimePreKeyDatabase.TABLE_NAME) && + !table.equals(SessionDatabase.TABLE_NAME) && + !table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) && + !table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) && + !table.startsWith("sqlite_")) + { + count = exportTable(table, input, outputStream, null, null, count); + } } - } - for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); - outputStream.write(preference); - } + for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); + outputStream.write(preference); + } - for (File avatar : AvatarHelper.getAvatarFiles(context)) { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); - outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length()); - } + for (File avatar : AvatarHelper.getAvatarFiles(context)) { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); + outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length()); + } - outputStream.writeEnd(); - outputStream.close(); - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count)); + outputStream.writeEnd(); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count)); + } } private static List exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream) @@ -228,8 +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(); } diff --git a/src/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt b/src/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt index 815e138151..d68b61a6f1 100644 --- a/src/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt +++ b/src/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt @@ -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 { databaseHelper.readableDatabase.query(TABLE_NAME, allColumns, null, null, null, null, null).use { val records = ArrayList() - 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 + } } \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java index 234d12c3b8..938a7ebffd 100644 --- a/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ b/src/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -1,32 +1,22 @@ package org.thoughtcrime.securesms.jobs; -import android.Manifest; import androidx.annotation.NonNull; -import org.thoughtcrime.securesms.backup.BackupPassphrase; -import org.thoughtcrime.securesms.backup.FullBackupExporter; -import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; -import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.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); } diff --git a/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java index 123f3262cb..883e81740f 100644 --- a/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java +++ b/src/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -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); } diff --git a/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java b/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java index 572ae4ab6d..5a082f532b 100644 --- a/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java +++ b/src/org/thoughtcrime/securesms/profiles/AvatarHelper.java @@ -52,9 +52,9 @@ public class AvatarHelper { if (data == null) { delete(context, address); } else { - FileOutputStream out = new FileOutputStream(getAvatarFile(context, address)); - out.write(data); - out.close(); + try (FileOutputStream out = new FileOutputStream(getAvatarFile(context, address))) { + out.write(data); + } } } diff --git a/src/org/thoughtcrime/securesms/util/BackupUtil.kt b/src/org/thoughtcrime/securesms/util/BackupUtil.kt index 3025c2e3db..94a5b1eaf1 100644 --- a/src/org/thoughtcrime/securesms/util/BackupUtil.kt +++ b/src/org/thoughtcrime/securesms/util/BackupUtil.kt @@ -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 } + + @JvmStatic + fun validateDirAccess(context: Context, dirUri: Uri): Boolean { + val hasWritePermission = context.contentResolver.persistedUriPermissions.any { + it.isWritePermission && it.uri == dirUri + } + if (!hasWritePermission) return false + + val document = DocumentFile.fromTreeUri(context, dirUri) + if (document == null || !document.exists()) { + return false + } + + return true + } + + @JvmStatic + fun getBackupDirUri(context: Context): Uri? { + val dirUriString = TextSecurePreferences.getBackupSaveDir(context) ?: return null + return Uri.parse(dirUriString) + } + + @JvmStatic + fun setBackupDirUri(context: Context, uriString: String?) { + TextSecurePreferences.setBackupSaveDir(context, uriString) + } + + /** + * @return The selected backup directory if it's valid (exists, is writable). + */ + @JvmStatic + fun getSelectedBackupDirIfValid(context: Context): Uri? { + val dirUri = getBackupDirUri(context) + + if (dirUri == null) { + Log.v(TAG, "The backup dir wasn't selected yet.") + return null + } + if (!validateDirAccess(context, dirUri)) { + Log.v(TAG, "Cannot validate the access to the dir $dirUri.") + return null + } + + return dirUri; + } + + @JvmStatic + @WorkerThread + @Throws(IOException::class) + fun createBackupFile(context: Context): BackupFileRecord { + val backupPassword = BackupPassphrase.get(context) + ?: throw IOException("Backup password is null") + + val dirUri = getSelectedBackupDirIfValid(context) + ?: throw IOException("Backup save directory is not selected or invalid") + + val date = Date() + val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(date) + val fileName = String.format("session-%s.backup", timestamp) + + val fileUri = DocumentsContract.createDocument( + context.contentResolver, + DocumentFile.fromTreeUri(context, dirUri)!!.uri, + "application/x-binary", + fileName) + + if (fileUri == null) { + Toast.makeText(context, "Cannot create writable file in the dir $dirUri", Toast.LENGTH_LONG).show() + throw IOException("Cannot create writable file in the dir $dirUri") + } + + FullBackupExporter.export(context, + AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret, + DatabaseFactory.getBackupDatabase(context), + fileUri, + backupPassword) + + //TODO Use real file size. + val record = DatabaseFactory.getLokiBackupFilesDatabase(context) + .insertBackupFile(BackupFileRecord(fileUri, -1, date)) + + Log.v(TAG, "Backup file was created: $fileUri") + + return record + } + + @JvmStatic + @JvmOverloads + fun deleteAllBackupFiles(context: Context, except: Collection? = null) { + val db = DatabaseFactory.getLokiBackupFilesDatabase(context) + db.getBackupFiles().forEach { record -> + if (except != null && except.contains(record)) return@forEach + + // Try to delete the related file. The operation may fail in many cases + // (the user moved/deleted the file, revoked the write permission, etc), so that's OK. + try { + val result = DocumentsContract.deleteDocument(context.contentResolver, record.uri) + if (!result) { + Log.w(TAG, "Failed to delete backup file: ${record.uri}") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to delete backup file: ${record.uri}", e) + } + + db.deleteBackupFile(record) + + Log.v(TAG, "Backup file was deleted: ${record.uri}") + } + } +} + +/** + * An utility class to help perform backup directory selection requests. + * + * An instance of this class should be created per an [Activity] or [Fragment] + * and [onActivityResult] should be called appropriately. + */ +class BackupDirSelector(private val contextProvider: ContextProvider) { + + companion object { + private const val REQUEST_CODE_SAVE_DIR = 7844 + } + + private val context: Context get() = contextProvider.getContext() + + private var listener: Listener? = null + + constructor(activity: Activity) : + this(ActivityContextProvider(activity)) + + constructor(fragment: Fragment) : + this(FragmentContextProvider(fragment)) + + /** + * Performs ACTION_OPEN_DOCUMENT_TREE intent to select backup directory URI. + * If the directory is already selected and valid, the request will be skipped. + * @param force if true, the previous selection is ignored and the user is requested to select another directory. + * @param onSelectedListener an optional action to perform once the directory is selected. + */ + fun selectBackupDir(force: Boolean, onSelectedListener: Listener? = null) { + if (!force) { + val dirUri = BackupUtil.getSelectedBackupDirIfValid(context) + if (dirUri != null && onSelectedListener != null) { + onSelectedListener.onBackupDirSelected(dirUri) + } + return + } + + // Let user pick the dir. + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + // Request read/write permission grant for the dir. + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + + // Set the default dir. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val dirUri = BackupUtil.getBackupDirUri(context) + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, dirUri + ?: Uri.fromFile(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS))) + } + + if (onSelectedListener != null) { + this.listener = onSelectedListener + } + + contextProvider.startActivityForResult(intent, REQUEST_CODE_SAVE_DIR) + } + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != REQUEST_CODE_SAVE_DIR) return + + if (resultCode == Activity.RESULT_OK && data != null && data.data != null) { + // Acquire persistent access permissions for the file selected. + val persistentFlags: Int = data.flags and + (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + context.contentResolver.takePersistableUriPermission(data.data!!, persistentFlags) + + BackupUtil.setBackupDirUri(context, data.dataString) + + listener?.onBackupDirSelected(data.data!!) + } + + listener = null + } + + @FunctionalInterface + interface Listener { + fun onBackupDirSelected(uri: Uri) + } } \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/util/BackupUtilOld.java b/src/org/thoughtcrime/securesms/util/BackupUtilOld.java index adaecb8f4d..31aad6b8bd 100644 --- a/src/org/thoughtcrime/securesms/util/BackupUtilOld.java +++ b/src/org/thoughtcrime/securesms/util/BackupUtilOld.java @@ -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