android备份还原



Backup & Restore is infrequent yet very important functionality for Android apps. It becomes even more critical when an App is not using its own dedicated cloud storage to store user data. As a developer, when you are making heavy use of local storage, it’s important that your app starts back with same experience when reinstall happens. Android comes with its own backup support but not without some problems. Here are some of the problems with in built backup support.

备份和还原对于Android应用而言并不常见,但非常重要。 当应用程序不使用其自己的专用云存储来存储用户数据时,这一点变得尤为重要。 作为开发人员,当您大量使用本地存储时,在重新安装时,您的应用程序应以相同的体验重新开始,这一点很重要。 Android附带了自己的备份支持,但并非没有问题。 这是内置备份支持中的一些问题。

  • Size limit : Android’s built in backup option only supports backups till 25 MB in size. While this might be enough for most apps, it might not be enough for all apps. Apps with huge local storage can not use this.
    大小限制 :Android的内置备份选项仅支持备份直到25 MB。 尽管这对于大多数应用程序来说已经足够了,但对于所有应用程序来说可能还不够。 具有大量本地存储的应用程序无法使用此功能。
  • Password protected / encrypted backups: Password protected backups is not possible with default option. Privacy sensitive apps might want to provide this option to users. Encrypted backups is only possible on version N and above. Note that creating password protected backup is different than encrypting content of backup. Having a password of backup adds extra layer of security because you can combine user given password with random key to block any attempt to open the backup file.
    密码保护/加密备份 :默认选项下无法使用密码保护的备份。 隐私敏感的应用可能希望向用户提供此选项。 加密备份仅在版本N和更高版本上可用。 请注意,创建受密码保护的备份与加密备份内容不同。 拥有备份密码会增加安全性,因为您可以将用户指定的密码与随机密钥结合使用,以阻止任何打开备份文件的尝试。
  • Restoration: Restoration only happens during setup wizard or when the app is reinstalled. Users can’t restore data from within the app anytime they want.
    恢复 :恢复仅在安装向导或重新安装应用程序时发生。 用户无法随时从应用程序中还原数据。
  • Minimum API level: Your app requires minimum API level 23. Users below that will not be able to use it.
    最低API级别 :您的应用要求最低API级别23。低于此级别的用户将无法使用它。
  • Storage preference: Users might not always want to backup their data on Google drive. Giving them capability to store wherever they want is definitely a huge plus point.
    存储偏好设置 :用户可能并不总是希望将其数据备份到Google驱动器上。 使他们能够将其存储在所需的位置绝对是一大优势。
  • Bugs: Bugs like these will be out of your control and your users might suffer because of this.
    错误此类错误将不受您的控制,因此您的用户可能会因此受苦。

Because of all these problems, I decided to provide my own implementation of Backup & Restore in my App. Here’s how I did it.

由于所有这些问题,我决定在我的应用程序中提供自己的备份与还原实现。 这是我的方法。

Theoretically it’s pretty simple. If you take a snapshot of your app’s data directory while creating a backup and restore content of data directory from that snapshot later, your app will be in the exact same state as when that snapshot was taken. Here are the steps that we would need to follow

从理论上讲,这很简单。 如果您在创建备份时恢复了应用程序data目录的快照,并在以后从该快照还原data目录的内容,则您的应用程序将与拍摄该快照时处于完全相同的状态。 这是我们需要遵循的步骤

(Backup)

  • Create a password protected .zip file in external storage.
    在外部存储中创建一个受密码保护的.zip文件。
  • Add all the data directories to that .zip file (that we want to include in backup)
    将所有数据目录添加到该.zip文件(我们要包括在备份中)

(Restore)

  • Extract the password protected .zip file
    提取受密码保护的.zip文件
  • Put back all the directories inside data directory of the app
    将所有目录放回应用程序的data目录中

Let’s go steps by steps to see how you can create backup and restore it later.

让我们逐步介绍如何创建备份并在以后还原它。

  1. Fire ACTION_CREATE_DOCUMENT intent to create a file in external storage. In onActivityResult you get the URI of the file that is about to get created. You can write to this file and this will be our final backup zip file.
    ACTION_CREATE_DOCUMENT意图在外部存储中创建文件。 在onActivityResult您将获取将要创建的文件的URI。 您可以写入此文件,这将是我们的最终备份zip文件。
fun onLocalBackupRequested(activity: Activity) {
    val mimeTypes = arrayOf("application/zip")
    val fn = getBackupFileName(prefix)
    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
        .addCategory(Intent.CATEGORY_OPENABLE)
        .setType("application/zip")
        .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
        .putExtra(
            Intent.EXTRA_TITLE, fn
        )
    activity.startActivityForResult(intent, BACKUP_REQUEST_CODE)
}

And then in your onActivityResult

然后在你的onActivityResult

override fun onActivityResult(
        requestCode: Int,
        resultCode: Int,
        resultIntent: Intent?
    ) {
        super.onActivityResult(requestCode, resultCode, resultIntent)
        if (resultCode == Activity.RESULT_OK && resultIntent != null && requestCode == BACKUP_REQUEST_CODE) {
            lifecycleScope.launch {
                val uri = resultIntent.data!!
                val pfd = contentResolver.openFileDescriptor(uri, "w")
                pfd?.use {
                    FileOutputStream(pfd.fileDescriptor).use { outputStream ->
                        val file = packZipFileForBackup(context)
                        try {
                            file?.inputStream()?.use { input ->
                                input.copyTo(outputStream)                        
                            }
                            
                        } finally {
                            if (file?.exists() == true) {
                                file.delete()
                            }
                        }
                    }
                }
            }
        }
    }

Look at the function packZipFileForBackup . It does all the work of creating our file backup file. Let’s look at the implementation of that function.

看一下函数packZipFileForBackup 。 它完成了创建文件备份文件的所有工作。 让我们看一下该函数的实现。

2. Before we look at the implementation, one important thing to note here is that Java does not have native support for password protected zip files. We’ll use this nice library called Zip4j to make our zips password protected. Now here is the implementation of the function:

2.在我们看一下实现之前,这里要注意的一件事是Java没有对受密码保护的zip文件的本地支持。 我们将使用这个名为Zip4j的漂亮库来使我们的zip密码受保护。 现在是该函数的实现:

suspend fun packZipFileForBackup(context: Context): File? {
  return withContext(Dispatchers.IO) {
    val dbFile = context.getDatabasePath("dbName.db") // replace this with your db name
    val dbParentDirectory = dbFile.parentFile
    val zipFilePath = context.filesDir.path + "/backup.zip" // create zip file for backup
    val zipFile = File(zipFilePath)


    val dataDir = context.filesDir.parentFile
    if (dataDir != null) {
      val sharedPrefDirectoryPath = dataDir.absolutePath + "/shared_prefs"
      val encZipFile = ZipFile(zipFile.absolutePath, "password".toCharArray())
      val zipParameters = ZipParameters()
      zipParameters.isEncryptFiles = true
      zipParameters.encryptionMethod = EncryptionMethod.AES
      encZipFile.addFolder(File(sharedPrefDirectoryPath), zipParameters) // add shared pref directory
      encZipFile.addFolder(context.filesDir, zipParameters) // add files directory
      encZipFile.addFolder(dbParentDirectory, zipParameters) // add database directory
    }
    return@withContext zipFile
  }
}
  • Declare a file with the path you want which will be our final zip backup file
  • Pass this file to Zip4j as a final backup file and add all the data to it
    将此文件作为最终备份文件传递到Zip4j并将所有数据添加到其中
  • Zip4j will pack a zip file for us which will be our backup file
  • We’ll copy entire directories (that we want to include in backup) in our zip file. This will be helpful when restoring as we can just put these directories back into data directory so that we don’t have to worry about file level details. Here, we’ll copy Shared preference, files and database directories in our zip file. You can add any custom directory that you might have created for your app in the zip file.
    我们将整个目录(要包含在备份中的目录)复制到zip文件中。 在恢复时这将很有用,因为我们可以将这些目录放回data目录中,这样我们就不必担心文件级别的详细信息。 在这里,我们将在zip文件中复制“共享”首选项,文件和数据库目录。 您可以在zip文件中添加可能已为应用创建的任何自定义目录。

Look at this snippet. This basically tells Zip4j to encrypt zip files and make zip password protected.

看一下这段代码。 这基本上告诉Zip4j加密zip文件并使zip密码受保护。

val encZipFile = ZipFile(zipFile.absolutePath, "password".toCharArray())
val zipParameters = ZipParameters()      zipParameters.isEncryptFiles = true      zipParameters.encryptionMethod = EncryptionMethod.AES

3. Restoring is exactly the reverse of backup as you’d imagine. We pass the password that was set for this zip and ask Zip4j to extract all the files at a temporary location. Now because we know every folder inside the zip file is just a direct child of the data directory, we just recursively copy all the folder inside zip and put them under data directory as it is without having to worry about names and their location. Let’s see the code.

3.还原与您想象的完全相反。 我们传递为此zip设置的密码,并要求Zip4j在临时位置提取所有文件。 现在,因为我们知道zip文件中的每个文件夹只是data目录的直接子目录,所以我们只需递归地复制zip中的所有文件夹并将其按原样放在data目录下,而不必担心名称和位置。 让我们看一下代码。

You can fire an intent again to select the zip file residing in external storage like this:

您可以再次触发意图以选择驻留在外部存储中的zip文件,如下所示:

fun onLocalRestoreRequested(activity: Activity) {
    val mimeTypes = arrayOf("application/zip", "application/octet-stream", "application/x-zip-compressed", "multipart/x-zip")
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
        .addCategory(Intent.CATEGORY_OPENABLE)
        .setType("*/*")
        .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
        .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
        .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)


    activity.startActivityForResult(intent, RESTORE_REQUEST_CODE)
  }

And then in your onActivityResult , for above request code:

然后在onActivityResult ,获取上述请求代码:

override fun onActivityResult(
        requestCode: Int,
        resultCode: Int,
        resultIntent: Intent?
    ) {
        super.onActivityResult(requestCode, resultCode, resultIntent)
        if (resultCode == Activity.RESULT_OK && resultIntent != null && requestCode == RESTORE_REQUEST_CODE) {
            lifecycleScope.launch {
                val uri = resultIntent.data!!
                contentResolver
                    .takePersistableUriPermission(
                        uri,
                        Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
                    )
                val pfd = contentResolver.openFileDescriptor(uri, "r")
                pfd?.use {
                    FileInputStream(pfd.fileDescriptor).use { inputStream ->
                        try {
                            val restoreResult = restoreFromInputStream(
                                context,
                                inputStream
                            )
                            if (restoreResult) {
                                // Success action
                            } else {
                                //failure action
                            }
                        } catch (e: IncorrectPasswordException) {
                            // incorrect password
                        }
                    }
                }
            }
        }
    }

restoreFromInputStream does the actual restoration of the data. Let’s look at implementation of the function:

restoreFromInputStream实际还原数据。 让我们看一下函数的实现:

