Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 53b2ac2b authored by Moez Bhatti's avatar Moez Bhatti Committed by GitHub
Browse files

Migrate backup & restore feature to use SAF (#1900)

* Migrate backup & restore feature to use SAF

* Remove unused imports

* Remove unused rx subject

* Remove unused dateFormatter

* Open picker to current dir

* Improve exception messages when handling DocumentFile
parent c31d3890
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ buildscript {
    ext.androidx_appcompat_version = '1.3.1'
    ext.androidx_constraintlayout_version = '1.1.3'
    ext.androidx_core_version = '1.1.0'
    ext.androidx_documentfile_version = '1.0.1'
    ext.androidx_emoji_version = '1.0.0'
    ext.androidx_exifinterface_version = '1.0.0'
    ext.androidx_testrunner_version = '1.1.0-alpha3'
+1 −0
Original line number Diff line number Diff line
@@ -51,6 +51,7 @@ dependencies {
    implementation "androidx.appcompat:appcompat:$androidx_appcompat_version"
    implementation "androidx.core:core-ktx:$androidx_core_version"
    implementation "androidx.exifinterface:exifinterface:$androidx_exifinterface_version"
    implementation "androidx.documentfile:documentfile:$androidx_documentfile_version"

    // glide
    implementation "com.github.bumptech.glide:glide:$glide_version"
+64 −43
Original line number Diff line number Diff line
@@ -18,26 +18,27 @@
 */
package com.moez.QKSMS.repository

import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.provider.Telephony
import androidx.core.content.contentValuesOf
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.moez.QKSMS.common.util.extensions.now
import com.moez.QKSMS.model.BackupFile
import com.moez.QKSMS.model.Message
import com.moez.QKSMS.util.Preferences
import com.moez.QKSMS.util.QkFileObserver
import com.moez.QKSMS.util.tryOrNull
import com.squareup.moshi.Moshi
import io.reactivex.Observable
import io.reactivex.schedulers.Schedulers
import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.Subject
import io.realm.Realm
import okio.buffer
import okio.source
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
@@ -52,10 +53,6 @@ class BackupRepositoryImpl @Inject constructor(
    private val syncRepo: SyncRepository
) : BackupRepository {

    companion object {
        private val BACKUP_DIRECTORY = Environment.getExternalStorageDirectory().toString() + "/QKSMS/Backups"
    }

    data class Backup(
        val messageCount: Int = 0,
        val messages: List<BackupMessage> = listOf()
@@ -91,11 +88,35 @@ class BackupRepositoryImpl @Inject constructor(

    @Volatile private var stopFlag: Boolean = false

    override fun getDefaultBackupPath(): String {
        return "${Environment.getExternalStorageDirectory()}/QKSMS/Backups"
    }

    override fun getBackupDocumentTree(): DocumentFile? {
        return prefs.backupDirectory.get()
                .takeIf { uri -> uri != Uri.EMPTY }
                ?.let { uri -> DocumentFile.fromTreeUri(context, uri) }
                ?.takeIf { dir -> dir.exists() && dir.canRead() && dir.canWrite() }
    }

    override fun getBackupPathUriForPicker(): Uri {
        return prefs.backupDirectory.get().takeIf { uri -> uri != Uri.EMPTY }
                ?: getDefaultBackupPath().toUri()
    }

    override fun persistBackupDirectory(directory: Uri) {
        context.contentResolver.takePersistableUriPermission(directory,
                Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
        prefs.backupDirectory.set(directory)

        Timber.v("Updated backup directory: $directory")
    }

    override fun performBackup() {
        // If a backup or restore is already running, don't do anything
        if (isBackupOrRestoreRunning()) return

        var messageCount = 0
        var messageCount: Int

        // Map all the messages into our object we'll use for the Json mapping
        val backupMessages = Realm.getDefaultInstance().use { realm ->
@@ -119,13 +140,15 @@ class BackupRepositoryImpl @Inject constructor(
        val json = adapter.toJson(Backup(messageCount, backupMessages)).toByteArray()

        try {
            // Create the directory and file
            val dir = File(BACKUP_DIRECTORY).apply { mkdirs() }
            val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(System.currentTimeMillis())
            val file = File(dir, "backup-$timestamp.json")

            // Write the log to the file
            FileOutputStream(file, true).use { fileOutputStream -> fileOutputStream.write(json) }
            val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(now())
            val inputStream = getBackupDocumentTree()
                    ?.createFile("application/json", "backup-$timestamp.json")
                    ?.let { file -> context.contentResolver.openOutputStream(file.uri) }
                    ?: throw Exception("Failed to open output stream")

            inputStream.use { stream ->
                stream.write(json)
            }
        } catch (e: Exception) {
            Timber.w(e)
        }
@@ -151,36 +174,34 @@ class BackupRepositoryImpl @Inject constructor(

    override fun getBackupProgress(): Observable<BackupRepository.Progress> = backupProgress

    override fun getBackups(): Observable<List<BackupFile>> = QkFileObserver(BACKUP_DIRECTORY).observable
            .map { File(BACKUP_DIRECTORY).listFiles() ?: arrayOf() }
            .subscribeOn(Schedulers.io())
            .observeOn(Schedulers.computation())
            .map { files ->
                files.mapNotNull { file ->
    @SuppressLint("Recycle") // InputStream is closed by Okio BufferedSource
    override fun parseBackup(uri: Uri): BackupFile {
        val adapter = moshi.adapter(BackupMetadata::class.java)
                    val backup = tryOrNull(false) {
                        file.source().buffer().use(adapter::fromJson)
                    } ?: return@mapNotNull null

                    val path = file.path
                    val date = file.lastModified()
                    val messages = backup.messageCount
                    val size = file.length()
                    BackupFile(path, date, messages, size)
                }
        val file = DocumentFile.fromSingleUri(context, uri)
                ?: throw IllegalArgumentException("Couldn't open backup file")

        val metadata = context.contentResolver.openInputStream(file.uri)
                ?.source()
                ?.buffer()
                ?.use(adapter::fromJson)
                ?: throw IllegalArgumentException("Couldn't parse backup file")

        return BackupFile(file.lastModified(), metadata.messageCount)
    }
            .map { files -> files.sortedByDescending { file -> file.date } }

    override fun performRestore(filePath: String) {
    override fun performRestore(uri: Uri) {
        // If a backupFile or restore is already running, don't do anything
        if (isBackupOrRestoreRunning()) return

        restoreProgress.onNext(BackupRepository.Progress.Parsing())

        val file = File(filePath)
        val backup = file.source().buffer().use { source ->
            moshi.adapter(Backup::class.java).fromJson(source)
        }
        val adapter = moshi.adapter(Backup::class.java)
        val backup = DocumentFile.fromSingleUri(context, uri)
                ?.let { file -> context.contentResolver.openInputStream(file.uri) }
                ?.source()
                ?.buffer()
                ?.use(adapter::fromJson)

        val messageCount = backup?.messages?.size ?: 0
        var errorCount = 0
@@ -234,12 +255,12 @@ class BackupRepositoryImpl @Inject constructor(
        Timer().schedule(1000) { restoreProgress.onNext(BackupRepository.Progress.Idle()) }
    }

    override fun getRestoreProgress(): Observable<BackupRepository.Progress> = restoreProgress

    override fun stopRestore() {
        stopFlag = true
    }

    override fun getRestoreProgress(): Observable<BackupRepository.Progress> = restoreProgress

    private fun isBackupOrRestoreRunning(): Boolean {
        return backupProgress.blockingFirst().running || restoreProgress.blockingFirst().running
    }
+2 −0
Original line number Diff line number Diff line
@@ -38,6 +38,8 @@ android {
}

dependencies {
    // androidx
    implementation "androidx.documentfile:documentfile:$androidx_documentfile_version"

    // dagger
    implementation "com.google.dagger:dagger:$dagger_version"
+4 −3
Original line number Diff line number Diff line
@@ -18,15 +18,16 @@
 */
package com.moez.QKSMS.interactor

import android.net.Uri
import com.moez.QKSMS.repository.BackupRepository
import io.reactivex.Flowable
import javax.inject.Inject

class PerformRestore @Inject constructor(
    private val backupRepo: BackupRepository
) : Interactor<String>() {
) : Interactor<Uri>() {

    override fun buildObservable(params: String): Flowable<*> {
    override fun buildObservable(params: Uri): Flowable<*> {
        return Flowable.just(params)
                .doOnNext(backupRepo::performRestore)
    }
Loading