Skip to content

用户数据导入导出设计

v4 版本新增用户数据的导入与导出功能,允许用户将输入历史、收藏列表等个人数据导出为文件备份,以及从文件中导入恢复。目的是保护用户数据安全,方便换机迁移和灾难恢复。


1. UserDataService

kotlin
/**
 * 用户数据导入导出服务。
 *
 * 负责将用户数据序列化为 JSON 文件以及从 JSON 文件反序列化恢复数据。
 * 所有操作均为协程化的异步操作,避免阻塞主线程。
 */
class UserDataService(
    private val userInputDao: UserInputDao,
    private val favoriteDao: FavoriteDao,
    private val configStore: ConfigDataStore,
    private val json: Json,
    private val scope: CoroutineScope,
) {
    /**
     * 导出用户数据到指定 URI。
     *
     * @param context Android Context,用于访问 ContentResolver
     * @param uri 目标文件 URI(由系统文件选择器返回)
     * @return 导出结果
     */
    suspend fun exportTo(context: Context, uri: Uri): ExportResult {
        val userInputData = userInputDao.getAll()
        val favoriteData = favoriteDao.getAll()
        val configData = configStore.config.first()

        val backup = UserBackup(
            version = BACKUP_FORMAT_VERSION,
            appVersion = BuildConfig.VERSION_NAME,
            exportedAt = Clock.System.now().toString(),
            data = BackupData(
                userInput = userInputData.map { it.toBackupEntry() },
                favorites = favoriteData.map { it.toBackupEntry() },
                config = configData.toBackupEntry(),
            ),
        )

        val jsonString = json.encodeToString(backup)

        return withContext(Dispatchers.IO) {
            runCatching {
                context.contentResolver.openOutputStream(uri)?.use { output ->
                    output.write(jsonString.toByteArray(Charsets.UTF_8))
                } ?: error("Failed to open output stream for URI: $uri")
            }.fold(
                onSuccess = { ExportResult.Success(itemCount = userInputData.size + favoriteData.size) },
                onFailure = { ExportResult.Failure(it.message ?: "Unknown error") },
            )
        }
    }

    /**
     * 从指定 URI 导入用户数据。
     *
     * @param context Android Context
     * @param uri 源文件 URI
     * @param strategy 导入策略
     * @return 导入结果
     */
    suspend fun importFrom(
        context: Context,
        uri: Uri,
        strategy: ImportStrategy = ImportStrategy.Replace,
    ): ImportResult {
        return withContext(Dispatchers.IO) {
            val jsonString = runCatching {
                context.contentResolver.openInputStream(uri)?.use { input ->
                    input.readBytes().toString(Charsets.UTF_8)
                } ?: error("Failed to open input stream for URI: $uri")
            }.getOrElse {
                return@withContext ImportResult.Failure(it.message ?: "Failed to read file")
            }

            val backup = runCatching {
                json.decodeFromString<UserBackup>(jsonString)
            }.getOrElse {
                return@withContext ImportResult.Failure("Invalid backup file format: ${it.message}")
            }

            // 版本兼容性检查
            if (backup.version > BACKUP_FORMAT_VERSION) {
                return@withContext ImportResult.Failure(
                    "Backup format version ${backup.version} is not supported (max: $BACKUP_FORMAT_VERSION)"
                )
            }

            // 按策略执行导入
            when (strategy) {
                ImportStrategy.Replace -> importReplace(backup)
                ImportStrategy.Merge -> importMerge(backup)
            }
        }
    }

    private suspend fun importReplace(backup: UserBackup): ImportResult {
        // 先保存当前数据用于回滚
        val currentInputData = userInputDao.getAll()
        val currentFavorites = favoriteDao.getAll()

        return runCatching {
            userInputDao.clearAll()
            favoriteDao.clearAll()

            backup.data.userInput.forEach { entry ->
                userInputDao.upsert(entry.toEntity())
            }
            backup.data.favorites.forEach { entry ->
                favoriteDao.upsert(entry.toEntity())
            }
            backup.data.config?.let { config ->
                configStore.updateConfig { config.restoreFromBackup(it) }
            }

            ImportResult.Success(
                importedCount = backup.data.userInput.size + backup.data.favorites.size,
                skippedCount = 0,
                conflictCount = 0,
            )
        }.getOrElse { error ->
            // 回滚
            runCatching {
                userInputDao.clearAll()
                favoriteDao.clearAll()
                currentInputData.forEach { userInputDao.upsert(it) }
                currentFavorites.forEach { favoriteDao.upsert(it) }
            }
            ImportResult.Failure("Import failed, rolled back: ${error.message}")
        }
    }

    private suspend fun importMerge(backup: UserBackup): ImportResult {
        var importedCount = 0
        var conflictCount = 0

        backup.data.userInput.forEach { entry ->
            val existing = userInputDao.getByTextAndType(entry.text, entry.type)
            if (existing != null) {
                conflictCount++
                // 取较高频率和较新时间
                val merged = existing.copy(
                    freq = maxOf(existing.freq, entry.freq),
                    lastUsed = maxOf(existing.lastUsed, entry.lastUsed),
                )
                userInputDao.upsert(merged)
            } else {
                userInputDao.upsert(entry.toEntity())
                importedCount++
            }
        }

        backup.data.favorites.forEach { entry ->
            val existing = favoriteDao.getByText(entry.text)
            if (existing != null) {
                conflictCount++
                val merged = existing.copy(
                    usageCount = maxOf(existing.usageCount, entry.usageCount),
                )
                favoriteDao.upsert(merged)
            } else {
                favoriteDao.upsert(entry.toEntity())
                importedCount++
            }
        }

        backup.data.config?.let { config ->
            configStore.updateConfig { config.restoreFromBackup(it) }
        }

        return ImportResult.Success(
            importedCount = importedCount,
            skippedCount = 0,
            conflictCount = conflictCount,
        )
    }

    companion object {
        private const val BACKUP_FORMAT_VERSION = 1
    }
}