suspend fun restoreFromInputStream(context: Context,
  contentStream: InputStream): Boolean {
  return withContext(Dispatchers.IO) {
    var result = false
    var toBeRestoredZipFile: File? = null
    var extractedFilesDir: File? = null
    try {
      val dbFile = context.getDatabasePath("dbName.db")
      val parentDbFile = dbFile.parentFile
      val dataDir = requireNotNull(context.filesDir.parentFile)


      toBeRestoredZipFile = File(dataDir.absolutePath + "/toBeRestored.zip") //zip file that is going to be restored
      extractedFilesDir = File(dataDir.absolutePath + "/toBeRestoredDir") // directory used to temporary extract all files
      extractedFilesDir.mkdir()
      contentStream.use { input ->     //Copy stream into temporary file
          toBeRestoredZipFile.outputStream().use { output ->
            input.copyTo(output)
          }
        }
      val preparedZipFile =
        ZipFile(toBeRestoredZipFile.absolutePath, getFinalZipPass("password").toCharArray())
      preparedZipFile.extractAll(extractedFilesDir.absolutePath) // extract all the files inside the zip file


      //delete existing data directories. these will be replaced with the ones in zip file
      val sharedPrefDirePath = dataDir.absolutePath + "/shared_prefs"
      val sharedPrefDir = File(sharedPrefDirePath)
      sharedPrefDir.deleteRecursively()


      parentDbFile?.listFiles()
        ?.forEach {
          it.deleteRecursively()
        }


      context.filesDir.listFiles()?.forEach {
        it.deleteRecursively()
      }




      // copy all the files that were extracted from zip and place them under `data` directory
      if (extractedFilesDir.exists()) {
        val toBeRestoredFolders = extractedFilesDir.listFiles()
        toBeRestoredFolders?.forEach {
          val contentFolderInData = File(dataDir.absolutePath + "/" + it.name)
          if (!contentFolderInData.exists()) {
            contentFolderInData.mkdir()
          }
          it.copyRecursively(contentFolderInData)
        }
        result = true
      }
    } catch (e: ZipException) {
      if (e.type == ZipException.Type.WRONG_PASSWORD) {
        // Provided password is wrong for the zip hence it can not be extracted
        throw IncorrectPasswordException("Invalid password", e)
      }
      throw e
    } finally {
      if (extractedFilesDir?.exists() == true) {
        extractedFilesDir.deleteRecursively() // delete directory used to extract all files
      }


      if (toBeRestoredZipFile?.exists() == true) {
        toBeRestoredZipFile.delete() // delete actual .zip file that was restored
      }
    }
    return@withContext result
  }
}
  • Copy stream into temporary file
  • Create a temporary directory and extract the zip file content into it. At this point, Zip4j will throw ZipException with type WRONG_PASSWORD 创建一个临时目录并将zip文件内容提取到其中。 此时,Zip4j将抛出类型为WRONG_PASSWORD ZipException
  • Delete existing files and directories of Shared Preferences, files and Database directories.
  • Put all the extracted directories from zip file in data folder as it is.
  • Cleanup temporary zip file and directory used at the end.
  • In case of wrong password, IncorrectPasswordException is being thrown here which is a custom exception. You can opt out of custom exception here and propagate ZipException if you want.
    如果密码IncorrectPasswordException则会在此处引发IncorrectPasswordException ,这是一个自定义异常。 您可以在此处选择退出自定义异常,并根据需要传播ZipException

One important thing to note here is that after restoring all the directories, You’ll have to re create your db instance (SQLiteDatabase instance). Also, you’ll have save all the preferences (in restored preference file) by using SharedPreferences editor API to reflect those changes in current process. I’ll leave that as an exercise for you. I have used coroutines to switch to IO dispatchers in my implementation, but you can RxJava or any other framework that you are using in your project.

这里要注意的一件事是还原所有目录后,您将不得不重新创建数据库实例( SQLiteDatabase实例)。 另外,您SharedPreferences通过使用SharedPreferences编辑器API保存所有首选项(在还原的首选项文件中),以反映当前流程中的那些更改。 我将其留给您练习。 在实现中,我已经使用协程切换到IO调度程序 ,但是您可以在项目中使用RxJava或任何其他框架。

I’ve implemented this in my app Plenary. It’s an RSS reader app with heavy offline support (such as downloading entire articles in your local storage). Since there is no cloud storage for app, it was critical to provide options to backup data on user’s preferred storage. I have provided options to create backups in local storage (covered in this article), Google drive and Dropbox. Creating backups and restoring process is same for any storage. For example, packZipFileForBackup() and restoreFromStream() are also being used for Google drive and Dropbox.

我已经在我的应用程序全体会议中实现了这一点。 这是一个RSS阅读器应用程序,具有强大的脱机支持(例如,将整个文章下载到本地存储中)。 由于没有适用于应用程序的云存储,因此提供选项来备份用户首选存储上的数据至关重要。 我提供了在本地存储(本文介绍),Google驱动器和Dropbox中创建备份的选项。 任何存储的创建备份和还原过程都是相同的。 例如, packZipFileForBackup()restoreFromStream()也被用于Google驱动器和Dropbox。

That’s it! Let me know the feedback in responses section.

而已! 让我知道回复部分的反馈。