ⅴ.进阶定制之限制备份来源
与中国市场上大都售卖无锁版设备不同,海外售卖的不少设备是绑定运营商的。而不同运营商上即便同一个应用,它们预设的数据可能都不同。这时候我们可能需要对备份数据的来源做出限制。
简言之A设备上面备份数据限制恢复到B设备。
如何实现?
因为自动备份模式下不会将数据的appVersionCode传回来,所以判断应用版本的办法行不通。而且有的时候应用版本是一致的,只是运营商不一致。
所以需要我们自己实现,大家可以自行思考。先说我之前想到的几种方案。
备份的时候将设备的名称埋入SP文件,恢复的时候检查SP文件里的值
备份的时候将设备的名称埋入新的File文件,恢复的时候检查File文件的值
这俩方案的缺陷:
方案1的缺点在于备份的逻辑会在原有的文件里增加值,会影响现有的逻辑。
方案2增加了新文件,避免对现有的逻辑造成影响,对方案1有所改善。但它和方案1都存在一个潜在的问题。
问题在于无法保证这个新文件首先被恢复到,也就无保证在恢复执行的一开始就知道本次恢复是否需要。
假使恢复进行到了一半,轮到标记新文件的时候才发现本次恢复需要丢弃,那么将会导致数据错乱。因为系统没有提供Roll back已恢复数据的API,如果我们自己也没做好保存和回退旧的文件处理的话,最后必然发生部分文件已恢复部分没恢复的不一致问题。
要理解这个问题就要搞清楚恢复操作针对文件的执行顺序。
自动备份模式在恢复的时候会逐个调用onRestoreFile(),将各个目录下备份的文件回调过来。目录之间的顺序和备份时候的顺序一致,如下备份的代码可以看出来:从根目录的Data开始,接着File目录开始,然后DB和SP文件。
public abstract class BackupAgent extends ContextWrapper { ... public void onFullBackup(FullBackupDataOutput data) throws IOException { ... // Root dir first. applyXmlFiltersAndDoFullBackupForDomain( packageName, FullBackup.ROOT_TREE_TOKEN, manifestIncludeMap, manifestExcludeSet, traversalExcludeSet, data); // Data dir next. traversalExcludeSet.remove(filesDir); // Database directory. traversalExcludeSet.remove(databaseDir); // SharedPrefs. traversalExcludeSet.remove(sharedPrefsDir); } }
文件内的顺序则通过File#list()获取,而这个API是无法保证得到的文件列表都按照abcd的字母排序。所以在File目录下放标记文件不能保证它首先被恢复到。即便放一个a开头的标记文件也不能完全保证。
★推荐方案★
一般的App鲜少在根目录存放数据,而根目录最先被恢复到。所以我推荐的方案是这样的。
备份的时候将设备的名称埋入根目录的特定文件,恢复的时候检查该File文件,在恢复的初期就决定本次恢复是否需要。为了不影响恢复之后的正常使用,最后还要删除这个标记文件。
废话不多说,看下代码。
Backup里放入标记文件。
class MyBackupAgent : BackupAgentHelper() { ... override fun onFullBackup(data: FullBackupDataOutput?) { // ★ 在备份执行前先将标记文件写入Data目录 // Make backup source file before full backup invoke. writeBackupSourceToFile() super.onFullBackup(data) } private fun writeBackupSourceToFile() { val sourceFile = File(dataDir.absolutePath + File.separator + Constants.BACKUP_SOURCE_FILE_PREFIX + Build.MODEL) if (!sourceFile.exists()) { sourceFile.createNewFile() } } ... }
- Restore检查标记文件。
class MyBackupAgent : BackupAgentHelper() { private var needSkipRestore = false ... override fun onRestoreFile( data: ParcelFileDescriptor?, size: Long, destination: File?, type: Int, mode: Long, mtime: Long ) { if (!needSkipRestore) { val sourceDevice = readBackupSourceFromFile(destination) // ★ 备份源设备名和当前名不一致的时候标记需要跳过 // Mark need skip restore if source got and not match current device. if (!TextUtils.isEmpty(sourceDevice) && !sourceDevice.equals(Build.MODEL)) { needSkipRestore = true } } if (!needSkipRestore) { // Invoke restore if skip flag set. super.onRestoreFile(data, size, destination, type, mode, mtime) } else { // ★ 跳过备份但一定要消费stream防止恢复的进程阻塞 // Consume data to keep restore stream go. consumeData(data!!, size, type, mode, mtime, null) } } ... private fun readBackupSourceFromFile(file: File?): String { if (file == null) return "" var decodeDeviceSource = "" // Got data file with backup source mark. if (file.name.startsWith(Constants.BACKUP_SOURCE_FILE_PREFIX)) { decodeDeviceSource = file.name.replace(Constants.BACKUP_SOURCE_FILE_PREFIX, "") } return decodeDeviceSource } @Throws(IOException::class) fun consumeData(data: ParcelFileDescriptor, size: Long, type: Int, mode: Long, mtime: Long, outFile: File?) { ... } }
- 无论是Backup还是Restore都要将标记文件移除。
class MyBackupAgent : BackupAgentHelper() { ... override fun onDestroy() { super.onDestroy() // 移除标记文件 // Ensure temp source file is removed after backup or restore finished. ensureBackupSourceFileRemoved() } private fun ensureBackupSourceFileRemoved() { val sourceFile = File(dataDir.absolutePath + File.separator + Constants.BACKUP_SOURCE_FILE_PREFIX + Build.MODEL) if (sourceFile.exists()) { val result = sourceFile.delete() } } }
接下里验证代码能否拦截不同设备的备份文件。先在小米手机里备份文件,然后到Pixel模拟器里恢复这个数据。
- 在小米手机里备份
>adb -s c7a1a50c7d27 backup -f auto-backup-cus-xiaomi.ab -apk com.ellison.backupdemo >adb -s c7a1a50c7d27 logcat -s BackupManagerService -s BackupRestoreAgent BackupManagerService: --- Performing full backup for package com.ellison.backupdemo --- BackupRestoreAgent: onCreate() BackupManagerService: agentConnected pkg=com.ellison.backupdemo agent=android.os.BinderProxy@5e68506 BackupManagerService: got agent android.app.IBackupAgent$Stub$Proxy@852a7c7 BackupManagerService: Calling doFullBackup() on com.ellison.backupdemo BackupRestoreAgent: onFullBackup() // ★标记文件里写入了小米的设备名称并备份了 BackupRestoreAgent: writeBackupSourceToFile() sourceFile:/data/user/0/com.ellison.backupdemo/backup-source-Redmi 6A create:true ★ BackupRestoreAgent: onDestroy() BackupManagerService: Adb backup processing complete. BackupRestoreAgent: ensureBackupSourceFileRemoved() sourceFile:/data/user/0/com.ellison.backupdemo/backup-source-Redmi 6A delete:true ★ BackupManagerService: Full backup pass complete.
- 往Pixel手机里恢复,可以看到Pixel的日志里显示跳过了恢复
>adb -s emulator-5554 restore auto-backup-cus-xiaomi.ab >adb -s emulator-5554 logcat -s BackupManagerService -s BackupRestoreAgent BackupManagerService: --- Performing full-dataset restore --- ... BackupRestoreAgent: onRestoreFile() destination:/data/data/com.ellison.backupdemo/backup-source-Redmi 6A type:1 mode:384 mtime:1619355877 currentDevice:sdk_gphone_x86_arm needSkipRestore:false BackupRestoreAgent: readBackupSourceFromFile() file:/data/data/com.ellison.backupdemo/backup-source-Redmi 6A BackupRestoreAgent: readBackupSourceFromFile() source:Redmi 6A BackupRestoreAgent: onRestoreFile() sourceDevice:Redmi 6A // ★从备份数据里读取到了小米的设备名,不同于Pixel模拟器的名称,设定了跳过恢复的flag BackupRestoreAgent: onRestoreFile() destination:/data/data/com.ellison.backupdemo/Post.jpg type:1 mode:384 mtime:1619355781 currentDevice:sdk_gphone_x86_arm needSkipRestore:true BackupRestoreAgent: onRestoreFile() skip restore and consume ★ ... BackupRestoreAgent: onRestoreFinished() BackupManagerService: [UserID:0] adb restore processing complete. BackupRestoreAgent: onDestroy() BackupManagerService: Full restore pass complete.
Pixel模拟器上重新打开App之后确实没有任何数据。
当然如果App确实有在根目录下存放数据,那么建议你仍采用这个方案。
只不过需要给这个特定文件加一个a的前缀,以保证它大多数情况下会被先恢复到。当然为了防止极低的概率下它没有首先被恢复,开发者还需自行加上一个Data目录下文件的暂存和回退处理,以防万一。
更高的定制需求
如果发现备份的设备名称不一致的时候,客户的需求并不是丢弃恢复,而是让我们将运营商之间的diff merge进来呢?
这里提供一个思路。在上述方案的基础之上改下就行了。
比如恢复的一开始通过标记的文件发现备份的不一致,丢弃恢复的同时将待恢复的文件都改个别名暂存到本地。应用再次打开的时候读取暂存的数据和当前数据做对比,然后将diff merge进来。
如果不是限制恢复而是怕恢复的数据被别人看到,需要加个验证保护,怎么做?
譬如在恢复数据结束之后存一个需要验证账号的Flag。当App打开的时候发现Flag的存在会强制验证账户,输入验证码等。
ⅵ.BackupAgent和配置规则的混用
BackupAgent和XML配置并不冲突,在backup逻辑里还可以获取配置的设备条件。比如在onFullBackup()里可以利用FullBackupDataOutput的getTransportFlags()来取得相应的Flag来执行相应的逻辑。
FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED 对应着设备加密条件
FLAG_DEVICE_TO_DEVICE_TRANSFER 对应D2D备份场景条件
class MyBackupAgent: BackupAgentHelper() { ... override fun onFullBackup(data: FullBackupDataOutput?) { Log.d(Constants.TAG_BACKUP, "onFullBackup()") super.onFullBackup(data) if (data != null) { if ((data.transportFlags and FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) != 0) { Log.d(Constants.TAG_BACKUP, "onFullBackup() CLIENT ENCRYPTION NEED") } } } }
Ⅳ. 键值对备份
键值对备份
支持的空间小,而且针对File类型的Backup实现非线程安全
,同时需要自行考虑DB这种大空间文件的备份处理,并不推荐使用。
但本着学习的目的还是要了解一下。
ⅰ. 基本定制
使用这个模式需额外指定BackupAgent
并实现其细节。
<manifest ... > <application android:allowBackup="true" android:backupAgent=".MyBackupAgent" ... > <!-- 为兼容旧版本设备最好加上api_key的meta-data --> <meta-data android:name="com.google.android.backup.api_key" android:value="unused" /> </application> </manifest>
BackupAgent的实现在于告诉BMS每个类型的文件采用什么Key备份和恢复。可以选择高度定制的复杂办法去实现,当然SDK也提供了简单办法。
复杂办法:直接扩展自BackupAgent抽象类,需要自行实现onBackup()和onRestore的细节。包括读取各类型文件并调用对应的Helper实现写入数据到备份文件中以及考虑旧的备份数据的迁移等处理。需要考虑很多细节,代码量很大
简单办法:扩展自系统封装好的BackupAgentHelper类并告知各类型文件对应的KEY和Helper实现即可,高效而简单,但没有提供大容量文件比如DB的备份实现
以扩展BackupAgentHelper的简单办法为例,演示下键值对备份的实现。
SP文件的话SDK提供了特定的SharedPreferencesBackupHelper实现
File文件对应的Helper实现为FileBackupHelper,只限于file目录的数据
其他类型文件比如Data和DB是没有预设Helper实现的,需要自行实现BackupHelper
// MyBackupAgent.kt class MyBackupAgent: BackupAgentHelper() { override fun onCreate() { ... // Init helper for data, file, db and sp files. // Data和DB文件使用FileBackupHelper是无法备份的,此处单纯为了验证下 FileBackupHelper(this, Constants.DATA_NAME).also { addHelper(Constants.BACKUP_KEY_DATA, it) } FileBackupHelper(this, Constants.DB_NAME).also { addHelper(Constants.BACKUP_KEY_DB, it) } // File和SP各自使用对应的Helper是可以备份的 FileBackupHelper(this, Constants.FILE_NAME).also { addHelper(Constants.BACKUP_KEY_FILE, it) } SharedPreferencesBackupHelper(this, Constants.SP_NAME).also { addHelper(Constants.BACKUP_KEY_SP, it) } } ... }
先用bmgr工具执行Backup,然后清除Demo的数据再执行Restore。从日志可以看出来键值对备份和恢复成功进行了。
// 开启bmgr和设置本地传输服务 >adb shell bmgr enabled >adb shell bmgr transport com.android.localtransport/.LocalTransport // Backup >adb shell bmgr backupnow com.ellison.backupdemo Running incremental backup for 1 requested packages. Package @pm@ with result: Success Package com.ellison.backupdemo with result: Success Backup finished with result: Success // 清空数据 >adb shell pm clear com.ellison.backupdemo // 查看Backup Token >adb shell dumpsys backup ... Ancestral: 0 Current: 1 // Restore >adb shell bmgr restore 01 com.ellison.backupdemo Scheduling restore: Local disk image restoreStarting: 1 packages onUpdate: 0 = com.ellison.backupdemo restoreFinished: 0 done
Demo的截图显示File和SP备份和恢复成功了。但存放在Data目录的海报和DB目录都失败了。这也验证了上述的结论。
因为出于备份文件空间的考虑,官方并不建议针对DB文件等大容量文件做键值对备份。理论上可以扩展FileBackupHelper对Data和DB文件做出支持。但Google将关键的备份实现(FileBackupHelperBase和performBackup_checked())对外隐藏,使得简单扩展变得不可能。
StackOverFlow上针对这个问题有过热烈的讨论,唯一的办法是完全自己实现,但随着自动备份的出现,这个问题似乎已经不再重要。
ⅱ.手动发起备份
BackupManager的dataChanged()函数可以告知系统App数据变化了,可以安排备份操作。我们在Demo的Backup Button里添加调用。
class LocalData @Inject constructor(... val backupManager: BackupManager){ fun backupData() { backupManager.dataChanged() } ... }
点击这个Backup Button之后等几秒钟,发现Demo的备份任务被安排进Schedule里,意味着备份操作将被系统发起。
>adb shell dumpsys backup Pending key/value backup: 3 BackupRequest{pkg=com.ellison.backupdemo} ★ ...
我们可以强制这个Schedule的执行,也可以等待系统的调度。
>adb shell bmgr run
BackupManagerService: clearing pending backups PFTBT : backupmanager pftbt token=604faa13 ... BackupManagerService: [UserID:0] awaiting agent for ApplicationInfo{7b6a019 com.ellison.backupdemo} BackupRestoreAgent: onCreate() BackupManagerService: [UserID:0] agentConnected pkg=com.ellison.backupdemo agent=android.os.BinderProxy@be4cabf BackupManagerService: [UserID:0] got agent android.app.IBackupAgent$Stub$Proxy@4eab58c BackupRestoreAgent: onBackup() ★ BackupRestoreAgent: onDestroy() BackupManagerService: [UserID:0] Released wakelock:*backup*-0-1265
ⅲ.手动发起恢复
除了bmgr工具提供的restore以外还可以通过代码手动触发恢复。但这并不安全会影响应用的数据一致性,所以恢复的API requestRestore()废弃了。
我们来验证下,在Demo的Restore Button里添加BackupManager#requestRestore()的调用。
class LocalData @Inject constructor(... val backupManager: BackupManager){ fun restoreData() { backupManager.requestRestore(object: RestoreObserver() { ... }) } ... }
但点击Button之后等一段时间,恢复的日志没有出现,反倒是弹出了无效的警告。
BackupRestoreApp: LocalData#restoreData() BackupManager: requestRestore(): Since Android P app can no longer request restoring of its backup.
ⅳ.备份版本不一致的处理
版本不一致意味着恢复之后的逻辑可能会受到影响,这是我们在定制Backup功能时需要着重考虑的问题。
版本不一致的情况有两种。
现在运行的应用版本比备份时候的版本高,比较常见的场景
现在运行的应用版本比备份时候的版本低,即App降级,不太常见
默认情况下系统会无视App降级的恢复操作,意味着BackupAgent#onRestore()永远不会被回调。
但如果应用对于旧版本数据的兼容处理比较完善,希望支持降级的情况。那么需要在Manifest里打开restoreAnyVersion属性,系统将意识到你的兼容并包并回调你的onRestore处理。
无论哪种情况都可以在BackupAgent#onRestore()回调里拿到备份时的版本。然后读取App当前的VersionCode,执行对应的数据迁移或丢弃处理。
class MyBackupAgent: BackupAgentHelper() { ... override fun onRestore( data: BackupDataInput?, appVersionCode: Int, newState: ParcelFileDescriptor? ) { val packageInfo = packageManager.getPackageInfo(packageName, 0) if (packageInfo.versionCode != appVersionCode) { // Do something. // 可以调用BackupDataInput#restoreEntity() // 或skipEntityData()决定恢复还是丢弃 } else { super.onRestore(data, appVersionCode, newState) } } }
ⅴ.直接扩展BackupAgent
扩展自BackupAgent的需要考虑诸多细节,对这个方案有兴趣的朋友可以参考BackupAgentHelper的源码,也可以查阅官方说明。
https://developer.android.google.cn/guide/topics/data/keyvaluebackup
Ⅴ. 系统App的Backup限制
部分系统App的隐私级别较高,即便手动调用了Backup命令,系统仍将无视。并在日志中给出提示。
BackupManagerService: Beginning adb backup... BackupManagerService: Starting backup confirmation UI, token=1763174695 BackupManagerService: Waiting for backup completion... BackupManagerService: acknowledgeAdbBackupOrRestore : token=1763174695 allow=true BackupManagerService: --- Performing adb backup --- BackupManagerService: Package com.android.phone is not eligible for backup, removing.★提示该App不适合备份操作 BackupManagerService: Adb backup processing complete. BackupManagerService: Full backup pass complete.
这个限制的源码在AppBackupUtils中,解决办法很简单在Manifest文件里明确指定BackupAgent。
其实Google的意图很清楚,这些系统级别的App数据要是被窃取将十分危险,默认禁止这个操作。但如果你指定了Backup代理那代表开发者考虑到了备份和恢复的场景,对这个操作进行了默许,备份操作才会被放行。
Ⅵ. 实战总结
ⅰ. Backup定制的总结
当我们遇到Backup定制任务的时候认真思考下需求再对症下药。为使得这个流程更加直观,做了个流程图分享给大家。
ⅱ. Backup相关属性
三、结语
Android 12 Beta版公开在即,针对Backup功能又做了些改善。主要体现在两个方面,一是将备份规则针对云端备份和设备到设备备份两种场景区分开来,更加合理;二是加大adb backup命令的限制,对Backup功能可能造成的数据泄露进行了封堵。
针对Backup功能的持续改善足以瞥见这个功能的重要性。开发者需要对这些改善保持关注,不断调整Backup功能的开发策略,强化用户的数据安全。给大家一些实用建议。
厂商针对Backup功能的Transport扩展可以是Google云盘也可以是国内服务器,App开发者需要关注自己的备份需求和安全策略
思考App是否支持备份,明确开关allowBackup属性
更为推荐空间更大、定制灵活的自动备份模式
尽快适配Android 12封堵数据泄露的风险
隐私级别很高的数据可以补充设备加密的备份条件在备份阶段拦截
复写BackupAgent可以加入恢复的限制,灵活控制流程,在恢复阶段二次拦截
四、DEMO
https://github.com/ellisonchan/BackupRestoreApp
- 提供了键值对备份模式的实现
- 针对自动备份模式预设了备份规则,并定制了限制备份源的恢复流程