2. 数据模型

2.1 UserBackup / BackupData

kotlin
@Serializable
data class UserBackup(
    val version: Int,
    val appVersion: String,
    val exportedAt: String,
    val data: BackupData,
)

@Serializable
data class BackupData(
    val userInput: List<UserInputBackupEntry>,
    val favorites: List<FavoriteBackupEntry>,
    val config: ConfigBackupEntry? = null,
)

@Serializable
data class UserInputBackupEntry(
    val text: String,
    val type: String,
    val freq: Int,
    val last_used: Long,
)

@Serializable
data class FavoriteBackupEntry(
    val text: String,
    val type: String? = null,
    val usage_count: Int,
    val created_at: Long,
)

@Serializable
data class ConfigBackupEntry(
    // 引擎配置(对应 ImeConfig.EngineConfig)
    val engine: EngineConfigBackupEntry? = null,
    // UI 配置(对应 ImeConfig.UiConfig)
    val ui: UiConfigBackupEntry? = null,
)

@Serializable
data class EngineConfigBackupEntry(
    // 输入体验
    val keyboard_type: String? = null,
    val hand_mode: String? = null,
    val candidate_prediction_enabled: Boolean? = null,
    val single_line_input: Boolean? = null,
    // 功能集单独处理(features 为枚举集合,不在此列出)
)

@Serializable
data class UiConfigBackupEntry(
    // 外观
    val theme_type: String? = null,
    val x_pad_enabled: Boolean? = null,
    // 输入体验
    val latin_use_pinyin_keys_in_x_pad: Boolean? = null,
    val adapt_desktop_swipe_up_gesture: Boolean? = null,
    val candidate_variant_first_enabled: Boolean? = null,
    // 隐私
    val user_input_data_enabled: Boolean? = null,
    // 反馈控制
    val audio_feedback_enabled: Boolean? = null,
    val haptic_feedback_enabled: Boolean? = null,
    val key_animation_enabled: Boolean? = null,
    val key_popup_tips_enabled: Boolean? = null,
    val gesture_slipping_trail_enabled: Boolean? = null,
    val clip_popup_tips_enabled: Boolean? = null,
    val candidates_paging_audio_enabled: Boolean? = null,
    val clip_popup_tips_timeout: Int? = null,
    // 日志与诊断
    val log_level: String? = null,
    val log_storage_path: String? = null,
    // 输入练习演示
    val practice_playback_speed: Float? = null,
    val practice_show_finger_overlay: Boolean? = null,
    val practice_show_swipe_trail: Boolean? = null,
)

2.2 导出文件格式

导出文件格式为 JSON,便于人工检视和跨版本兼容:

