Merge pull request #341 from loki-project/backup-file-storage
Use Storage Access Framework for Backupspull/342/head
commit
6930e8a3e8
@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.loki.database
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Represents a record for a backup file in the [org.thoughtcrime.securesms.database.LokiBackupFilesDatabase].
|
||||
*/
|
||||
data class BackupFileRecord(val id: Long, val uri: Uri, val fileSize: Long, val timestamp: Date) {
|
||||
|
||||
constructor(uri: Uri, fileSize: Long, timestamp: Date) : this(-1, uri, fileSize, timestamp)
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
package org.thoughtcrime.securesms.loki.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.database.Database
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
/**
|
||||
* Keeps track of the backup files saved by the app.
|
||||
* Uses [BackupFileRecord] as an entry data projection.
|
||||
*/
|
||||
class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHelper)
|
||||
: Database(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private const val TABLE_NAME = "backup_files"
|
||||
private const val COLUMN_ID = "_id"
|
||||
private const val COLUMN_URI = "uri"
|
||||
private const val COLUMN_FILE_SIZE = "file_size"
|
||||
private const val COLUMN_TIMESTAMP = "timestamp"
|
||||
|
||||
private val allColumns = arrayOf(COLUMN_ID, COLUMN_URI, COLUMN_FILE_SIZE, COLUMN_TIMESTAMP)
|
||||
|
||||
@JvmStatic
|
||||
val createTableCommand = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$COLUMN_ID INTEGER PRIMARY KEY,
|
||||
$COLUMN_URI TEXT NOT NULL,
|
||||
$COLUMN_FILE_SIZE INTEGER NOT NULL,
|
||||
$COLUMN_TIMESTAMP INTEGER NOT NULL
|
||||
);
|
||||
""".trimIndent()
|
||||
|
||||
private fun mapCursorToRecord(cursor: Cursor): BackupFileRecord {
|
||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_ID))
|
||||
val uriRaw = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_URI))
|
||||
val fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_FILE_SIZE))
|
||||
val timestampRaw = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_TIMESTAMP))
|
||||
return BackupFileRecord(id, Uri.parse(uriRaw), fileSize, Date(timestampRaw))
|
||||
}
|
||||
|
||||
private fun mapRecordToValues(record: BackupFileRecord): ContentValues {
|
||||
val contentValues = ContentValues()
|
||||
if (record.id >= 0) { contentValues.put(COLUMN_ID, record.id) }
|
||||
contentValues.put(COLUMN_URI, record.uri.toString())
|
||||
contentValues.put(COLUMN_FILE_SIZE, record.fileSize)
|
||||
contentValues.put(COLUMN_TIMESTAMP, record.timestamp.time)
|
||||
return contentValues
|
||||
}
|
||||
}
|
||||
|
||||
fun getBackupFiles(): List<BackupFileRecord> {
|
||||
databaseHelper.readableDatabase.query(TABLE_NAME, allColumns, null, null, null, null, null).use {
|
||||
val records = ArrayList<BackupFileRecord>()
|
||||
while (it != null && it.moveToNext()) {
|
||||
val record = mapCursorToRecord(it)
|
||||
records.add(record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
||||
fun insertBackupFile(record: BackupFileRecord): BackupFileRecord {
|
||||
val contentValues = mapRecordToValues(record)
|
||||
val id = databaseHelper.writableDatabase.insertOrThrow(TABLE_NAME, null, contentValues)
|
||||
return BackupFileRecord(id, record.uri, record.fileSize, record.timestamp)
|
||||
}
|
||||
|
||||
fun getLastBackupFileTime(): Date? {
|
||||
// SELECT $COLUMN_TIMESTAMP FROM $TABLE_NAME ORDER BY $COLUMN_TIMESTAMP DESC LIMIT 1
|
||||
databaseHelper.readableDatabase.query(
|
||||
TABLE_NAME,
|
||||
arrayOf(COLUMN_TIMESTAMP),
|
||||
null, null, null, null,
|
||||
"$COLUMN_TIMESTAMP DESC",
|
||||
"1"
|
||||
).use {
|
||||
if (it !== null && it.moveToFirst()) {
|
||||
return Date(it.getLong(0))
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLastBackupFile(): BackupFileRecord? {
|
||||
// SELECT * FROM $TABLE_NAME ORDER BY $COLUMN_TIMESTAMP DESC LIMIT 1
|
||||
databaseHelper.readableDatabase.query(
|
||||
TABLE_NAME,
|
||||
allColumns,
|
||||
null, null, null, null,
|
||||
"$COLUMN_TIMESTAMP DESC",
|
||||
"1"
|
||||
).use {
|
||||
if (it != null && it.moveToFirst()) {
|
||||
return mapCursorToRecord(it)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBackupFile(record: BackupFileRecord): Boolean {
|
||||
return deleteBackupFile(record.id)
|
||||
}
|
||||
|
||||
fun deleteBackupFile(id: Long): Boolean {
|
||||
if (id < 0) {
|
||||
throw IllegalArgumentException("ID must be zero or a positive number.")
|
||||
}
|
||||
return databaseHelper.writableDatabase.delete(TABLE_NAME, "$COLUMN_ID = $id", null) > 0
|
||||
}
|
||||
}
|
@ -1,161 +0,0 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
||||
import org.whispersystems.libsignal.util.ByteUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.Locale;
|
||||
|
||||
//TODO AC: Needs to be refactored to use Storage Access Framework or Media Store API.
|
||||
public class BackupUtil {
|
||||
|
||||
private static final String TAG = BackupUtil.class.getSimpleName();
|
||||
|
||||
public static @NonNull String getLastBackupTime(@NonNull Context context, @NonNull Locale locale) {
|
||||
try {
|
||||
BackupInfo backup = getLatestBackup(context);
|
||||
|
||||
if (backup == null) return context.getString(R.string.BackupUtil_never);
|
||||
else return DateUtils.getExtendedRelativeTimeSpanString(context, locale, backup.getTimestamp());
|
||||
} catch (NoExternalStorageException e) {
|
||||
Log.w(TAG, e);
|
||||
return context.getString(R.string.BackupUtil_unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable BackupInfo getLatestBackup(Context context) throws NoExternalStorageException {
|
||||
File backupDirectory = ExternalStorageUtil.getBackupDir(context);
|
||||
File[] backups = backupDirectory.listFiles();
|
||||
BackupInfo latestBackup = null;
|
||||
|
||||
for (File backup : backups) {
|
||||
long backupTimestamp = getBackupTimestamp(backup);
|
||||
|
||||
if (latestBackup == null || (backupTimestamp != -1 && backupTimestamp > latestBackup.getTimestamp())) {
|
||||
latestBackup = new BackupInfo(backupTimestamp, backup.length(), backup);
|
||||
}
|
||||
}
|
||||
|
||||
return latestBackup;
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
public static void deleteAllBackups(Context context) {
|
||||
try {
|
||||
File backupDirectory = ExternalStorageUtil.getBackupDir(context);
|
||||
File[] backups = backupDirectory.listFiles();
|
||||
|
||||
for (File backup : backups) {
|
||||
if (backup.isFile()) backup.delete();
|
||||
}
|
||||
} catch (NoExternalStorageException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteOldBackups(Context context) {
|
||||
try {
|
||||
File backupDirectory = ExternalStorageUtil.getBackupDir(context);
|
||||
File[] backups = backupDirectory.listFiles();
|
||||
|
||||
if (backups != null && backups.length > 2) {
|
||||
Arrays.sort(backups, (left, right) -> {
|
||||
long leftTimestamp = getBackupTimestamp(left);
|
||||
long rightTimestamp = getBackupTimestamp(right);
|
||||
|
||||
if (leftTimestamp == -1 && rightTimestamp == -1) return 0;
|
||||
else if (leftTimestamp == -1) return 1;
|
||||
else if (rightTimestamp == -1) return -1;
|
||||
|
||||
return (int)(rightTimestamp - leftTimestamp);
|
||||
});
|
||||
|
||||
for (int i=2;i<backups.length;i++) {
|
||||
Log.i(TAG, "Deleting: " + backups[i].getAbsolutePath());
|
||||
|
||||
if (!backups[i].delete()) {
|
||||
Log.w(TAG, "Delete failed: " + backups[i].getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (NoExternalStorageException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull String[] generateBackupPassphrase() {
|
||||
String[] result = new String[6];
|
||||
byte[] random = new byte[30];
|
||||
|
||||
new SecureRandom().nextBytes(random);
|
||||
|
||||
for (int i=0;i<30;i+=5) {
|
||||
result[i/5] = String.format("%05d", ByteUtil.byteArray5ToLong(random, i) % 100000);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static long getBackupTimestamp(File backup) {
|
||||
String name = backup.getName();
|
||||
String[] prefixSuffix = name.split("[.]");
|
||||
|
||||
if (prefixSuffix.length == 2) {
|
||||
String[] parts = prefixSuffix[0].split("\\-");
|
||||
|
||||
if (parts.length == 7) {
|
||||
try {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.set(Calendar.YEAR, Integer.parseInt(parts[1]));
|
||||
calendar.set(Calendar.MONTH, Integer.parseInt(parts[2]) - 1);
|
||||
calendar.set(Calendar.DAY_OF_MONTH, Integer.parseInt(parts[3]));
|
||||
calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(parts[4]));
|
||||
calendar.set(Calendar.MINUTE, Integer.parseInt(parts[5]));
|
||||
calendar.set(Calendar.SECOND, Integer.parseInt(parts[6]));
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
|
||||
return calendar.getTimeInMillis();
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static class BackupInfo {
|
||||
|
||||
private final long timestamp;
|
||||
private final long size;
|
||||
private final File file;
|
||||
|
||||
BackupInfo(long timestamp, long size, File file) {
|
||||
this.timestamp = timestamp;
|
||||
this.size = size;
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,282 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.fragment.app.Fragment
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||
import org.thoughtcrime.securesms.backup.FullBackupExporter
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.loki.database.BackupFileRecord
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener
|
||||
import org.whispersystems.libsignal.util.ByteUtil
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.jvm.Throws
|
||||
|
||||
object BackupUtil {
|
||||
private const val TAG = "BackupUtil"
|
||||
|
||||
/**
|
||||
* Set app-wide configuration to enable the backups and schedule them.
|
||||
*
|
||||
* Make sure that the backup dir is selected prior activating the backup.
|
||||
* Use [BackupDirSelector] or [setBackupDirUri] manually.
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun enableBackups(context: Context, password: String) {
|
||||
val backupDir = getBackupDirUri(context)
|
||||
if (backupDir == null || !validateDirAccess(context, backupDir)) {
|
||||
throw IOException("Backup dir is not set or invalid.")
|
||||
}
|
||||
|
||||
BackupPassphrase.set(context, password)
|
||||
TextSecurePreferences.setBackupEnabled(context, true)
|
||||
LocalBackupListener.schedule(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set app-wide configuration to disable the backups.
|
||||
*
|
||||
* This call resets the backup dir value.
|
||||
* Make sure to call [setBackupDirUri] prior next call to [enableBackups].
|
||||
*
|
||||
* @param deleteBackupFiles if true, deletes all the previously created backup files
|
||||
* (if the app has access to them)
|
||||
*/
|
||||
@JvmStatic
|
||||
fun disableBackups(context: Context, deleteBackupFiles: Boolean) {
|
||||
BackupPassphrase.set(context, null)
|
||||
TextSecurePreferences.setBackupEnabled(context, false)
|
||||
if (deleteBackupFiles) {
|
||||
deleteAllBackupFiles(context)
|
||||
}
|
||||
setBackupDirUri(context, null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getLastBackupTimeString(context: Context, locale: Locale): String {
|
||||
val timestamp = DatabaseFactory.getLokiBackupFilesDatabase(context).getLastBackupFileTime()
|
||||
if (timestamp == null) {
|
||||
return context.getString(R.string.BackupUtil_never)
|
||||
}
|
||||
return DateUtils.getExtendedRelativeTimeSpanString(context, locale, timestamp.time)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getLastBackup(context: Context): BackupFileRecord? {
|
||||
return DatabaseFactory.getLokiBackupFilesDatabase(context).getLastBackupFile()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun generateBackupPassphrase(): Array<String> {
|
||||
val random = ByteArray(30).also { SecureRandom().nextBytes(it) }
|
||||
return Array(6) {i ->
|
||||
String.format("%05d", ByteUtil.byteArray5ToLong(random, i * 5) % 100000)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun validateDirAccess(context: Context, dirUri: Uri): Boolean {
|
||||
val hasWritePermission = context.contentResolver.persistedUriPermissions.any {
|
||||
it.isWritePermission && it.uri == dirUri
|
||||
}
|
||||
if (!hasWritePermission) return false
|
||||
|
||||
val document = DocumentFile.fromTreeUri(context, dirUri)
|
||||
if (document == null || !document.exists()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getBackupDirUri(context: Context): Uri? {
|
||||
val dirUriString = TextSecurePreferences.getBackupSaveDir(context) ?: return null
|
||||
return Uri.parse(dirUriString)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setBackupDirUri(context: Context, uriString: String?) {
|
||||
TextSecurePreferences.setBackupSaveDir(context, uriString)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The selected backup directory if it's valid (exists, is writable).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getSelectedBackupDirIfValid(context: Context): Uri? {
|
||||
val dirUri = getBackupDirUri(context)
|
||||
|
||||
if (dirUri == null) {
|
||||
Log.v(TAG, "The backup dir wasn't selected yet.")
|
||||
return null
|
||||
}
|
||||
if (!validateDirAccess(context, dirUri)) {
|
||||
Log.v(TAG, "Cannot validate the access to the dir $dirUri.")
|
||||
return null
|
||||
}
|
||||
|
||||
return dirUri;
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
fun createBackupFile(context: Context): BackupFileRecord {
|
||||
val backupPassword = BackupPassphrase.get(context)
|
||||
?: throw IOException("Backup password is null")
|
||||
|
||||
val dirUri = getSelectedBackupDirIfValid(context)
|
||||
?: throw IOException("Backup save directory is not selected or invalid")
|
||||
|
||||
val date = Date()
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(date)
|
||||
val fileName = String.format("session-%s.backup", timestamp)
|
||||
|
||||
val fileUri = DocumentsContract.createDocument(
|
||||
context.contentResolver,
|
||||
DocumentFile.fromTreeUri(context, dirUri)!!.uri,
|
||||
"application/x-binary",
|
||||
fileName)
|
||||
|
||||
if (fileUri == null) {
|
||||
Toast.makeText(context, "Cannot create writable file in the dir $dirUri", Toast.LENGTH_LONG).show()
|
||||
throw IOException("Cannot create writable file in the dir $dirUri")
|
||||
}
|
||||
|
||||
FullBackupExporter.export(context,
|
||||
AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret,
|
||||
DatabaseFactory.getBackupDatabase(context),
|
||||
fileUri,
|
||||
backupPassword)
|
||||
|
||||
//TODO Use real file size.
|
||||
val record = DatabaseFactory.getLokiBackupFilesDatabase(context)
|
||||
.insertBackupFile(BackupFileRecord(fileUri, -1, date))
|
||||
|
||||
Log.v(TAG, "Backup file was created: $fileUri")
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun deleteAllBackupFiles(context: Context, except: Collection<BackupFileRecord>? = null) {
|
||||
val db = DatabaseFactory.getLokiBackupFilesDatabase(context)
|
||||
db.getBackupFiles().forEach { record ->
|
||||
if (except != null && except.contains(record)) return@forEach
|
||||
|
||||
// Try to delete the related file. The operation may fail in many cases
|
||||
// (the user moved/deleted the file, revoked the write permission, etc), so that's OK.
|
||||
try {
|
||||
val result = DocumentsContract.deleteDocument(context.contentResolver, record.uri)
|
||||
if (!result) {
|
||||
Log.w(TAG, "Failed to delete backup file: ${record.uri}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete backup file: ${record.uri}", e)
|
||||
}
|
||||
|
||||
db.deleteBackupFile(record)
|
||||
|
||||
Log.v(TAG, "Backup file was deleted: ${record.uri}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An utility class to help perform backup directory selection requests.
|
||||
*
|
||||
* An instance of this class should be created per an [Activity] or [Fragment]
|
||||
* and [onActivityResult] should be called appropriately.
|
||||
*/
|
||||
class BackupDirSelector(private val contextProvider: ContextProvider) {
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE_SAVE_DIR = 7844
|
||||
}
|
||||
|
||||
private val context: Context get() = contextProvider.getContext()
|
||||
|
||||
private var listener: Listener? = null
|
||||
|
||||
constructor(activity: Activity) :
|
||||
this(ActivityContextProvider(activity))
|
||||
|
||||
constructor(fragment: Fragment) :
|
||||
this(FragmentContextProvider(fragment))
|
||||
|
||||
/**
|
||||
* Performs ACTION_OPEN_DOCUMENT_TREE intent to select backup directory URI.
|
||||
* If the directory is already selected and valid, the request will be skipped.
|
||||
* @param force if true, the previous selection is ignored and the user is requested to select another directory.
|
||||
* @param onSelectedListener an optional action to perform once the directory is selected.
|
||||
*/
|
||||
fun selectBackupDir(force: Boolean, onSelectedListener: Listener? = null) {
|
||||
if (!force) {
|
||||
val dirUri = BackupUtil.getSelectedBackupDirIfValid(context)
|
||||
if (dirUri != null && onSelectedListener != null) {
|
||||
onSelectedListener.onBackupDirSelected(dirUri)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Let user pick the dir.
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
// Request read/write permission grant for the dir.
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
|
||||
// Set the default dir.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val dirUri = BackupUtil.getBackupDirUri(context)
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, dirUri
|
||||
?: Uri.fromFile(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)))
|
||||
}
|
||||
|
||||
if (onSelectedListener != null) {
|
||||
this.listener = onSelectedListener
|
||||
}
|
||||
|
||||
contextProvider.startActivityForResult(intent, REQUEST_CODE_SAVE_DIR)
|
||||
}
|
||||
|
||||
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode != REQUEST_CODE_SAVE_DIR) return
|
||||
|
||||
if (resultCode == Activity.RESULT_OK && data != null && data.data != null) {
|
||||
// Acquire persistent access permissions for the file selected.
|
||||
val persistentFlags: Int = data.flags and
|
||||
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
context.contentResolver.takePersistableUriPermission(data.data!!, persistentFlags)
|
||||
|
||||
BackupUtil.setBackupDirUri(context, data.dataString)
|
||||
|
||||
listener?.onBackupDirSelected(data.data!!)
|
||||
}
|
||||
|
||||
listener = null
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface Listener {
|
||||
fun onBackupDirSelected(uri: Uri)
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.loki.database.BackupFileRecord;
|
||||
|
||||
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
//TODO AC: Delete this class when its functionality is
|
||||
// fully replaced by the BackupUtil.kt and related classes.
|
||||
/** @deprecated in favor of {@link BackupUtil} */
|
||||
public class BackupUtilOld {
|
||||
|
||||
private static final String TAG = BackupUtilOld.class.getSimpleName();
|
||||
|
||||
/**
|
||||
* @deprecated this method exists only for the backward compatibility with the legacy Signal backup code.
|
||||
* Use {@link BackupUtil} if possible.
|
||||
*/
|
||||
public static @Nullable BackupInfo getLatestBackup(Context context) throws NoExternalStorageException {
|
||||
BackupFileRecord backup = BackupUtil.getLastBackup(context);
|
||||
if (backup == null) return null;
|
||||
|
||||
|
||||
return new BackupInfo(
|
||||
backup.getTimestamp().getTime(),
|
||||
backup.getFileSize(),
|
||||
new File(backup.getUri().getPath()));
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public static class BackupInfo {
|
||||
|
||||
private final long timestamp;
|
||||
private final long size;
|
||||
private final File file;
|
||||
|
||||
BackupInfo(long timestamp, long size, File file) {
|
||||
this.timestamp = timestamp;
|
||||
this.size = size;
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.Fragment
|
||||
|
||||
/**
|
||||
* A simplified version of [android.content.ContextWrapper],
|
||||
* but properly supports [startActivityForResult] for the implementations.
|
||||
*/
|
||||
interface ContextProvider {
|
||||
fun getContext(): Context
|
||||
fun startActivityForResult(intent: Intent, requestCode: Int)
|
||||
}
|
||||
|
||||
class ActivityContextProvider(private val activity: Activity): ContextProvider {
|
||||
|
||||
override fun getContext(): Context {
|
||||
return activity
|
||||
}
|
||||
|
||||
override fun startActivityForResult(intent: Intent, requestCode: Int) {
|
||||
activity.startActivityForResult(intent, requestCode)
|
||||
}
|
||||
}
|
||||
|
||||
class FragmentContextProvider(private val fragment: Fragment): ContextProvider {
|
||||
|
||||
override fun getContext(): Context {
|
||||
return fragment.requireContext()
|
||||
}
|
||||
|
||||
override fun startActivityForResult(intent: Intent, requestCode: Int) {
|
||||
fragment.startActivityForResult(intent, requestCode)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue