313 lines
12 KiB
Kotlin
313 lines
12 KiB
Kotlin
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.widget.Toast
|
|
import androidx.annotation.WorkerThread
|
|
import androidx.documentfile.provider.DocumentFile
|
|
import androidx.fragment.app.Fragment
|
|
import network.loki.messenger.R
|
|
import org.greenrobot.eventbus.EventBus
|
|
import org.session.libsession.utilities.TextSecurePreferences
|
|
import org.session.libsignal.utilities.ByteUtil
|
|
import org.session.libsignal.utilities.Log
|
|
import org.thoughtcrime.securesms.backup.BackupEvent
|
|
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
|
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
|
import org.thoughtcrime.securesms.backup.FullBackupExporter
|
|
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
|
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
|
import org.thoughtcrime.securesms.database.BackupFileRecord
|
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
|
import java.io.IOException
|
|
import java.security.MessageDigest
|
|
import java.security.NoSuchAlgorithmException
|
|
import java.security.SecureRandom
|
|
import java.text.SimpleDateFormat
|
|
import java.util.*
|
|
|
|
object BackupUtil {
|
|
private const val MASTER_SECRET_UTIL_PREFERENCES_NAME = "SecureSMS-Preferences"
|
|
private const val TAG = "BackupUtil"
|
|
const val BACKUP_FILE_MIME_TYPE = "application/session-backup"
|
|
const val BACKUP_PASSPHRASE_LENGTH = 30
|
|
|
|
fun getBackupRecords(context: Context): List<SharedPreference> {
|
|
val prefName = MASTER_SECRET_UTIL_PREFERENCES_NAME
|
|
val preferences = context.getSharedPreferences(prefName, 0)
|
|
val prefList = LinkedList<SharedPreference>()
|
|
prefList.add(SharedPreference.newBuilder()
|
|
.setFile(prefName)
|
|
.setKey(IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF)
|
|
.setValue(preferences.getString(IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, null))
|
|
.build())
|
|
prefList.add(SharedPreference.newBuilder()
|
|
.setFile(prefName)
|
|
.setKey(IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF)
|
|
.setValue(preferences.getString(IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, null))
|
|
.build())
|
|
if (preferences.contains(IdentityKeyUtil.ED25519_PUBLIC_KEY)) {
|
|
prefList.add(SharedPreference.newBuilder()
|
|
.setFile(prefName)
|
|
.setKey(IdentityKeyUtil.ED25519_PUBLIC_KEY)
|
|
.setValue(preferences.getString(IdentityKeyUtil.ED25519_PUBLIC_KEY, null))
|
|
.build())
|
|
}
|
|
if (preferences.contains(IdentityKeyUtil.ED25519_SECRET_KEY)) {
|
|
prefList.add(SharedPreference.newBuilder()
|
|
.setFile(prefName)
|
|
.setKey(IdentityKeyUtil.ED25519_SECRET_KEY)
|
|
.setValue(preferences.getString(IdentityKeyUtil.ED25519_SECRET_KEY, null))
|
|
.build())
|
|
}
|
|
prefList.add(SharedPreference.newBuilder()
|
|
.setFile(prefName)
|
|
.setKey(IdentityKeyUtil.LOKI_SEED)
|
|
.setValue(preferences.getString(IdentityKeyUtil.LOKI_SEED, null))
|
|
.build())
|
|
return prefList
|
|
}
|
|
|
|
@JvmStatic
|
|
fun getLastBackupTimeString(context: Context, locale: Locale): String {
|
|
val timestamp = DatabaseComponent.get(context).lokiBackupFilesDatabase().getLastBackupFileTime()
|
|
if (timestamp == null) {
|
|
return context.getString(R.string.BackupUtil_never)
|
|
}
|
|
return DateUtils.getDisplayFormattedTimeSpanString(context, locale, timestamp.time)
|
|
}
|
|
|
|
@JvmStatic
|
|
fun getLastBackup(context: Context): BackupFileRecord? {
|
|
return DatabaseComponent.get(context).lokiBackupFilesDatabase().getLastBackupFile()
|
|
}
|
|
|
|
@JvmStatic
|
|
fun generateBackupPassphrase(): Array<String> {
|
|
val random = ByteArray(BACKUP_PASSPHRASE_LENGTH).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,
|
|
BACKUP_FILE_MIME_TYPE,
|
|
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")
|
|
}
|
|
|
|
try {
|
|
FullBackupExporter.export(context,
|
|
AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret,
|
|
DatabaseComponent.get(context).openHelper().readableDatabase,
|
|
fileUri,
|
|
backupPassword)
|
|
} catch (e: Exception) {
|
|
// Delete the backup file on any error.
|
|
DocumentsContract.deleteDocument(context.contentResolver, fileUri)
|
|
throw e
|
|
}
|
|
|
|
//TODO Use real file size.
|
|
val record = DatabaseComponent.get(context).lokiBackupFilesDatabase()
|
|
.insertBackupFile(BackupFileRecord(fileUri, -1, date))
|
|
|
|
Log.v(TAG, "A backup file was created: $fileUri")
|
|
|
|
return record
|
|
}
|
|
|
|
@JvmStatic
|
|
@JvmOverloads
|
|
fun deleteAllBackupFiles(context: Context, except: Collection<BackupFileRecord>? = null) {
|
|
val db = DatabaseComponent.get(context).lokiBackupFilesDatabase()
|
|
db.getBackupFiles().iterator().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}")
|
|
}
|
|
}
|
|
|
|
@JvmStatic
|
|
fun computeBackupKey(passphrase: String, salt: ByteArray?): ByteArray {
|
|
return try {
|
|
EventBus.getDefault().post(BackupEvent.createProgress(0))
|
|
val digest = MessageDigest.getInstance("SHA-512")
|
|
val input = passphrase.replace(" ", "").toByteArray()
|
|
var hash: ByteArray = input
|
|
if (salt != null) digest.update(salt)
|
|
for (i in 0..249999) {
|
|
if (i % 1000 == 0) EventBus.getDefault().post(BackupEvent.createProgress(0))
|
|
digest.update(hash)
|
|
hash = digest.digest(input)
|
|
}
|
|
ByteUtil.trim(hash, 32)
|
|
} catch (e: NoSuchAlgorithmException) {
|
|
throw AssertionError(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
} |