json
{
  "version": 1,
  "app_version": "4.0.0",
  "exported_at": "2026-05-12T10:30:00+08:00",
  "data": {
    "user_input": [
      { "text": "你好", "type": "pinyin", "freq": 42, "last_used": 1715472000000 },
      { "text": "hello", "type": "latin", "freq": 15, "last_used": 1715471000000 }
    ],
    "favorites": [
      { "text": "example@email.com", "type": "Email", "usage_count": 3, "created_at": 1715470000000 }
    ],
    "config": {
      "engine": {
        "hand_mode": "Right"
      },
      "ui": {
        "theme_type": "FollowSystem",
        "x_pad_enabled": true,
        "candidate_variant_first_enabled": false,
        "user_input_data_enabled": true,
        "audio_feedback_enabled": true,
        "key_animation_enabled": true,
        "candidates_paging_audio_enabled": true,
        "key_popup_tips_enabled": true,
        "gesture_slipping_trail_enabled": true,
        "clip_popup_tips_enabled": true,
        "clip_popup_tips_timeout": 15
      }
    }
  }
}

设计说明

  • version 为数据格式版本号,便于后续格式变更时做兼容处理
  • app_version 记录导出时的应用版本,用于排查兼容性问题
  • 拼音字典数据为内置数据,不纳入导出范围
  • config 中的字段结构对应 ImeConfig 的嵌套结构(engineui 两个子对象),仅导出与默认值不同的配置项
  • 导入时,备份文件中不存在的可选字段使用 null 默认值,不影响现有配置

3. 结果类型

kotlin
sealed class ExportResult {
    data class Success(val itemCount: Int) : ExportResult()
    data class Failure(val message: String) : ExportResult()
}

sealed class ImportResult {
    data class Success(
        val importedCount: Int,
        val skippedCount: Int,
        val conflictCount: Int,
    ) : ImportResult()

    data class Failure(val message: String) : ImportResult()
}

enum class ImportStrategy {
    Replace,  // 替换:清除现有数据后导入
    Merge,    // 合并:与现有数据合并
}

4. UI 设计

4.1 设置页面中的入口

kotlin
@Composable
fun DataManagementSection(
    onExport: () -> Unit,
    onImport: () -> Unit,
) {
    SettingsSectionHeader("数据管理")

    ClickablePreference(
        title = "导出用户数据",
        description = "将输入历史、收藏和设置导出为备份文件",
        onClick = onExport,
    )

    ClickablePreference(
        title = "导入用户数据",
        description = "从备份文件恢复输入历史、收藏和设置",
        onClick = onImport,
    )
}

4.2 文件选择

kotlin
// 导出:使用 Activity Result API 创建文件
val createFileLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.CreateDocument("application/json")
) { uri ->
    uri?.let { viewModel.handleIntent(ImeIntent.ExportUserData(it)) }
}

// 导入:使用 Activity Result API 打开文件
val openFileLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.OpenDocument(arrayOf("application/json"))
) { uri ->
    uri?.let {
        // 显示导入策略选择对话框
        showImportStrategyDialog = true
        selectedImportUri = it
    }
}

4.3 导入策略选择对话框

kotlin
@Composable
fun ImportStrategyDialog(
    onStrategySelected: (ImportStrategy) -> Unit,
    onDismiss: () -> Unit,
) {
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("导入策略") },
        text = {
            Column {
                Text("请选择数据导入方式:")
                Spacer(modifier = Modifier.height(16.dp))
                // 替换选项
                Row(verticalAlignment = Alignment.CenterVertically) {
                    RadioButton(selected = true, onClick = { /* ... */ })
                    Column {
                        Text("替换现有数据", fontWeight = FontWeight.Bold)
                        Text("清除当前所有数据后导入备份数据", style = bodySmall)
                    }
                }
                // 合并选项
                Row(verticalAlignment = Alignment.CenterVertically) {
                    RadioButton(selected = false, onClick = { /* ... */ })
                    Column {
                        Text("与现有数据合并", fontWeight = FontWeight.Bold)
                        Text("保留现有数据,相同条目取较高频率", style = bodySmall)
                    }
                }
            }
        },
        confirmButton = {
            TextButton(onClick = { onStrategySelected(ImportStrategy.Replace) }) {
                Text("确认导入")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text("取消") }
        },
    )
}

5. 权限与安全

5.1 权限

  • 不需要 WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE 权限
  • 使用 SAF(Storage Access Framework)的 CreateDocumentOpenDocument,系统自动处理权限

5.2 数据安全

  • 导出文件为明文 JSON,不包含加密
  • 导出文件中不包含拼音字典数据(为应用内置数据)
  • 导入时验证文件格式版本,不兼容的版本拒绝导入
  • 导入失败自动回滚,确保数据完整性

6. Intent 扩展

用户数据导入导出新增以下 ImeIntent 子类:

kotlin
// 新增于 ImeIntent
data class ExportUserData(val uri: Uri) : ImeIntent()
data class ImportUserData(val uri: Uri, val strategy: ImportStrategy) : ImeIntent()