Android 13+下Kotlin闹钟App的Room数据库备份恢复权限配置
Android 13+ 下Room数据库备份/恢复的权限配置与代码优化
一、权限适配方案
1. Android 12及以下版本
在AndroidManifest.xml中声明存储权限,并在运行时动态申请:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
动态申请代码示例:
private val requestPermissionsLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> val allGranted = permissions.values.all { it } if (allGranted) { // 权限已授予,执行备份/恢复 } else { // 权限被拒绝,提示用户开启 } } // 触发权限申请 fun requestStoragePermissions() { requestPermissionsLauncher.launch( arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE ) ) }
2. Android 13及以上版本
Android 13拆分了存储权限,且直接访问公共目录的旧方式被限制,推荐以下两种无权限方案:
- 写入备份文件:使用
MediaStore直接写入Download目录,无需申请权限 - 读取备份文件:使用**存储访问框架(SAF)**让用户选择文件,无需申请权限
二、备份功能(Export)代码优化
替换原直接操作File的逻辑,改用MediaStore写入公共Download目录:
fun exportDataToJson(context: Context) { CoroutineScope(Dispatchers.IO).launch { val db = AppDatabase.getDatabase(context) val remedio = db.remedioDao().getRemedio() val alarmeRemedio = db.alarmeDao().getAllAlarme() val itemAlarmeRemedio = db.itemAlarmeDao().getAllItemAlarme() val dataToExport = mapOf( "remedio" to remedio, "alarmeRemedio" to alarmeRemedio, "itemAlarmeRemedio" to itemAlarmeRemedio ) val json = Gson().toJson(dataToExport) // 配置文件信息,自动创建room_backups子目录 val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, "my_remedio.json") put(MediaStore.MediaColumns.MIME_TYPE, "application/json") put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/room_backups") } // 插入文件到MediaStore并写入内容 val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) uri?.let { context.contentResolver.openOutputStream(it)?.use { outputStream -> outputStream.write(json.toByteArray()) withContext(Dispatchers.Main) { Toast.makeText(context, "备份成功", Toast.LENGTH_SHORT).show() } } } ?: run { withContext(Dispatchers.Main) { Toast.makeText(context, "创建备份文件失败", Toast.LENGTH_SHORT).show() } } } }
三、恢复功能(Restore)代码优化
改用SAF让用户选择备份文件,避免权限问题:
1. 启动文件选择器
在Activity/Fragment中注册文件选择回调:
private val pickBackupLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let { CoroutineScope(Dispatchers.IO).launch { // 读取选中文件的内容 val jsonContent = context.contentResolver.openInputStream(it)?.bufferedReader()?.use { reader -> reader.readText() } ?: "" // 执行恢复逻辑 restoreFromJson(context, jsonContent) } } } // 触发文件选择 fun pickBackupFile() { pickBackupLauncher.launch(arrayOf("application/json")) }
2. 优化恢复逻辑
修改原方法直接接收JSON字符串,同时修复闹钟调度的协程异步问题:
fun restoreFromJson(context: Context, jsonContent: String) { if (jsonContent.isEmpty()) { withContext(Dispatchers.Main) { Toast.makeText(context, "备份文件为空", Toast.LENGTH_SHORT).show() } return } val gson = Gson() val jsonObject = JsonParser.parseString(jsonContent).asJsonObject val remedios: List<RemedioVo> = gson.fromJson( jsonObject.getAsJsonArray("remedio"), object : TypeToken<List<RemedioVo>>() {}.type ) val alarmes: List<AlarmeRemedioVo> = gson.fromJson( jsonObject.getAsJsonArray("alarmeRemedio"), object : TypeToken<List<AlarmeRemedioVo>>() {}.type ) val itensAlarme: List<ItemAlarmeRemedioVo> = gson.fromJson( jsonObject.getAsJsonArray("itemAlarmeRemedio"), object : TypeToken<List<ItemAlarmeRemedioVo>>() {}.type ) val database = AppDatabase.getDatabase(context) val remedioDao = database.remedioDao() val alarmeDao = database.alarmeDao() val itemAlarmeDao = database.itemAlarmeDao() CoroutineScope(Dispatchers.IO).launch { try { // 事务保证数据一致性 database.runInTransaction { remedioDao.deleteAllRemedios() remedioDao.insertAllRemedios(remedios) alarmeDao.deleteAllAlarmes() alarmeDao.insertAllAlarmes(alarmes) itemAlarmeDao.deleteAllItensAlarme() itemAlarmeDao.insertAllItensAlarme(itensAlarme) } val currentTime = System.currentTimeMillis() val activeItems = itemAlarmeDao.getAllItemAlarmeMais(currentTime) withContext(Dispatchers.Main) { val alarmManager = context.getSystemService(AlarmManager::class.java) // 取消现有闹钟 activeItems.forEach { item -> val intent = Intent(context, AlarmReceiverNovo::class.java).apply { putExtra("id", item.id) } val pendingIntent = PendingIntent.getBroadcast( context, item.id!!, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) alarmManager.cancel(pendingIntent) } // 重新调度有效闹钟 itensAlarme.filter { it.dataAlarme > currentTime }.forEach { item -> val alarme = alarmes.find { it.id == item.fk_alertaRemedio } val intent = Intent(context, AlarmReceiverNovo::class.java).apply { putExtra("id", item.id) putExtra("remedio", alarme) } val pendingIntent = PendingIntent.getBroadcast( context, item.id!!, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, item.dataAlarme, pendingIntent ) } Toast.makeText(context, "恢复成功", Toast.LENGTH_SHORT).show() } } catch (e: Exception) { withContext(Dispatchers.Main) { Toast.makeText(context, "恢复失败", Toast.LENGTH_SHORT).show() } } } }
四、关键注意事项
- 分区存储适配:Android 10及以上禁止直接访问
/storage/emulated/0路径,必须使用MediaStore或SAF - PendingIntent标记:Android 12及以上必须添加
FLAG_IMMUTABLE(广播场景推荐)或FLAG_MUTABLE - 事务处理:恢复数据时使用Room事务,避免部分数据插入失败导致的不一致
- 权限简化:使用
MediaStore写入和SAF读取无需申请存储权限,减少用户交互
内容的提问来源于stack exchange,提问作者Eliel Moreno
相关产品推荐
相关产品推荐

