From 07ce1e9a77a5882d8eec58d6aa807c501cff314c Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 16 Sep 2026 16:53:36 +0800 Subject: [PATCH 01/15] chore(release): prepare 2.0.0-Beta.2 fork release --- .github/workflows/android-apk.yml | 2 +- .github/workflows/release-tauri.yml | 2 +- openless-all/app/package-lock.json | 4 ++-- openless-all/app/package.json | 2 +- openless-all/app/src-tauri/Cargo.lock | 2 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/tauri.conf.json | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index ff1bf6d6e..30b784a5e 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -348,7 +348,7 @@ jobs: working-directory: openless-all/app env: OPENLESS_UPDATE_APK_DIR: ${{ steps.apk.outputs.out_dir }} - OPENLESS_UPDATE_REPO: Open-Less/openless + OPENLESS_UPDATE_REPO: ${{ github.repository }} OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} OPENLESS_RELEASE_TAG: ${{ github.ref_name }} diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index f161811e3..1503aac40 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -387,7 +387,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} OPENLESS_UPDATE_TARGET: ${{ matrix.updater-target }} OPENLESS_UPDATE_ARCH: ${{ matrix.updater-arch }} - OPENLESS_UPDATE_REPO: Open-Less/openless + OPENLESS_UPDATE_REPO: ${{ github.repository }} OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com # beta 渠道时输出 latest-{tgt}-{arch}-beta.json,stable 沿用旧文件名。 OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 31fde046d..120132464 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "2.0.0-Beta.1", + "version": "2.0.0-Beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "2.0.0-Beta.1", + "version": "2.0.0-Beta.2", "dependencies": { "@base-ui/react": "^1.6.0", "@shadcn/react": "^0.2.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index bbe7f4a17..594113b27 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "2.0.0-Beta.1", + "version": "2.0.0-Beta.2", "type": "module", "scripts": { "pretest": "npm run build", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 6cacfb09a..021319e72 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "openless" -version = "2.0.0-Beta.1" +version = "2.0.0-Beta.2" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 13fc610a6..ae3023ba8 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "2.0.0-Beta.1" +version = "2.0.0-Beta.2" license = "AGPL-3.0-only" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 0005e47f7..f27f11742 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "2.0.0-Beta.1", + "version": "2.0.0-Beta.2", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", From 4e8b509a239b60a63430015897585008111d5e9c Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 15 Oct 2026 00:49:40 +0800 Subject: [PATCH 02/15] feat(android): add local migration bundle Co-Authored-By: Claude Fable 5 --- docs/research/issue-1192.md | 201 ++++++ .../android/kotlin/OpenLessContentReader.kt | 54 +- .../android/kotlin/OpenLessContentWriter.kt | 68 +- .../OpenLessKeyboardSettingsActivity.kt | 498 +++++++++++--- .../kotlin/OpenLessMigrationBundlePolicy.kt | 35 + .../app/android/kotlin/OpenLessNative.kt | 25 + .../android/kotlin/OpenLessSettingsExport.kt | 87 +++ ...OpenLessMigrationBundleInstrumentedTest.kt | 18 + .../OpenLessMigrationBundleContractTest.kt | 36 + .../app/crates/openless-core/src/api.rs | 119 ++-- .../src/cloud_sync_e2ee/adapter.rs | 48 ++ .../openless-core/src/cloud_sync_e2ee/api.rs | 59 ++ .../openless-core/src/cloud_sync_e2ee/mod.rs | 2 +- .../src/cloud_sync_e2ee_documents/export.rs | 13 +- .../src/cloud_sync_e2ee_documents/merge.rs | 5 +- .../src/cloud_sync_e2ee_documents/mod.rs | 67 ++ .../src/cloud_sync_e2ee_documents/registry.rs | 97 +++ .../src/cloud_sync_e2ee_documents/tests.rs | 18 + .../src/cloud_sync_e2ee_documents/types.rs | 2 + .../src/cloud_sync_e2ee_documents/validate.rs | 10 +- .../src/cloud_sync_e2ee_store/mod.rs | 412 ++++++++++- .../src/cloud_sync_e2ee_store/native.rs | 60 ++ .../src/cloud_sync_e2ee_store/tests.rs | 39 +- .../openless-core/src/dictation_engine.rs | 12 +- .../app/crates/openless-core/src/lib.rs | 1 + .../src/local_migration_bundle/container.rs | 645 ++++++++++++++++++ .../src/local_migration_bundle/crypto.rs | 130 ++++ .../src/local_migration_bundle/mod.rs | 10 + .../src/local_migration_bundle/types.rs | 366 ++++++++++ .../tests/local_bundle_contract.rs | 156 +++++ .../app/scripts/copy-android-scaffolding.mjs | 3 + .../scripts/local-bundle-contract.test.mjs | 36 + .../src-tauri/src/android/native_bridge.rs | 359 ++++++++++ .../src-tauri/src/persistence/credentials.rs | 1 + 34 files changed, 3519 insertions(+), 173 deletions(-) create mode 100644 docs/research/issue-1192.md create mode 100644 openless-all/app/android/kotlin/OpenLessMigrationBundlePolicy.kt create mode 100644 openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt create mode 100644 openless-all/app/android/kotlin/test/OpenLessMigrationBundleContractTest.kt create mode 100644 openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs create mode 100644 openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs create mode 100644 openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs create mode 100644 openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs create mode 100644 openless-all/app/crates/openless-core/tests/local_bundle_contract.rs create mode 100644 openless-all/app/scripts/local-bundle-contract.test.mjs diff --git a/docs/research/issue-1192.md b/docs/research/issue-1192.md new file mode 100644 index 000000000..0d8efe097 --- /dev/null +++ b/docs/research/issue-1192.md @@ -0,0 +1,201 @@ +# Issue #1192 调研:Android 整套配置本地导入 / 导出 + +调研日期:2026-10-14 +Issue: + +## 结论摘要 + +Issue #1192 对 **v1.3.18 稳定版用户**仍然是有效需求,但对当前 `beta` 代码树来说不是从零开始开发:Android IME 已经有一版“导出 / 导入配置”实现,能够通过系统文件选择器迁移一部分本地设置、键盘学习数据、当前 ASR/LLM 渠道和可选 API Key。 + +这版实现还不能称为“整套配置迁移”。主要缺口是: + +- 没有导出完整的 Android / App 偏好; +- 只处理当前激活的 ASR 与 LLM 渠道,不是全部渠道,也没有在不携带凭据时保留完整的非敏感端点 / 模型 / 高级参数; +- 只迁移笔画频率和简拼学习,未迁移 Core 词典、词库预设和完整词库状态; +- 只迁移风格包 ID,不迁移风格包本体、提示词、示例和图标; +- 导出文件是明文 JSON,没有“个人迁移 / 分享配置”两种安全模式、密码加密、导入前备份或可回滚事务; +- 未找到 `OpenLessSettingsExport` 的直接 Kotlin 单元测试或端到端往返测试。 + +因此建议 **保留 Issue 作为完整迁移的总需求,不直接关闭**,但将现有实现认定为部分交付,并拆出“完整本地 bundle / 安全分享 / 原子导入 / 测试验证”等子任务。若产品只要求“换机时减少重复填写”,当前 beta 版可以作为 MVP;若要求 Issue 正文中的验收标准,则仍需继续实现。 + +## 一手来源 + +- Issue 正文与评论: +- 稳定版 v1.3.18(2026-08-21): +- 当前 beta 的 Android APK 发布版 v2.0.0-Beta.4(2026-09-30): +- Android IME 大型 PR #1067(已关闭、未标记为 merged): +- Android 凭据安全边界 Issue #817: + +## Issue 要求拆解 + +Issue #1192 要求一个不依赖同一账号、云同步或链接服务的本地文件入口,覆盖: + +1. ASR / LLM 及其他服务的协议、渠道名称、Base URL / 完整地址、模型、启用状态、激活渠道和高级参数; +2. 通用词典、用户词条、预设词库、当前激活状态; +3. 风格包本体、提示词、示例及当前默认风格; +4. Android 应用偏好,例如语言、主题、输入与剪贴板策略、触发 / 录音设置; +5. API Key 的显式选择,普通分享默认脱敏; +6. 导入前显示来源、版本、类别和变更摘要,支持合并或替换,并自动备份原配置; +7. 权限不随配置文件迁移,导入后只提示接收者重新授权; +8. 版本化格式、兼容性校验、设备不适用配置的显式跳过或确认。 + +Issue 的唯一评论来自维护者,内容为“满足你。”;没有关联 PR、标签或进一步的验收讨论。该评论不能替代上述完整验收标准。 + +## 当前代码现状 + +### 稳定版与 beta 的边界 + +在 `v1.3.18-tauri` tag 中不存在 `OpenLessSettingsExport.kt`、Android IME 设置页和 `android_transfer.rs`;这与 Issue 正文以官方 APK 1.3.18 为主要场景的描述一致。当前 `beta` 已包含 Android IME 和导入 / 导出实现,并在 v2.0.0-Beta.4 发布了多架构 APK。 + +### 已有导出 / 导入入口 + +[OpenLessSettingsExport.kt](../../openless-all/app/android/kotlin/OpenLessSettingsExport.kt) 定义了版本化 JSON(当前版本为 2),并提供六个可勾选类别: + +- `cloudNotes`:云笔记 URL / Token; +- `haptic`:震动开关、强度、时长; +- `strokeFrequency`:笔画输入频率; +- `pinyinLearnedPhrases`:简拼组合学习; +- `providerSelection`:ASR / LLM 当前渠道与两个风格包 ID; +- `credentials`:ASR / LLM 渠道凭据。 + +入口在 [OpenLessKeyboardSettingsActivity.kt:538-734](../../openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt#L538-L734): + +- 导出使用 `ACTION_CREATE_DOCUMENT` 写 JSON; +- 导入使用 `ACTION_OPEN_DOCUMENT` 读取 JSON; +- 导入前按文件实际包含的类别弹出复选框; +- 文件读取上限为 4 MiB; +- 导入后显示成功类别与逐项错误。 + +这版实现已经具备 schema/version、按类别选择、基础输入校验和分项错误报告,不能算完全没有设计。 + +### Provider / credential 迁移 + +[android/native_bridge.rs:61-187](../../openless-all/app/src-tauri/src/android/native_bridge.rs#L61-L187) 通过运行中的 Core backend 和 `CredentialsVault` 读写,而不是由 Kotlin 手工修改 `preferences.json`。导入会: + +- 校验版本 1 / 2; +- 校验 provider/channel 身份一致性; +- 将渠道合并到目标设备而不是删除其他渠道; +- 刷新 backend 的凭据视图; +- 只在目标设备存在对应风格包时切换风格包,否则保持原值并报告缺失。 + +[credentials/android_transfer.rs:170-201](../../openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs#L170-L201) 的导出只取 `root.active.asr` 与 `root.active.llm` 对应的渠道,因此不是所有已配置渠道的完整备份。当前 native bridge 也只处理 ASR、LLM,未覆盖 Omni 渠道。 + +Android 凭据在设备本地由 Android Keystore + AES-GCM 保护,见 [OpenLessCredentialVault.kt:114-169](../../openless-all/app/android/kotlin/OpenLessCredentialVault.kt#L114-L169) 和 [android_credentials.rs:11-18](../../openless-all/app/src-tauri/src/persistence/android_credentials.rs#L11-L18)。这解决的是“设备存储时”的保护,不等于导出文件安全。 + +## 与 Issue 验收标准的差异 + +### 1. 应用偏好覆盖不足 + +现有 JSON 只读写 `openless_ime_ui` 中的云笔记与震动字段,以及 provider 的几个选择字段。完整的 `UserPreferences` 还包含主题、语言、录音 / 自动停止、剪贴板、流式输入、Android 插入策略、悬浮窗触发模式、手势、尺寸等大量字段,见 [shared_types.rs:385-849](../../openless-all/app/crates/openless-core/src/shared_types.rs#L385-L849)。 + +Android 自身还把 `theme_mode`、`input_mode`、`latin_input_mode`、英文提示等放在 `openless_ime_ui`,例如 [OpenLessImeService.kt:464-503](../../openless-all/app/android/kotlin/OpenLessImeService.kt#L464-L503);这些字段不在 `OpenLessSettingsExport.Category` 的导出逻辑中。 + +**影响:** 当前功能能迁移“键盘设置页里明确列出来的少量选项”,不能满足“安装 APK 后导入即可恢复应用偏好”的目标。 + +### 2. Provider 的“脱敏分享”不完整 + +`export_provider_settings_json(false)` 会复制渠道身份后移除 `configuration`,见 [native_bridge.rs:67-79](../../openless-all/app/src-tauri/src/android/native_bridge.rs#L67-L79)。因此不携带 credentials 时,Base URL、模型、协议高级参数等非敏感配置也不会保留;携带 credentials 时则使用完整 `configuration`,其中包含裸 API Key。 + +此外,导出只取当前激活的 ASR / LLM 渠道,见 [android_transfer.rs:174-188](../../openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs#L174-L188),不满足“已配置渠道、启用状态、其他服务均可迁移”的完整范围。 + +### 3. 词典 / 词库覆盖不足 + +当前导出只包含 Android 键盘自己的 `StrokeUserFrequency` 与 `LitePinyinLearnedPhrases`。Core 的通用词典和词库预设是独立持久化资源: + +- `DictionaryStore` 使用 `dictionary.json`; +- 词库预设使用 `vocab-presets.json`; +- Android mobile command handler 已经注册 `list_vocab`、`add_vocab`、`list_vocab_presets` 等命令,见 [lib.rs:577-588](../../openless-all/app/src-tauri/src/lib.rs#L577-L588)。 + +因此实现上有现成入口可复用,但当前配置文件没有这些内容,也没有恢复当前启用状态。 + +### 4. 风格包只迁移引用,不迁移实体 + +当前 provider selection 只写 `activeStylePackId` 和 `selectionPolishStylePackId`。导入时若目标设备没有对应 ID,则保留旧 ID并报告,见 [native_bridge.rs:146-173](../../openless-all/app/src-tauri/src/android/native_bridge.rs#L146-L173)。这意味着自定义风格的 prompt、examples、元数据和图标不会随配置一起迁移。 + +Core 已有完整的 ZIP 能力:[persistence/style_pack.rs:112-134](../../openless-all/app/src-tauri/src/persistence/style_pack.rs#L112-L134) 暴露 style pack 的 ZIP 导入 / 导出;Android mobile handler 也已注册对应命令,见 [lib.rs:599-610](../../openless-all/app/src-tauri/src/lib.rs#L599-L610)。后续应把这些资源纳入同一个 bundle,而不是只复制 ID。 + +实现时需注意 Android SAF 的 `content://` 目标:当前 [commands/style_packs.rs:215-228](../../openless-all/app/src-tauri/src/commands/style_packs.rs#L215-L228) 的 ZIP 导出路径直接按文件路径处理,而导入已经专门支持 `content://`。完整 bundle 需要补充 content URI 写入或在 Kotlin 层统一写出。 + +### 5. 安全流程与恢复流程不足 + +导出器明确采用明文 JSON,见 [OpenLessSettingsExport.kt:15-19](../../openless-all/app/android/kotlin/OpenLessSettingsExport.kt#L15-L19);UI 只展示“文件包含明文 API Key,请妥善保管”的警告,见 [OpenLessKeyboardSettingsActivity.kt:538-555](../../openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt#L538-L555)。当前没有: + +- “个人换机”与“分享配置”两种明确模式; +- 密码加密 / 密钥派生; +- 默认脱敏后仍保留非敏感 provider 配置; +- 导入前快照、自动备份、回滚; +- 合并 / 替换策略选择; +- 写入前的实际变更摘要。 + +导入是按类别分开写入;非 provider 类别即使前一项成功、后一项失败,也不会进行全局回滚,见 [OpenLessSettingsExport.kt:91-140](../../openless-all/app/android/kotlin/OpenLessSettingsExport.kt#L91-L140)。provider 导入虽然使用 Core 的合并逻辑,但整体配置仍没有统一事务边界。 + +Issue #817 已明确项目对 Android 凭据的安全要求是 Keystore-backed protection、认证加密、迁移 / 篡改处理和测试;本地导出应遵循同一安全边界,不能因为文件通过系统分享就退回无保护的裸密钥。 + +### 6. 权限边界目前基本正确,但需要产品化提示 + +现有导出器没有导出麦克风、悬浮窗、无障碍、输入法启用等系统授权状态;这是正确的,权限不能被配置文件自动授予。完整导入流程仍应在成功后明确列出“尚需在本机授权”的权限,而不是只显示“Imported N categories”。 + +### 7. 测试覆盖 + +[android_transfer.rs:281-387](../../openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs#L281-L387) 已覆盖渠道身份绑定、合并时保留其他渠道、重复导入幂等性;style pack archive / store 也有较完整的 ZIP 安全和持久化测试。 + +但在当前代码树中没有找到 `OpenLessSettingsExport` 的直接 Kotlin test,也没有覆盖以下完整场景的往返测试: + +- 导出所有类别后在干净 Android 数据目录导入; +- 缺失 provider / style pack 的部分成功与回滚; +- 明文 / 脱敏 / 加密三种分享模式; +- SAF `content://` 输入和输出; +- 系统权限不随文件迁移; +- 旧 schema 与未来 schema 的兼容矩阵。 + +## 与现有 Cloud Sync 的关系 + +不能简单把用户导向 Cloud Sync:Issue 明确要求无需同一账号、无需云同步的本地文件。但 Cloud Sync 的 E2EE 文档层已经提供了值得复用的“可迁移数据分类”和验证模型: + +- [cloud_sync_e2ee_documents/export.rs:27-69](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs#L27-L69) 将偏好分类为 portable / device profile / excluded; +- [export.rs:71-153](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs#L71-L153) 已处理 provider、词典、词库预设、纠错规则和 style packs; +- [types.rs:136-189](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs#L136-L189) 明确区分渠道身份、provider credentials 与完整 style pack; +- [registry.rs:34-83](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs#L34-L83) 提供字段级 portable / device-bound / excluded 清单。 + +推荐抽取一个“本地 bundle source / sink”复用这些 canonical record、字段白名单、引用校验和安全错误模型,但不复用账号、服务器或云端同步状态。 + +## 建议的实现拆分 + +### P0:先定义稳定本地格式和安全模式 + +- 统一 ZIP bundle(manifest、schemaVersion、源版本、类别摘要、payload、style assets);JSON 只作为内部 payload,不作为默认分享格式。 +- 明确两种用途: + - **分享配置**:默认不含凭据,但保留 endpoint / model / protocol / advanced non-secret fields; + - **个人迁移**:凭据必须单独勾选,并使用用户输入的密码进行 AEAD 加密。 +- 统一限制:最大文件大小、最大条目数、压缩炸弹防护、路径穿越防护、未知字段策略、未来版本拒绝策略。 + +### P1:完整 portable snapshot + +- 用 Cloud Sync registry 的 portable / device-bound / excluded 分类为基础,显式定义 Android 可迁移白名单。 +- 导出 / 导入完整 `UserPreferences` 中适用于 Android 的字段,以及 `openless_ime_ui` 中的主题、语言、输入模式、键盘偏好和悬浮窗配置。 +- 导出所有 ASR / LLM / Omni 渠道的身份与非敏感配置;凭据按渠道和账号单独加密。 +- 复用 Core 的 DictionaryStore、vocab presets、correction rules 和 StylePackStore;自定义 style pack 必须携带实体而非只有 ID。 + +### P1:原子导入与用户可控恢复 + +- 导入前自动创建本地备份;显示来源、版本、类别、凭据状态和实际变更摘要。 +- 支持“合并”与“替换”;默认合并,禁止静默删除目标设备已有渠道 / 词条。 +- 用临时目录 / snapshot + 校验后一次性提交;任何类别失败时保留原配置并报告逐项原因。 +- 对缺失 provider、风格包、设备相关路径明确标记“跳过 / 需要选择”,不要把 ID 写成不可用状态。 +- 完成后提示重启 backend / App,并列出需要用户手动授予的系统权限。 + +### P2:测试与发布验证 + +- Kotlin:schema、类别筛选、脱敏、坏文件、版本兼容、大小限制。 +- Rust:完整 bundle 的 schema、引用完整性、凭据加密 / 解密、原子提交 / 回滚、style asset 安全。 +- Android instrumented:SAF `content://` 读写、Keystore 锁定 / 密钥失效、换机干净安装、旧版本升级。 +- 真机矩阵:arm64-v8a、armeabi-v7a、x86_64;覆盖无悬浮窗 / 无障碍 / 未启用 IME 的权限状态。 + +## 最终 triage 建议 + +- **不要把 Issue 直接标记为重复并关闭**:稳定版用户仍没有入口,且当前 beta 只完成了子集。 +- 可在 Issue 中补充说明:v2 beta 已提供“部分配置导出 / 导入”MVP,并列出上述缺口,避免重复实现已有 provider transfer。 +- 将 Issue 标题或描述收敛为“Android 本地完整迁移 bundle(含脱敏分享、可选加密凭据、词典与风格包)”,把当前 `OpenLessSettingsExport` 作为基础实现继续演进。 +- 第一阶段优先实现完整非敏感配置、词典 / 词库 / style pack 和备份回滚;凭据分享必须单独经过安全设计与 review。 + +本报告为代码与 GitHub 一手资料的静态调研,未运行 Android 真机验证或构建 APK。 diff --git a/openless-all/app/android/kotlin/OpenLessContentReader.kt b/openless-all/app/android/kotlin/OpenLessContentReader.kt index a24ac82d6..f1a59bd9b 100644 --- a/openless-all/app/android/kotlin/OpenLessContentReader.kt +++ b/openless-all/app/android/kotlin/OpenLessContentReader.kt @@ -8,7 +8,21 @@ import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream -/** Reads a bounded document selected through Android's Storage Access Framework. */ +@Keep +sealed class OpenLessReadResult { + data class Success(val bytes: ByteArray) : OpenLessReadResult() + data class Failure(val reason: Reason) : OpenLessReadResult() + + enum class Reason { + INVALID_URI, + ACCESS_DENIED, + TOO_LARGE, + READ_FAILED, + CANCELLED, + } +} + +/** Reads a bounded document and preserves failure classes for migration UI. */ @Keep object OpenLessContentReader { private const val TAG = "OpenLessContentReader" @@ -16,22 +30,42 @@ object OpenLessContentReader { @Keep @JvmStatic - fun readBytes(context: Context, uriString: String, maxBytes: Int): ByteArray? { + fun readStrict(context: Context, uriString: String, maxBytes: Int): OpenLessReadResult { + if (maxBytes < 0) return OpenLessReadResult.Failure(OpenLessReadResult.Reason.TOO_LARGE) + val uri = runCatching { Uri.parse(uriString) }.getOrNull() + ?: return OpenLessReadResult.Failure(OpenLessReadResult.Reason.INVALID_URI) + if (uri.scheme != "content") { + return OpenLessReadResult.Failure(OpenLessReadResult.Reason.INVALID_URI) + } return try { - val uri = Uri.parse(uriString) - context.contentResolver.openInputStream(uri)?.use { input -> - readBounded(input, maxBytes) + val bytes = context.contentResolver.openInputStream(uri)?.use { input -> readBounded(input, maxBytes) } + ?: return OpenLessReadResult.Failure(OpenLessReadResult.Reason.READ_FAILED) + OpenLessReadResult.Success(bytes) + } catch (_: SecurityException) { + OpenLessReadResult.Failure(OpenLessReadResult.Reason.ACCESS_DENIED) + } catch (_: java.util.concurrent.CancellationException) { + OpenLessReadResult.Failure(OpenLessReadResult.Reason.CANCELLED) + } catch (error: IOException) { + if (error.message?.contains("exceeds", ignoreCase = true) == true) { + OpenLessReadResult.Failure(OpenLessReadResult.Reason.TOO_LARGE) + } else { + Log.e(TAG, "failed to read selected document", error) + OpenLessReadResult.Failure(OpenLessReadResult.Reason.READ_FAILED) } - ?: run { - Log.w(TAG, "openInputStream returned null for selected document") - null - } } catch (error: Throwable) { Log.e(TAG, "failed to read selected document", error) - null + OpenLessReadResult.Failure(OpenLessReadResult.Reason.READ_FAILED) } } + @Keep + @JvmStatic + fun readBytes(context: Context, uriString: String, maxBytes: Int): ByteArray? = + when (val result = readStrict(context, uriString, maxBytes)) { + is OpenLessReadResult.Success -> result.bytes + is OpenLessReadResult.Failure -> null + } + internal fun readBounded(input: InputStream, maxBytes: Int): ByteArray { require(maxBytes >= 0) { "maxBytes must not be negative" } val output = ByteArrayOutputStream(minOf(maxBytes, BUFFER_BYTES)) diff --git a/openless-all/app/android/kotlin/OpenLessContentWriter.kt b/openless-all/app/android/kotlin/OpenLessContentWriter.kt index 9361ed416..1e379a2c1 100644 --- a/openless-all/app/android/kotlin/OpenLessContentWriter.kt +++ b/openless-all/app/android/kotlin/OpenLessContentWriter.kt @@ -11,37 +11,67 @@ import androidx.annotation.Keep import java.io.File import java.io.IOException -/** - * Writes bytes to a SAF content:// URI via ContentResolver. - * - * Prefer this over tauri-plugin-fs for exports: fs detaches the FD early and some providers - * finalize a 0-byte file before Rust finishes writing. - */ +@Keep +sealed class OpenLessWriteResult { + data object Success : OpenLessWriteResult() + data class Failure(val reason: Reason) : OpenLessWriteResult() + + enum class Reason { + INVALID_URI, + ACCESS_DENIED, + SHORT_WRITE, + FLUSH_FAILED, + WRITE_FAILED, + CANCELLED, + } +} + +/** Writes bounded bytes to a user-selected SAF document. */ @Keep object OpenLessContentWriter { private const val TAG = "OpenLessContentWriter" @Keep @JvmStatic - fun writeBytes(context: Context, uriString: String, bytes: ByteArray): Boolean { + fun writeStrict(context: Context, uriString: String, bytes: ByteArray): OpenLessWriteResult { + val uri = runCatching { Uri.parse(uriString) }.getOrNull() + ?: return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.INVALID_URI) + if (uri.scheme != "content") { + return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.INVALID_URI) + } return try { - val uri = Uri.parse(uriString) - context.contentResolver.openOutputStream(uri)?.use { output -> - output.write(bytes) - output.flush() - } - ?: run { - Log.w(TAG, "openOutputStream returned null for selected document") - return false + val output = context.contentResolver.openOutputStream(uri) + ?: return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.WRITE_FAILED) + var written = 0 + output.use { stream -> + stream.write(bytes) + written = bytes.size + try { + stream.flush() + } catch (_: Throwable) { + return OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.FLUSH_FAILED) } - Log.i(TAG, "wrote ${bytes.size} bytes to selected document") - true + } + if (written != bytes.size) { + OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.SHORT_WRITE) + } else { + OpenLessWriteResult.Success + } + } catch (_: SecurityException) { + OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.ACCESS_DENIED) + } catch (_: java.util.concurrent.CancellationException) { + OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.CANCELLED) } catch (error: Throwable) { Log.e(TAG, "failed to write selected document", error) - false + OpenLessWriteResult.Failure(OpenLessWriteResult.Reason.WRITE_FAILED) } } + @Keep + @JvmStatic + fun writeBytes(context: Context, uriString: String, bytes: ByteArray): Boolean = + writeStrict(context, uriString, bytes) is OpenLessWriteResult.Success + /** * Writes an app-generated export into Downloads without a SAF picker. * @@ -103,4 +133,4 @@ object OpenLessContentWriter { Log.i(TAG, "wrote ${bytes.size} bytes to app Downloads (pre-Q): $appFile") return appFile.absolutePath } -} \ No newline at end of file +} diff --git a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt index 8d0a565c5..aa1c373bb 100644 --- a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt @@ -32,16 +32,21 @@ import androidx.core.view.WindowInsetsCompat class OpenLessKeyboardSettingsActivity : Activity() { private val prefs by lazy { getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE) } - // Export/import (see OpenLessSettingsExport): plain Activity, so this - // uses the classic startActivityForResult()/onActivityResult() pair - // rather than androidx.activity's ActivityResultContracts, which needs - // ComponentActivity. pendingExportJson bridges the gap between building - // the export JSON (at the moment the user picks categories) and - // actually having somewhere to write it (only known once - // ACTION_CREATE_DOCUMENT's picker returns a Uri, an async round trip). - private var pendingExportJson: String? = null + // The new migration flow captures only after the user has selected a SAF + // destination. Keep only small UI state across that picker round trip; the + // bundle itself never lives in an Activity field. + private var pendingExportMode: String? = null + private var pendingExportIncludeCredentials = false + private var pendingExportPassword: String? = null private val requestCodeExportSave = 4201 private val requestCodeImportOpen = 4202 + private val requestCodeLegacyImportOpen = 4203 + private val maxMigrationBundleBytes = OpenLessMigrationBundlePolicy.MAX_BUNDLE_BYTES + private data class PendingExportState( + val mode: String, + val includeCredentials: Boolean, + val password: String?, + ) private val englishUi by lazy { val locale = prefs.getString("locale", null) ?: resources.configuration.locales[0].toLanguageTag() !locale.startsWith("zh", ignoreCase = true) @@ -62,6 +67,18 @@ class OpenLessKeyboardSettingsActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val retained = lastNonConfigurationInstance as? PendingExportState + if (retained != null) { + pendingExportMode = retained.mode + pendingExportIncludeCredentials = retained.includeCredentials + pendingExportPassword = retained.password + } else if (savedInstanceState != null) { + pendingExportMode = savedInstanceState.getString("migrationExportMode") + pendingExportIncludeCredentials = savedInstanceState.getBoolean("migrationExportIncludeCredentials", false) + // Passwords are deliberately not persisted in saved state. If the + // process was killed while the picker was open, ask again on return. + pendingExportPassword = null + } // Explicit rather than relying on targetSdk 36's implicit // edge-to-edge enforcement — guarantees the IME-inset listener in // buildContent() actually fires so the "云笔记提交" URL/Token fields @@ -76,6 +93,18 @@ class OpenLessKeyboardSettingsActivity : Activity() { WindowCompat.getInsetsController(window, window.decorView) } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString("migrationExportMode", pendingExportMode) + outState.putBoolean("migrationExportIncludeCredentials", pendingExportIncludeCredentials) + } + + @Suppress("DEPRECATION") + override fun onRetainCustomNonConfigurationInstance(): Any? = + pendingExportMode?.let { + PendingExportState(it, pendingExportIncludeCredentials, pendingExportPassword) + } + private fun buildContent(): View { val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -520,56 +549,69 @@ class OpenLessKeyboardSettingsActivity : Activity() { ), ) - // "导出/导入配置": bundles everything OpenLessSettingsExport knows how - // to read (see that object's own Category enum) into one JSON file, - // or restores one — for moving personal settings to another device - // without retyping everything. Plain JSON, no encryption, by - // explicit product decision — the warning text below is the only - // protection against an accidentally-shared file containing a - // Cloud notes token or (if that category is checked) a raw ASR/LLM - // API key. - content.addView(sectionLabel(ui("导出 / 导入配置", "Export / Import settings"))) + content.addView(sectionLabel(ui("本地迁移 / 分享", "Local migration / sharing"))) content.addView( TextView(this).apply { text = ui( - "把云笔记地址、震动参数、笔画调频、简拼优选、ASR/LLM/风格包的选择(以及可选的 API Key)打包成一个文件,方便迁移到另一台设备。文件是明文 JSON——如果勾选了 API Key,文件里会有裸的密钥,请妥善保管。", - "Bundles the Cloud notes address, haptic settings, stroke ranking, pinyin combo learning, and ASR/LLM/style pack selection (plus optional API keys) into one file for moving to another device. The file is plain JSON — including API keys leaves them in the file in plaintext, so keep it safe.", + "默认分享模式会脱敏并排除所有凭据。个人迁移如需携带凭据,必须使用密码保护;系统权限、OAuth 会话、设备路径和运行时状态不会迁移。导入前会显示文件摘要,并自动创建本地备份。", + "Share mode is redacted and excludes all credentials. Personal migration credentials require a password. System permissions, OAuth sessions, device paths, and runtime state never migrate. Import shows a summary first and creates a local backup before applying.", ) textSize = 12f setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) setPadding(0, 0, 0, dp(10)) }, ) - val exportImportRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } - exportImportRow.addView( + val migrationRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } + migrationRow.addView( TextView(this).apply { - text = ui("导出配置 →", "Export settings →") + text = ui("导出迁移文件 →", "Export migration bundle →") textSize = 15f setTypeface(typeface, android.graphics.Typeface.BOLD) setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) isClickable = true - setOnClickListener { showExportCategoryDialog() } + setOnClickListener { showMigrationExportDialog() } }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), ) - exportImportRow.addView( + migrationRow.addView( TextView(this).apply { - text = ui("导入配置 →", "Import settings →") + text = ui("导入迁移文件 →", "Import migration bundle →") textSize = 15f setTypeface(typeface, android.graphics.Typeface.BOLD) setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) isClickable = true - setOnClickListener { launchImportPicker() } + setOnClickListener { launchMigrationImportPicker() } }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), ) content.addView( - exportImportRow, + migrationRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = dp(16) }, ) + content.addView( + TextView(this).apply { + text = ui("导入旧版 JSON 配置 →", "Import legacy JSON settings →") + textSize = 13f + setTextColor(tone(Color.rgb(170, 170, 170), Color.rgb(90, 90, 95))) + isClickable = true + setOnClickListener { launchLegacyImportPicker() } + setPadding(0, 0, 0, dp(16)) + }, + ) + + // Legacy v1/v2 JSON import remains available only through the old + // compatibility object and is intentionally not used by new export. + content.addView( + TextView(this).apply { + text = ui("旧版 JSON 导入仍可由兼容层处理;新导出始终使用带版本和校验的 .omb 文件。", "Legacy JSON import remains available to the compatibility layer; new exports always use versioned, digested .omb bundles.") + textSize = 11f + setTextColor(tone(Color.rgb(120, 120, 120), Color.rgb(150, 150, 155))) + }, + ) + // build_first_seen_wall_time is written by OpenLessApplication's // resetRestartStatsOnVersionBump() at the exact moment it last // zeroed the restart-cause counters below — i.e. "counting since @@ -597,81 +639,147 @@ class OpenLessKeyboardSettingsActivity : Activity() { return root } - private fun showExportCategoryDialog() { - val categories = OpenLessSettingsExport.Category.entries.toTypedArray() - val labels = categories.map { ui(it.labelZh, it.labelEn) }.toTypedArray() - val checked = BooleanArray(categories.size) { true } + private fun showMigrationExportDialog() { + val modes = arrayOf( + ui("分享(脱敏,不带凭据)", "Share (redacted, no credentials)"), + ui("个人迁移(可选密码保护凭据)", "Personal migration (optional password-protected credentials)"), + ) android.app.AlertDialog.Builder(this) - .setTitle(ui("选择要导出的内容", "Choose what to export")) - .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } - .setPositiveButton(ui("导出", "Export")) { _, _ -> - val selected = categories.filterIndexed { index, _ -> checked[index] }.toSet() - if (selected.isEmpty()) { - Toast.makeText(this, ui("没有选择任何内容", "Nothing selected"), Toast.LENGTH_SHORT).show() - return@setPositiveButton - } - Thread { - val exported = runCatching { OpenLessSettingsExport.export(this, selected) } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - exported.onSuccess { json -> - pendingExportJson = json - val timestamp = android.text.format.DateFormat.format("yyyyMMdd-HHmm", System.currentTimeMillis()) - val intent = android.content.Intent(android.content.Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(android.content.Intent.CATEGORY_OPENABLE) - type = "application/json" - putExtra(android.content.Intent.EXTRA_TITLE, "openless-settings-$timestamp.json") - } - runCatching { startActivityForResult(intent, requestCodeExportSave) }.onFailure { - pendingExportJson = null - Toast.makeText(this, ui("找不到文件管理器", "No file manager available"), Toast.LENGTH_LONG).show() - } - }.onFailure { - Toast.makeText(this, ui("导出失败,请检查服务和凭据存储", "Export failed; check the service and credential store"), Toast.LENGTH_LONG).show() - } + .setTitle(ui("选择导出模式", "Choose export mode")) + .setItems(modes) { _, which -> + if (which == 0) { + launchMigrationExportPicker("share_redacted", false, null) + } else { + showMigrationPasswordDialog { password -> + launchMigrationExportPicker("personal_migration", true, password) } - }.start() + } + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun showMigrationPasswordDialog(onConfirmed: (String) -> Unit) { + val field = EditText(this).apply { + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + hint = ui("至少输入一个密码字符", "Enter a password") + } + val container = FrameLayout(this).apply { + setPadding(dp(24), 0, dp(24), 0) + addView(field, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + android.app.AlertDialog.Builder(this) + .setTitle(ui("保护迁移凭据", "Protect migration credentials")) + .setMessage(ui("密码不会写入文件名、摘要或日志。请在目标设备导入时输入相同密码。", "The password is never written to the filename, summary, or logs. Enter the same password on the destination device.")) + .setView(container) + .setPositiveButton(ui("继续选择文件", "Choose destination")) { _, _ -> + val password = field.text.toString() + if (password.isBlank()) { + Toast.makeText(this, ui("密码不能为空", "Password cannot be empty"), Toast.LENGTH_SHORT).show() + } else { + onConfirmed(password) + } } .setNegativeButton(ui("取消", "Cancel"), null) .show() } - private fun launchImportPicker() { + private fun launchMigrationExportPicker(mode: String, includeCredentials: Boolean, password: String?) { + if (!OpenLessMigrationBundlePolicy.validateExport(mode, includeCredentials, password)) { + Toast.makeText(this, ui("导出选项无效", "Invalid export options"), Toast.LENGTH_SHORT).show() + return + } + pendingExportMode = mode + pendingExportIncludeCredentials = includeCredentials + pendingExportPassword = password + val timestamp = android.text.format.DateFormat.format("yyyyMMdd-HHmm", System.currentTimeMillis()) + val intent = android.content.Intent(android.content.Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(android.content.Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + putExtra(android.content.Intent.EXTRA_TITLE, "openless-migration-$timestamp.omb") + } + runCatching { startActivityForResult(intent, requestCodeExportSave) }.onFailure { + clearPendingExport() + Toast.makeText(this, ui("找不到文件管理器", "No file manager available"), Toast.LENGTH_LONG).show() + } + } + + private fun clearPendingExport() { + pendingExportMode = null + pendingExportIncludeCredentials = false + pendingExportPassword = null + } + + private fun launchMigrationImportPicker() { val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(android.content.Intent.CATEGORY_OPENABLE) - type = "application/json" + type = "application/octet-stream" } runCatching { startActivityForResult(intent, requestCodeImportOpen) } .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } } + private fun launchLegacyImportPicker() { + val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(android.content.Intent.CATEGORY_OPENABLE) + type = "application/json" + } + runCatching { startActivityForResult(intent, requestCodeLegacyImportOpen) } + .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { super.onActivityResult(requestCode, resultCode, data) - if (resultCode != RESULT_OK || data == null) return + if (resultCode != RESULT_OK || data == null) { + if (requestCode == requestCodeExportSave) clearPendingExport() + return + } when (requestCode) { requestCodeExportSave -> { val uri = data.data - val json = pendingExportJson - pendingExportJson = null - if (uri == null || json == null) return - val wrote = runCatching { - checkNotNull(contentResolver.openOutputStream(uri)).use { it.write(json.toByteArray(Charsets.UTF_8)) } - }.isSuccess - Toast.makeText( - this, - if (wrote) ui("已导出", "Exported") else ui("导出失败", "Export failed"), - Toast.LENGTH_SHORT, - ).show() + val mode = pendingExportMode + val includeCredentials = pendingExportIncludeCredentials + val password = pendingExportPassword.orEmpty() + clearPendingExport() + if (uri == null || mode == null) return + if (includeCredentials && password.isEmpty()) { + showMigrationPasswordDialog { restoredPassword -> performMigrationExport(uri, mode, true, restoredPassword) } + } else { + performMigrationExport(uri, mode, includeCredentials, password) + } } requestCodeImportOpen -> { val uri = data.data ?: return Thread { - val json = OpenLessContentReader.readBytes(this, uri.toString(), 4 * 1024 * 1024)?.toString(Charsets.UTF_8) + val result = runCatching { + val bytes = OpenLessContentReader.readBytes(this, uri.toString(), maxMigrationBundleBytes) + ?: error("read selected document failed") + val bundleBase64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + val envelope = org.json.JSONObject(OpenLessNative.nativeInspectLocalMigrationBundle(bundleBase64)) + check(envelope.optBoolean("ok")) { envelope.optString("error", "invalid migration bundle") } + bundleBase64 to envelope.getJSONObject("payload") + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.onSuccess { (bundleBase64, inspection) -> showMigrationImportSummary(bundleBase64, inspection) } + .onFailure { error -> + Toast.makeText(this, ui("读取或校验失败:${error.message}", "Read or validation failed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + requestCodeLegacyImportOpen -> { + val uri = data.data ?: return + Thread { + val json = OpenLessContentReader.readBytes(this, uri.toString(), 4 * 1024 * 1024) + ?.toString(Charsets.UTF_8) runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread if (json.isNullOrBlank()) { - Toast.makeText(this, ui("读取文件失败或文件超过 4 MiB", "Read failed or file exceeds 4 MiB"), Toast.LENGTH_SHORT).show() - } else showImportCategoryDialog(json) + Toast.makeText(this, ui("读取旧版配置失败或文件超过 4 MiB", "Read failed or legacy file exceeds 4 MiB"), Toast.LENGTH_SHORT).show() + } else { + showImportCategoryDialog(json) + } } }.start() } @@ -701,15 +809,20 @@ class OpenLessKeyboardSettingsActivity : Activity() { runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread val result = imported.getOrNull() - val message = if (result == null) ui("配置文件无效,导入失败", "Invalid settings file; import failed") - else buildString { - append(ui("已导入 ${result.applied.size} 项", "Imported ${result.applied.size} categories")) - for ((category, reason) in result.errors) { - append('\n').append(ui(category.labelZh, category.labelEn)).append(": ").append(reason) + val message = if (result == null) { + ui("配置文件无效,导入失败", "Invalid settings file; import failed") + } else { + buildString { + append(ui("已导入 ${result.applied.size} 项", "Imported ${result.applied.size} categories")) + for ((category, reason) in result.errors) { + append('\n').append(ui(category.labelZh, category.labelEn)).append(": ").append(reason) + } } } - android.app.AlertDialog.Builder(this).setMessage(message) - .setPositiveButton(android.R.string.ok, null).show() + android.app.AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() setContentView(buildContent()) } }.start() @@ -718,6 +831,225 @@ class OpenLessKeyboardSettingsActivity : Activity() { .show() } + private fun performMigrationExport( + uri: android.net.Uri, + mode: String, + includeCredentials: Boolean, + password: String, + ) { + Thread { + val result = runCatching { + val envelope = org.json.JSONObject( + OpenLessNative.nativeExportLocalMigrationBundle( + mode, + includeCredentials, + password, + OpenLessSettingsExport.exportPortable(this), + ), + ) + check(envelope.optBoolean("ok")) { envelope.optString("error", "export failed") } + val payload = envelope.getJSONObject("payload") + val bytes = android.util.Base64.decode(payload.getString("bundleBase64"), android.util.Base64.DEFAULT) + check(bytes.size <= maxMigrationBundleBytes) { "bundle exceeds size limit" } + check(OpenLessContentWriter.writeBytes(this, uri.toString(), bytes)) { "write selected document failed" } + payload.getJSONObject("inspection") + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.onSuccess { inspection -> + Toast.makeText(this, ui("已导出迁移文件(${inspection.optInt("documentCount")} 项)", "Migration bundle exported (${inspection.optInt("documentCount")} documents)"), Toast.LENGTH_LONG).show() + }.onFailure { error -> + Toast.makeText(this, ui("导出失败:${error.message}", "Export failed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + + private fun migrationInspectionText(inspection: org.json.JSONObject): String = buildString { + append(ui("格式:", "Format: ")).append(inspection.optString("format")).append('\n') + append(ui("版本:", "Version: ")).append(inspection.optInt("formatVersion")).append('\n') + append(ui("文档:", "Documents: ")).append(inspection.optInt("documentCount")).append('\n') + append(ui("渠道:", "Channels: ")).append(inspection.optJSONObject("sectionCounts")?.optInt("channels") ?: 0).append('\n') + append(ui("词典 / 纠错 / 风格包:", "Dictionary / corrections / style packs: ")) + .append(inspection.optJSONObject("sectionCounts")?.optInt("dictionary") ?: 0).append(" / ") + .append(inspection.optJSONObject("sectionCounts")?.optInt("corrections") ?: 0).append(" / ") + .append(inspection.optJSONObject("sectionCounts")?.optInt("stylePacks") ?: 0).append('\n') + append(ui("凭据:", "Credentials: ")).append( + if (inspection.optBoolean("credentialsEncrypted")) ui("已加密", "password protected") else ui("未携带", "not included"), + ).append('\n') + append(ui("系统权限不会迁移,目标设备需重新授权。", "System permissions do not migrate; authorize them again on the destination device.")) + } + + private fun showMigrationImportSummary(bundleBase64: String, inspection: org.json.JSONObject) { + android.app.AlertDialog.Builder(this) + .setTitle(ui("确认迁移文件", "Review migration bundle")) + .setMessage(migrationInspectionText(inspection)) + .setPositiveButton(ui("选择合并 / 替换", "Choose merge / replace")) { _, _ -> + val encrypted = inspection.optBoolean("credentialsEncrypted") + if (encrypted) { + showMigrationPasswordDialog { password -> showMigrationRestoreMode(bundleBase64, password) } + } else { + showMigrationRestoreMode(bundleBase64, "") + } + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun showMigrationRestoreMode(bundleBase64: String, password: String) { + Thread { + val previewResult = runCatching { + val response = org.json.JSONObject( + OpenLessNative.nativePreviewLocalMigrationBundle( + bundleBase64, + password, + OpenLessSettingsExport.exportPortable(this@OpenLessKeyboardSettingsActivity), + ), + ) + check(response.optBoolean("ok")) { response.optString("error", "preview failed") } + response.getJSONObject("payload") + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + previewResult.onSuccess { preview -> + val conflicts = preview.getJSONArray("conflicts") + val expectedGeneration = preview.optString("observedGeneration") + chooseMigrationConflicts(bundleBase64, password, conflicts, 0, org.json.JSONArray()) { + showMigrationRestoreModeAfterConflicts(bundleBase64, password, expectedGeneration, conflicts, it) + } + }.onFailure { error -> + Toast.makeText(this, ui("预览失败:${error.message}", "Preview failed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + + private fun chooseMigrationConflicts( + bundleBase64: String, + password: String, + conflicts: org.json.JSONArray, + index: Int, + choices: org.json.JSONArray, + onComplete: (org.json.JSONArray) -> Unit, + ) { + if (index >= conflicts.length()) { + onComplete(choices) + return + } + val conflict = conflicts.getJSONObject(index) + val kind = conflict.optString("kind") + val reason = conflict.optString("reason") + val credential = if (conflict.optBoolean("isCredentialUnit")) ui("(凭据关联渠道)", " (credential-linked channel)") else "" + var selected: String? = null + val dialog = android.app.AlertDialog.Builder(this) + .setTitle(ui("选择冲突 ${index + 1}/${conflicts.length()}", "Choose conflict ${index + 1}/${conflicts.length()}")) + .setMessage("$kind / $reason$credential") + .setSingleChoiceItems(arrayOf(ui("保留当前设备", "Keep this device"), ui("使用迁移文件", "Use migration file")), -1) { _, which -> + selected = if (which == 0) "local" else "remote" + } + .setPositiveButton(ui("继续", "Continue"), null) + .setNegativeButton(ui("取消", "Cancel"), null) + .create() + dialog.setOnShowListener { + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val side = selected + if (side == null) { + Toast.makeText(this, ui("必须选择一侧", "Choose one side"), Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + choices.put(org.json.JSONObject().put("conflictId", conflict.getString("conflictId")).put("side", side)) + dialog.dismiss() + chooseMigrationConflicts(bundleBase64, password, conflicts, index + 1, choices, onComplete) + } + } + dialog.show() + } + + private fun portableConflictSide( + conflicts: org.json.JSONArray, + choices: org.json.JSONArray, + ): String? { + for (i in 0 until conflicts.length()) { + val conflict = conflicts.getJSONObject(i) + if (conflict.optString("documentId") != "androidPortableSettings") continue + val conflictId = conflict.optString("conflictId") + for (j in 0 until choices.length()) { + val choice = choices.getJSONObject(j) + if (choice.optString("conflictId") == conflictId) { + return choice.optString("side") + } + } + } + return null + } + + private fun showMigrationRestoreModeAfterConflicts( + bundleBase64: String, + password: String, + expectedGeneration: String, + conflicts: org.json.JSONArray, + choices: org.json.JSONArray, + ) { + val modes = arrayOf(ui("合并", "Merge"), ui("替换", "Replace")) + android.app.AlertDialog.Builder(this) + .setTitle(ui("导入方式", "Restore mode")) + .setItems(modes) { _, which -> + val mode = if (which == 0) "merge" else "replace" + Thread { + val applyPortable = mode == "replace" || portableConflictSide(conflicts, choices) != "local" + val mergePortableLearning = mode == "merge" && applyPortable + val result = runCatching { + val response = org.json.JSONObject( + OpenLessNative.nativeImportLocalMigrationBundle( + bundleBase64, + password, + mode, + choices.toString(), + expectedGeneration, + OpenLessSettingsExport.exportPortable(this@OpenLessKeyboardSettingsActivity), + ), + ) + check(response.optBoolean("ok")) { response.optString("error", "import failed") } + val payload = response.getJSONObject("payload") + val portableResponse = org.json.JSONObject( + OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), + ) + check(portableResponse.optBoolean("ok")) { portableResponse.optString("error", "portable settings read failed") } + val portable = portableResponse.getJSONObject("payload").opt("preferences") + if (portable !is org.json.JSONObject && portable != org.json.JSONObject.NULL) { + error("invalid portable Android settings") + } + Triple( + payload, + (portable as? org.json.JSONObject)?.toString(), + applyPortable to mergePortableLearning, + ) + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.onSuccess { (_, portableJson, portableOptions) -> + runCatching { + if (portableOptions.first) { + portableJson?.let { + OpenLessSettingsExport.importPortable(this, it, portableOptions.second) + } + } + }.onSuccess { + Toast.makeText(this, ui("导入完成,备份已保留", "Import complete; backup retained"), Toast.LENGTH_LONG).show() + setContentView(buildContent()) + }.onFailure { error -> + Toast.makeText(this, ui("核心数据已导入,但 Android 偏好未应用:${error.message}", "Core data imported, but Android preferences were not applied: ${error.message}"), Toast.LENGTH_LONG).show() + } + }.onFailure { error -> + Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + /** Fires a one-shot vibration with the sliders' current (already-saved) values, so a change is felt immediately. */ private fun fireTestVibration(amplitude: Int, durationMs: Int) { runCatching { diff --git a/openless-all/app/android/kotlin/OpenLessMigrationBundlePolicy.kt b/openless-all/app/android/kotlin/OpenLessMigrationBundlePolicy.kt new file mode 100644 index 000000000..547ae430f --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessMigrationBundlePolicy.kt @@ -0,0 +1,35 @@ +package com.openless.app + +/** Pure Android-side guardrails mirrored by the Rust bundle policy. */ +object OpenLessMigrationBundlePolicy { + const val FORMAT = "openless.android.migration-bundle" + const val MAX_BUNDLE_BYTES = 32 * 1024 * 1024 + + fun validateExport(mode: String, includeCredentials: Boolean, password: String?): Boolean { + if (mode != "share_redacted" && mode != "personal_migration") return false + if (mode == "share_redacted" && includeCredentials) return false + if (!includeCredentials) return password.isNullOrEmpty() + return mode == "personal_migration" && !password.isNullOrEmpty() + } + + fun credentialSummary(credentialsEncrypted: Boolean): String = + if (credentialsEncrypted) "password_protected" else "not_included" + + fun canonicalLocale(value: String): String { + val normalized = value.trim() + if (normalized.equals("system", ignoreCase = true)) return "system" + val locale = java.util.Locale.forLanguageTag(normalized.replace('_', '-')) + return when (locale.language.lowercase(java.util.Locale.ROOT)) { + "zh" -> if ( + locale.country.uppercase(java.util.Locale.ROOT) in setOf("TW", "HK", "MO") || + locale.script.equals("Hant", ignoreCase = true) + ) { + "zh-TW" + } else { + "zh-CN" + } + "en", "ja", "ko", "es", "fr", "de" -> locale.language.lowercase(java.util.Locale.ROOT) + else -> "system" + } + } +} diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index 26f672f12..bee22536f 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -130,4 +130,29 @@ object OpenLessNative { /** Explicit, user-selected plaintext transfer; result envelopes carry failures. */ @JvmStatic external fun nativeExportProviderSettings(includeCredentials: Boolean): String @JvmStatic external fun nativeImportProviderSettings(json: String, selection: Boolean, credentials: Boolean): String + + /** Versioned, bounded local migration bundle facade. Payloads are base64 only at the JNI boundary. */ + @JvmStatic external fun nativeExportLocalMigrationBundle( + mode: String, + includeCredentials: Boolean, + password: String, + androidPreferencesJson: String, + ): String + + @JvmStatic external fun nativeInspectLocalMigrationBundle(bundleBase64: String): String + @JvmStatic external fun nativeExtractLocalMigrationAndroidPreferences(bundleBase64: String, password: String): String + @JvmStatic external fun nativePreviewLocalMigrationBundle( + bundleBase64: String, + password: String, + androidPreferencesJson: String, + ): String + + @JvmStatic external fun nativeImportLocalMigrationBundle( + bundleBase64: String, + password: String, + mode: String, + choicesJson: String, + expectedGeneration: String, + androidPreferencesJson: String, + ): String } diff --git a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt index a959dd6ac..a2f352162 100644 --- a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt +++ b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt @@ -21,6 +21,7 @@ import org.json.JSONObject internal object OpenLessSettingsExport { const val VERSION = 2 private const val PREFS_STORE = "openless_ime_ui" + private const val MAX_LEARNING_ENTRIES = 20_000 /** One category = one row in the export/import checkbox dialog. Order here is the order shown. */ enum class Category(val key: String, val labelZh: String, val labelEn: String) { @@ -79,6 +80,92 @@ internal object OpenLessSettingsExport { return root.toString(2) } + /** Portable Android-only state that is not represented by Core repositories. */ + fun exportPortable(context: Context): String { + val app = context.applicationContext + check(ImeLearningPolicy.migrate(app)) { "Learning data cleanup failed" } + val prefs = app.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE) + return JSONObject().apply { + put("schemaVersion", 1) + put("learningPrivacyVersion", ImeLearningPolicy.PRIVACY_VERSION) + put("preferences", JSONObject().apply { + prefs.getString("locale", null)?.let { put("locale", OpenLessMigrationBundlePolicy.canonicalLocale(it)) } + prefs.getString("theme_mode", null)?.let { put("themeMode", it) } + put("hapticEnabled", prefs.getBoolean("key_haptic_enabled", true)) + put("hapticAmplitude", prefs.getInt("key_haptic_amplitude", 55)) + put("hapticDurationMs", prefs.getLong("key_haptic_duration_ms", 12L)) + put("englishSuggestionsEnabled", prefs.getBoolean("english_suggestions_enabled", true)) + put("debugFeaturesEnabled", prefs.getBoolean("key_debug_features_enabled", false)) + }) + put("strokeFrequency", JSONObject(StrokeUserFrequency(app).exportAll())) + put("pinyinLearnedPhrases", JSONObject(LitePinyinLearnedPhrases(app).exportAll())) + }.toString() + } + + fun validatePortable(json: String): Boolean = runCatching { parsePortable(json) }.isSuccess + + fun importPortable(context: Context, json: String, mergeLearning: Boolean = false) { + val app = context.applicationContext + val root = parsePortable(json) + require(root.optInt("learningPrivacyVersion", 0) >= ImeLearningPolicy.PRIVACY_VERSION) { + "Portable learning data is outdated" + } + val prefs = app.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE) + val values = root.getJSONObject("preferences") + val editor = prefs.edit() + if (values.has("locale")) editor.putString("locale", values.getString("locale")) + if (values.has("themeMode")) editor.putString("theme_mode", values.getString("themeMode")) + if (values.has("hapticEnabled")) editor.putBoolean("key_haptic_enabled", values.getBoolean("hapticEnabled")) + if (values.has("hapticAmplitude")) editor.putInt("key_haptic_amplitude", values.getInt("hapticAmplitude")) + if (values.has("hapticDurationMs")) editor.putLong("key_haptic_duration_ms", values.getLong("hapticDurationMs")) + if (values.has("englishSuggestionsEnabled")) editor.putBoolean("english_suggestions_enabled", values.getBoolean("englishSuggestionsEnabled")) + if (values.has("debugFeaturesEnabled")) editor.putBoolean("key_debug_features_enabled", values.getBoolean("debugFeaturesEnabled")) + check(editor.commit()) { "Portable settings save failed" } + check(ImeLearningPolicy.migrate(app)) { "Learning data cleanup failed" } + val stroke = root.getJSONObject("strokeFrequency").toStringMap() + val pinyin = root.getJSONObject("pinyinLearnedPhrases").toStringMap() + if (mergeLearning) { + StrokeUserFrequency(app).importAll(mergeLearningEntries(StrokeUserFrequency(app).exportAll(), stroke)) + LitePinyinLearnedPhrases(app).importAll(mergeLearningEntries(LitePinyinLearnedPhrases(app).exportAll(), pinyin)) + } else { + StrokeUserFrequency(app).importAll(stroke) + LitePinyinLearnedPhrases(app).importAll(pinyin) + } + } + + private fun mergeLearningEntries( + current: Map, + incoming: Map, + ): Map { + val merged = LinkedHashMap(MAX_LEARNING_ENTRIES) + incoming.forEach { (key, value) -> merged[key] = value } + current.forEach { (key, value) -> + if (merged.size < MAX_LEARNING_ENTRIES) merged.putIfAbsent(key, value) + } + return merged + } + + private fun parsePortable(json: String): JSONObject { + val root = JSONObject(json) + require(root.optInt("schemaVersion", 0) == 1) { "Unsupported portable settings version" } + val allowed = setOf("schemaVersion", "learningPrivacyVersion", "preferences", "strokeFrequency", "pinyinLearnedPhrases") + val keys = root.keys() + while (keys.hasNext()) require(keys.next() in allowed) { "Unknown portable settings field" } + val values = root.getJSONObject("preferences") + val allowedValues = setOf("locale", "themeMode", "hapticEnabled", "hapticAmplitude", "hapticDurationMs", "englishSuggestionsEnabled", "debugFeaturesEnabled") + val valueKeys = values.keys() + while (valueKeys.hasNext()) require(valueKeys.next() in allowedValues) { "Unknown portable preference" } + if (values.has("locale")) require(values.getString("locale") in setOf("system", "zh-CN", "zh-TW", "en", "ja", "ko", "es", "fr", "de")) + if (values.has("themeMode")) require(values.getString("themeMode") in setOf("system", "light", "dark")) + if (values.has("hapticAmplitude")) require(values.getInt("hapticAmplitude") in 1..255) + if (values.has("hapticDurationMs")) require(values.getLong("hapticDurationMs") in 1L..1000L) + require(root.getJSONObject("strokeFrequency").length() <= 20000) + require(root.getJSONObject("pinyinLearnedPhrases").length() <= 20000) + root.getJSONObject("strokeFrequency").toStringMap() + root.getJSONObject("pinyinLearnedPhrases").toStringMap() + return root + } + /** Which categories are actually present in a previously-exported [json] — for the import dialog's checkbox list, which only ever offers what the file really has. Empty (not a throw) for unparseable input. */ fun categoriesPresent(json: String): Set { val root = runCatching { JSONObject(json) }.getOrNull() ?: return emptySet() diff --git a/openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt b/openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt new file mode 100644 index 000000000..4912ccd1d --- /dev/null +++ b/openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt @@ -0,0 +1,18 @@ +package com.openless.app + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OpenLessMigrationBundleInstrumentedTest { + @Test + fun appContextIsAvailableAndSharePolicyIsRedacted() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.openless.app", context.packageName) + assertTrue(OpenLessMigrationBundlePolicy.validateExport("share_redacted", false, null)) + } +} diff --git a/openless-all/app/android/kotlin/test/OpenLessMigrationBundleContractTest.kt b/openless-all/app/android/kotlin/test/OpenLessMigrationBundleContractTest.kt new file mode 100644 index 000000000..313ac431b --- /dev/null +++ b/openless-all/app/android/kotlin/test/OpenLessMigrationBundleContractTest.kt @@ -0,0 +1,36 @@ +package com.openless.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenLessMigrationBundleContractTest { + @Test + fun shareModeNeverCarriesCredentials() { + assertTrue(OpenLessMigrationBundlePolicy.validateExport("share_redacted", false, null)) + assertFalse(OpenLessMigrationBundlePolicy.validateExport("share_redacted", true, "secret")) + assertFalse(OpenLessMigrationBundlePolicy.validateExport("share_redacted", false, "secret")) + } + + @Test + fun personalCredentialsRequirePassword() { + assertFalse(OpenLessMigrationBundlePolicy.validateExport("personal_migration", true, null)) + assertFalse(OpenLessMigrationBundlePolicy.validateExport("personal_migration", true, "")) + assertTrue(OpenLessMigrationBundlePolicy.validateExport("personal_migration", true, "password")) + assertTrue(OpenLessMigrationBundlePolicy.validateExport("personal_migration", false, null)) + } + + @Test + fun canonicalizesRegionalLocalesForBundleSchema() { + assertEquals("en", OpenLessMigrationBundlePolicy.canonicalLocale("en-US")) + assertEquals("en", OpenLessMigrationBundlePolicy.canonicalLocale("en-us")) + assertEquals("zh-TW", OpenLessMigrationBundlePolicy.canonicalLocale("zh-HK")) + assertEquals("system", OpenLessMigrationBundlePolicy.canonicalLocale("xx-ZZ")) + } + + @Test + fun unknownModeIsRejected() { + assertFalse(OpenLessMigrationBundlePolicy.validateExport("legacy_json", false, null)) + } +} diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 3152b222d..953c397ca 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2330,6 +2330,7 @@ pub struct OpenLessBackend { cloud_sync: Option, encrypted_sync: Option, encrypted_sync_store: Option>, + local_migration_store: Arc, pending_corrections: Arc>>, edit_observation_generation: Arc, text_insertions: Arc>>, @@ -2506,9 +2507,10 @@ impl OpenLessBackend { /// its startup error instead of panicking before any window is visible. /// This path never opens the user's files or a credential store. pub fn blocked_startup(mut config: BackendConfig, error: BackendError) -> Self { - if config.data_dir.as_os_str().is_empty() { - config.data_dir = std::path::PathBuf::from("openless-startup-blocked"); - } + // A blocked backend must never touch the caller's failed data path. Use + // an isolated temporary store solely to satisfy the normal constructor. + config.data_dir = + std::env::temp_dir().join(format!("openless-startup-blocked-{}", uuid::Uuid::new_v4())); let repositories = BackendRepositories { preferences: Arc::new(PreferencesStore::in_memory()), history: Arc::new(HistoryStore::at_path(std::path::PathBuf::new())), @@ -2517,27 +2519,19 @@ impl OpenLessBackend { correction_rules: Arc::new(CorrectionRuleStore::at_path(std::path::PathBuf::new())), style_packs: Arc::new(StylePackStore::in_memory()), }; - // All fallible external dependencies are absent and the only required - // config field was normalized above. A failure here is a code invariant. - let mut backend = - Self::new_with_repositories(config, BackendDependencies::unsupported(), repositories) - .expect("memory-only blocked backend is constructible"); let denied = error.clone(); let guard: crate::domains::RuntimeRestoreGuard = Arc::new(move || Err(denied.clone())); - backend - .voice_sessions - .bind_restore_guard(Arc::clone(&guard)) - .expect("fresh voice guard"); - backend - .runtime_start_work - .bind(Arc::clone(&guard), Arc::clone(&backend.deps.task_spawner)) - .expect("fresh startup guard"); - backend - .deps - .services - .selection_voice - .bind_runtime_restore_guard(guard, Arc::clone(&backend.deps.task_spawner)) - .expect("fresh selection guard"); + // The constructor binds the runtime guard for every runtime entry point. + // Supply the blocked guard during construction so it is not replaced after + // the OnceLock-based bindings have already been initialized. + let mut backend = Self::new_with_repositories_and_clock_and_restore_guard( + config, + BackendDependencies::unsupported(), + repositories, + Arc::new(SystemClock), + Some(Arc::clone(&guard)), + ) + .expect("memory-only blocked backend is constructible"); backend.startup_error = Some(error); backend } @@ -2550,14 +2544,14 @@ impl OpenLessBackend { &self, effects: Arc, ) -> Result<(), BackendError> { - if let Some(store) = &self.encrypted_sync_store { - store.bind_runtime_effects(effects).map_err(|_| { + self.local_migration_store + .bind_runtime_effects(effects) + .map_err(|_| { BackendError::new( BackendErrorCode::InvalidState, "restore runtime effects binding failed", ) })?; - } Ok(()) } @@ -2606,10 +2600,26 @@ impl OpenLessBackend { } pub fn new_with_repositories_and_clock( + config: BackendConfig, + deps: BackendDependencies, + repositories: BackendRepositories, + clock: Arc, + ) -> Result { + Self::new_with_repositories_and_clock_and_restore_guard( + config, + deps, + repositories, + clock, + None, + ) + } + + fn new_with_repositories_and_clock_and_restore_guard( config: BackendConfig, mut deps: BackendDependencies, repositories: BackendRepositories, clock: Arc, + restore_guard: Option, ) -> Result { if config.data_dir.as_os_str().is_empty() { return Err(BackendError::new( @@ -2763,6 +2773,17 @@ impl OpenLessBackend { } else { None }; + let local_migration_store = if let Some(store) = &encrypted_sync_store { + Arc::clone(store) + } else { + crate::cloud_sync_e2ee::build_local_store( + &config.data_dir, + repositories.clone(), + Arc::clone(&deps.credential_store), + env!("CARGO_PKG_VERSION").into(), + Arc::clone(&deps.task_spawner), + )? + }; match ( deps.selection_runtime.take(), deps.selection_polisher.take(), @@ -2833,20 +2854,23 @@ impl OpenLessBackend { deps.services .remote_input .bind_event_publisher(BackendEventPublisher::new(Arc::clone(&events))); - if let Some(store) = &encrypted_sync_store { - let weak_store = Arc::downgrade(store); - let guard: crate::domains::RuntimeRestoreGuard = Arc::new(move || { - let store = weak_store.upgrade().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidState, - "runtime restore source is unavailable", - ) - })?; - store.ensure_runtime_available().map_err(|_| { - BackendError::new( - BackendErrorCode::Busy, - "encrypted sync restore must finish before starting runtime work", - ) + { + let store = &local_migration_store; + let guard: crate::domains::RuntimeRestoreGuard = restore_guard.unwrap_or_else(|| { + let weak_store = Arc::downgrade(&local_migration_store); + Arc::new(move || { + let store = weak_store.upgrade().ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidState, + "runtime restore source is unavailable", + ) + })?; + store.ensure_runtime_available().map_err(|_| { + BackendError::new( + BackendErrorCode::Busy, + "encrypted sync restore must finish before starting runtime work", + ) + }) }) }); voice_sessions.bind_restore_guard(Arc::clone(&guard))?; @@ -2929,6 +2953,7 @@ impl OpenLessBackend { cloud_sync, encrypted_sync, encrypted_sync_store, + local_migration_store, pending_corrections: Arc::new(Mutex::new(Vec::new())), edit_observation_generation: Arc::new(AtomicU64::new(0)), text_insertions: Arc::new(Mutex::new(HashMap::new())), @@ -2945,14 +2970,14 @@ impl OpenLessBackend { if let Some(error) = &self.startup_error { return Err(error.clone()); } - if let Some(store) = &self.encrypted_sync_store { - store.ensure_runtime_available().map_err(|_| { + self.local_migration_store + .ensure_runtime_available() + .map_err(|_| { BackendError::new( BackendErrorCode::Busy, "encrypted sync restore must finish before starting runtime work", ) })?; - } Ok(()) } @@ -3868,6 +3893,10 @@ impl OpenLessBackend { // The service records its own retryable recovery/storage error; // runtime admission remains fenced until recovery is complete. let _ = sync.start(Arc::clone(&self.deps.task_spawner)).await; + } else { + // Local migration has the same crash journal as cloud sync, but it + // has no service startup hook when the user is signed out. + let _ = self.local_migration_store.recover_registered().await; } let preferences = self.get_preferences(); let status = match warmed { @@ -4831,6 +4860,12 @@ impl OpenLessBackend { Ok(status) } + pub fn publish_local_migration_state_changed(&self) { + self.publish_preferences_changed(); + self.publish_vocabulary_changed(); + self.publish_style_packs_changed(); + } + pub fn get_preferences(&self) -> UserPreferences { self.preferences.get() } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index 91a36361b..23d2acb27 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -111,6 +111,54 @@ pub(crate) fn build( Ok((service, store)) } +/// Build the native document store without requiring a configured sync account. +/// The local bundle uses the same repositories, credential store, gate and +/// device identity as the rest of Core, but keeps its protected extensions in a +/// separate namespace from the cloud-sync state. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_local_store( + data_dir: &Path, + repositories: crate::BackendRepositories, + credentials: Arc, + app_version: String, + tasks: Arc, +) -> SyncResult> { + let root = data_dir.join("encrypted-sync"); + std::fs::create_dir_all(&root).map_err(|error| { + log::error!( + "[e2ee-adapter] create local migration root failed path={} err={error}", + root.display() + ); + super::error("local_storage_unavailable") + })?; + let gate = + crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(document_error)?; + let device_id = load_device_id(&root)?; + let device = SourceDevice { + id: device_id.clone(), + os: "android".into(), + arch: "unknown".into(), + app_version, + }; + let local = LocalStorage::new( + root.join("local-migration-protected"), + "https://local.openless.invalid".into(), + device_id, + credentials.clone(), + ); + CoreSyncStore::new( + repositories, + credentials, + data_dir.into(), + device, + gate, + Arc::new(local), + tasks, + ) + .map(Arc::new) + .map_err(document_error) +} + pub(crate) fn load_device_id(root: &Path) -> SyncResult { let device_path = root.join("device-id"); let device_id = match std::fs::read_to_string(&device_path) { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs index 6dbd7f913..bcc0d10da 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -2,6 +2,65 @@ use super::OpenLessBackend; use crate::{cloud_sync_e2ee::*, BackendError}; impl OpenLessBackend { + pub async fn export_local_migration_bundle( + &self, + options: crate::local_migration_bundle::BundleExportOptions, + ) -> Result { + self.local_migration_store + .export_local_bundle(options) + .await + .map_err(|error| { + BackendError::new(crate::BackendErrorCode::InvalidState, error.to_string()) + }) + } + + pub fn inspect_local_migration_bundle( + &self, + bytes: &[u8], + ) -> Result { + crate::cloud_sync_e2ee_store::CoreSyncStore::inspect_local_bundle(bytes).map_err(|error| { + BackendError::new(crate::BackendErrorCode::InvalidArgument, error.to_string()) + }) + } + + pub async fn preview_local_migration_bundle( + &self, + bytes: &[u8], + password: Option, + portable_android_preferences: Option, + ) -> Result { + self.local_migration_store + .preview_local_bundle(bytes, password, portable_android_preferences) + .await + .map_err(|error| { + BackendError::new(crate::BackendErrorCode::InvalidState, error.to_string()) + }) + } + + pub async fn import_local_migration_bundle( + &self, + bytes: &[u8], + password: Option, + mode: crate::local_migration_bundle::RestoreMode, + choices: &[crate::cloud_sync_e2ee_documents::ConflictChoice], + expected_generation: Option, + portable_android_preferences: Option, + ) -> Result { + self.local_migration_store + .import_local_bundle( + bytes, + password, + mode, + choices, + expected_generation, + portable_android_preferences, + ) + .await + .map_err(|error| { + BackendError::new(crate::BackendErrorCode::InvalidState, error.to_string()) + }) + } + pub async fn sign_out_account(&self) -> Result<(), BackendError> { if let Some(service) = &self.encrypted_sync { service.sign_out().await.map(|_| ()) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs index da39dce42..6cfc6ddc7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs @@ -8,7 +8,7 @@ mod service; #[cfg(test)] mod tests; -pub(crate) use adapter::build; +pub(crate) use adapter::{build, build_local_store}; pub use dto::*; pub(crate) use service::{EncryptedSyncService, SyncServiceConfig}; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs index 39ca280b5..d2abd0e58 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs @@ -9,7 +9,7 @@ use crate::credentials::{CredentialKey, CredentialNamespace, CredentialStore}; use super::registry::{ credential_accounts, excluded_extension_key, is_device_channel, preference_field, - PreferenceClass, + public_credential_account, public_credential_value_safe, PreferenceClass, }; use super::types::*; use super::validate::validate_sync_documents; @@ -77,7 +77,7 @@ pub fn export_snapshot(mut snapshot: ExportSnapshot) -> DocumentResult DocumentResult DocumentResult { + let mut set = crate::cloud_sync_e2ee_protocol::types::DocumentSet::from_json_bytes(bytes) + .map_err(|_| DocumentError::InvalidDocument)?; + let channel_ids: Vec<_> = set + .documents + .iter() + .filter(|document| document.kind == DocumentKind::Channels) + .map(|document| document.id.clone()) + .collect(); + for channel_id in channel_ids { + let (namespace, id) = channel_id + .split_once(':') + .ok_or(DocumentError::InvalidDocument)?; + if !matches!(namespace, "asr" | "llm" | "omni") { + return Err(DocumentError::InvalidDocument); + } + set.documents + .push(crate::cloud_sync_e2ee_protocol::types::LogicalDocument { + id: channel_id.clone(), + kind: DocumentKind::ProviderCredentials, + schema_version: DOCUMENT_SCHEMA_VERSION, + value: serde_json::json!({ + "channelId": id, + "namespace": namespace, + "accounts": {} + }), + }); + } + let mut validated = validate_sync_documents(set, observed_revision)?; + validated + .set + .documents + .retain(|document| document.kind != DocumentKind::ProviderCredentials); + Ok(validated) +} + +/// Revalidate a logical set after a bundle adapter restores target-device-only fields. +pub fn revalidate_sync_documents( + documents: crate::cloud_sync_e2ee_protocol::types::DocumentSet, + observed_revision: crate::cloud_sync_e2ee_protocol::types::Revision, +) -> DocumentResult { + validate_sync_documents(documents, observed_revision) +} + +/// Parse a bounded logical document set before any local side effect. +/// +/// The wire parser remains owned by the document module so other import +/// formats cannot accidentally bypass duplicate-key, depth, and schema checks. +pub fn parse_sync_documents( + bytes: &[u8], + observed_revision: crate::cloud_sync_e2ee_protocol::types::Revision, +) -> DocumentResult { + let set = crate::cloud_sync_e2ee_protocol::types::DocumentSet::from_json_bytes(bytes) + .map_err(|_| DocumentError::InvalidDocument)?; + validate_sync_documents(set, observed_revision) +} + +/// Serialize a validated logical document set without exposing its internal +/// representation to host adapters. +pub fn serialize_sync_documents(documents: &ValidatedSyncDocuments) -> DocumentResult> { + serde_json::to_vec(documents.documents()).map_err(|_| DocumentError::InvalidDocument) +} + #[cfg(test)] mod restore_tests; #[cfg(test)] diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index b1f7c6d8d..dae3ddeb1 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -964,6 +964,103 @@ pub fn credential_accounts(namespace: SyncNamespace) -> &'static [&'static str] } } +pub fn public_credential_account(namespace: SyncNamespace, account: &str) -> bool { + matches!( + (namespace, account), + ( + SyncNamespace::Asr, + "asr.endpoint" | "asr.model" | "asr.vocabulary_id" + ) | ( + SyncNamespace::Llm, + "ark.endpoint" + | "ark.model_id" + | "ark.temperature" + | "ark.request_format" + | "ark.messages_thinking" + | "ark.max_tokens" + | "ark.thinking_budget" + | "ark.service_tier" + ) | ( + SyncNamespace::Omni, + "omni.endpoint" | "omni.model" | "omni.temperature" + ) + ) +} + +pub fn public_credential_value_safe(account: &str, value: &str) -> bool { + if !account.ends_with(".endpoint") { + return true; + } + let Ok(url) = url::Url::parse(value) else { + return false; + }; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + let host_is_safe = host.parse::().is_ok() + || (host.len() <= 253 + && host.split('.').all(|label| { + let label = label.to_ascii_lowercase(); + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '-' + }) + && ![ + "token", + "secret", + "bearer", + "apikey", + "api-key", + "access", + "refresh", + "oauth", + "credential", + "password", + "passwd", + "jwt", + ] + .iter() + .any(|marker| label.contains(marker)) + && !["sk-", "pk-", "rk-"] + .iter() + .any(|prefix| label.starts_with(prefix)) + })); + if !host_is_safe { + return false; + } + let path_is_safe = url.path().len() <= 256 + && url + .path() + .split('/') + .filter(|segment| !segment.is_empty()) + .all(|segment| { + segment.len() <= 32 + && segment.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '-' | '.' | '~') + }) + && !segment.contains("token") + && !segment.contains("secret") + && !segment.contains("bearer") + && !segment.contains("key") + }); + path_is_safe +} + pub fn is_device_channel(namespace: SyncNamespace, provider: &str) -> bool { namespace == SyncNamespace::Asr && matches!( diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs index 072dc2fba..0945bc59d 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -79,6 +79,7 @@ pub(super) fn channel_snapshot() -> ExportSnapshot { enabled: true, order: 0, active: true, + configuration: BTreeMap::new(), }); data.provider_credentials.push(ProviderCredentialRecord { channel_id: "llm-one".into(), @@ -118,6 +119,22 @@ fn all_current_preference_fields_have_an_explicit_registration() { assert_eq!(registered.len(), registry::PREFERENCE_FIELDS.len()); } +#[test] +fn public_endpoint_rejects_credential_like_hostnames() { + assert!(!registry::public_credential_value_safe( + "ark.endpoint", + "https://sk-live-abc123.example.test" + )); + assert!(!registry::public_credential_value_safe( + "ark.endpoint", + "https://token.example.test/v1" + )); + assert!(registry::public_credential_value_safe( + "ark.endpoint", + "https://api.example.test/v1" + )); +} + #[test] fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_grants() { let mut data = channel_snapshot(); @@ -162,6 +179,7 @@ fn local_asr_channels_are_archived_with_their_credentials_not_globally_activated enabled: true, order: 0, active: true, + configuration: BTreeMap::new(), }); data.provider_credentials.push(ProviderCredentialRecord { channel_id: "local-one".into(), diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs index eb872e563..14a5bd1c7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs @@ -144,6 +144,8 @@ pub struct ChannelRecord { pub enabled: bool, pub order: u32, pub active: bool, + #[serde(default)] + pub configuration: BTreeMap, } impl ChannelRecord { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs index ee0ce812b..0cc03869e 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs @@ -13,7 +13,8 @@ use crate::cloud_sync_e2ee_protocol::types::{ use super::registry::{ credential_accounts, excluded_extension_key, is_device_channel, preference_field, - validate_preference_value, PreferenceClass, + public_credential_account, public_credential_value_safe, validate_preference_value, + PreferenceClass, }; use super::types::*; @@ -336,6 +337,13 @@ fn validate_channel(channel: &ChannelRecord) -> DocumentResult<()> { if channel.namespace == SyncNamespace::Omni && channel.id != channel.provider_type { return Err(DocumentError::InvalidReference); } + if channel.configuration.iter().any(|(account, value)| { + !public_credential_account(channel.namespace, account) + || !public_credential_value_safe(account, value) + || value.len() > 4096 + }) { + return Err(DocumentError::ExcludedField); + } Ok(()) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index 72814fd42..15e2c41ae 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -15,10 +15,16 @@ use std::sync::Arc; use futures_util::future::BoxFuture; use crate::cloud_sync_e2ee_documents::{ - DocumentError, DocumentResult, ExportedDocuments, RestoreBackend, RestoreContext, RestoreLease, - SecretJson, SyncScope, ValidatedSyncDocuments, + ConflictChoice, DocumentError, DocumentKind, DocumentResult, ExportedDocuments, RestoreBackend, + RestoreContext, RestoreLease, SecretJson, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentSet, LogicalDocument, Revision, SourceDevice, +}; +use crate::local_migration_bundle::{ + decode_bundle, export_validated_bundle, BundleArtifact, BundleError, BundleExportOptions, + BundleInspection, BundleMergePreview, BundlePassword, RestoreMode, }; -use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, Revision, SourceDevice}; use crate::{BackendRepositories, CredentialStore}; pub use extensions::{DeviceExtensionKey, ProtectedExtensionStore}; @@ -643,5 +649,405 @@ fn receipt(value: gate::GateRestoreReceipt) -> crate::cloud_sync_e2ee_documents: } } +impl CoreSyncStore { + /// Export the complete local state without binding the user file to a cloud account. + pub async fn export_local_bundle( + &self, + options: BundleExportOptions, + ) -> Result { + let captured = self + .export_scope(self.local_bundle_scope()) + .await + .map_err(BundleError::from)?; + export_validated_bundle(&captured.documents, options) + } + + /// Inspecting a bundle is deliberately independent of the native stores. + pub fn inspect_local_bundle(bytes: &[u8]) -> Result { + crate::local_migration_bundle::inspect_bundle(bytes) + } + + fn overlay_portable_android_preferences( + current: &ValidatedSyncDocuments, + value: Option, + ) -> Result { + let Some(value) = value else { + return Ok(current.clone()); + }; + let mut set = current.documents().clone(); + set.documents.retain(|document| { + !(document.kind == DocumentKind::UiPreferences + && document.id == "androidPortableSettings") + }); + set.documents.push(LogicalDocument { + id: "androidPortableSettings".into(), + kind: DocumentKind::UiPreferences, + schema_version: crate::cloud_sync_e2ee_documents::DOCUMENT_SCHEMA_VERSION, + value, + }); + crate::cloud_sync_e2ee_documents::revalidate_sync_documents( + set, + current.observed_revision(), + ) + .map_err(BundleError::from) + } + + pub async fn preview_local_bundle( + &self, + bytes: &[u8], + password: Option, + portable_android_preferences: Option, + ) -> Result { + let scope = self.local_bundle_scope(); + let captured = self.export_scope(scope).await.map_err(BundleError::from)?; + let generation = captured.generation; + let current = Self::overlay_portable_android_preferences( + &captured.documents, + portable_android_preferences, + )?; + let decoded = decode_bundle(bytes, password.as_ref(), generation)?; + let inspection = decoded.inspection.clone(); + let bundle_documents = self.prepare_bundle_documents(¤t, decoded)?; + let preview = crate::cloud_sync_e2ee_documents::diff_sync_documents( + None, + ¤t, + &bundle_documents, + ) + .map_err(BundleError::from)?; + Ok(BundleMergePreview { + inspection, + observed_generation: generation, + conflicts: preview.conflicts().to_vec(), + }) + } + + /// Apply a previously inspected bundle under the same crash-safe gate used by sync. + /// The synthetic scope is internal state only; it never appears in the user bundle. + pub async fn import_local_bundle( + &self, + bytes: &[u8], + password: Option, + mode: RestoreMode, + choices: &[ConflictChoice], + expected_generation: Option, + portable_android_preferences: Option, + ) -> Result { + let scope = self.local_bundle_scope(); + let captured = self + .export_scope(scope.clone()) + .await + .map_err(BundleError::from)?; + let current = Self::overlay_portable_android_preferences( + &captured.documents, + portable_android_preferences.clone(), + )?; + let generation = captured.generation; + if expected_generation.is_some_and(|expected| expected != generation) { + return Err(BundleError::StalePreview); + } + let decoded = decode_bundle(bytes, password.as_ref(), generation)?; + let inspection = decoded.inspection.clone(); + let bundle_documents = self.prepare_bundle_documents(¤t, decoded)?; + let desired = match mode { + RestoreMode::Replace => bundle_documents, + RestoreMode::Merge => { + let preview = crate::cloud_sync_e2ee_documents::diff_sync_documents( + None, + ¤t, + &bundle_documents, + ) + .map_err(BundleError::from)?; + preview.resolve(choices).map_err(BundleError::from)? + } + }; + let backup_options = if inspection.credentials_present { + BundleExportOptions { + mode: crate::local_migration_bundle::BundleMode::PersonalMigration, + credential_policy: + crate::local_migration_bundle::CredentialPolicy::PasswordEncrypted, + password: password.clone(), + portable_android_preferences, + ..BundleExportOptions::default() + } + } else { + BundleExportOptions { + portable_android_preferences, + ..BundleExportOptions::default() + } + }; + let backup = export_validated_bundle(&captured.documents, backup_options)?; + self.write_local_bundle_backup(&backup)?; + let context = RestoreContext { + scope, + operation_id: uuid::Uuid::new_v4().to_string(), + observed_revision: generation, + local_generation: generation, + target_device: self.inner.device.clone(), + }; + self.restore_scope(desired, context) + .await + .map_err(BundleError::from)?; + Ok(inspection) + } + + fn prepare_bundle_documents( + &self, + current: &ValidatedSyncDocuments, + decoded: crate::local_migration_bundle::DecodedBundle, + ) -> Result { + use crate::cloud_sync_e2ee_documents::DocumentKind; + + if !matches!( + decoded.inspection.mode, + crate::local_migration_bundle::BundleMode::ShareRedacted + ) && decoded.inspection.credentials_present + { + let mut set = decoded.documents.documents().clone(); + set.source_device = current.documents().source_device.clone(); + set.tombstones + .retain(|tombstone| tombstone.kind != DocumentKind::DeviceProfile); + set.documents + .retain(|document| document.kind != DocumentKind::DeviceProfile); + set.documents.extend( + current + .documents() + .documents + .iter() + .filter(|document| document.kind == DocumentKind::DeviceProfile) + .cloned(), + ); + return crate::cloud_sync_e2ee_documents::revalidate_sync_documents( + set, + current.observed_revision(), + ) + .map_err(BundleError::from); + } + + use serde_json::json; + use std::collections::{BTreeMap, BTreeSet}; + + let mut set = decoded.documents.documents().clone(); + set.source_device = current.documents().source_device.clone(); + set.tombstones.retain(|tombstone| { + !matches!( + tombstone.kind, + DocumentKind::ProviderCredentials | DocumentKind::DeviceProfile + ) + }); + let imported_channels: BTreeSet = set + .documents + .iter() + .filter(|document| document.kind == DocumentKind::Channels) + .map(|document| document.id.clone()) + .collect(); + let current_channels: BTreeMap = current + .documents() + .documents + .iter() + .filter(|document| document.kind == DocumentKind::Channels) + .map(|document| (document.id.clone(), document.clone())) + .collect(); + let imported_active_namespaces: BTreeSet = set + .documents + .iter() + .filter(|document| { + document.kind == DocumentKind::Channels + && document + .value + .get("active") + .and_then(serde_json::Value::as_bool) + == Some(true) + }) + .filter_map(|document| { + document + .value + .get("namespace") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .collect(); + let current_credentials: BTreeMap = current + .documents() + .documents + .iter() + .filter(|document| document.kind == DocumentKind::ProviderCredentials) + .map(|document| (document.id.clone(), document.clone())) + .collect(); + let mut additions = Vec::new(); + set.tombstones.retain(|tombstone| { + !(tombstone.kind == DocumentKind::Channels + && current_channels.contains_key(&tombstone.id) + && !imported_channels.contains(&tombstone.id)) + }); + + for channel in set + .documents + .iter_mut() + .filter(|document| document.kind == DocumentKind::Channels) + { + let existing_channel = current_channels.get(&channel.id); + let compatible_channel = existing_channel.is_some_and(|current| { + current.value.get("namespace") == channel.value.get("namespace") + && current.value.get("providerType") == channel.value.get("providerType") + }); + if let Some(credentials) = current_credentials.get(&channel.id) { + if compatible_channel { + let record: crate::cloud_sync_e2ee_documents::ProviderCredentialRecord = + serde_json::from_value(credentials.value.clone()) + .map_err(|_| BundleError::InvalidSchema)?; + let has_secret = record.accounts.keys().any(|account| { + !crate::cloud_sync_e2ee_documents::registry::public_credential_account( + record.namespace, + account, + ) + }); + if has_secret { + // Public configuration from a share is untrusted when it is + // paired with a retained secret. Keep the existing provider + // endpoint/model instead of sending that secret elsewhere. + channel + .value + .as_object_mut() + .ok_or(BundleError::InvalidSchema)? + .remove("configuration"); + } + let mut credential = credentials.clone(); + let credential_object = credential + .value + .as_object_mut() + .ok_or(BundleError::InvalidSchema)?; + let accounts = credential_object + .get_mut("accounts") + .and_then(serde_json::Value::as_object_mut) + .ok_or(BundleError::InvalidSchema)?; + if !has_secret { + accounts.retain(|account, _| { + !crate::cloud_sync_e2ee_documents::registry::public_credential_account( + record.namespace, + account, + ) + }); + if let Some(configuration) = channel + .value + .get("configuration") + .and_then(serde_json::Value::as_object) + { + for (account, value) in configuration { + accounts.insert(account.clone(), value.clone()); + } + } + } + additions.push(credential); + continue; + } + // A changed provider identity must never inherit a receiver secret. + channel + .value + .as_object_mut() + .ok_or(BundleError::InvalidSchema)? + .insert("enabled".into(), serde_json::Value::Bool(false)); + channel + .value + .as_object_mut() + .ok_or(BundleError::InvalidSchema)? + .insert("active".into(), serde_json::Value::Bool(false)); + } + let channel_value = channel + .value + .as_object_mut() + .ok_or(BundleError::InvalidSchema)?; + let namespace = channel_value + .get("namespace") + .cloned() + .ok_or(BundleError::InvalidSchema)?; + let channel_id = channel_value + .get("id") + .cloned() + .ok_or(BundleError::InvalidSchema)?; + additions.push(LogicalDocument { + id: channel.id.clone(), + kind: DocumentKind::ProviderCredentials, + schema_version: channel.schema_version, + value: json!({ + "channelId": channel_id, + "namespace": namespace, + "accounts": {}, + }), + }); + } + + // Replace imports must not delete receiver-only channels or their + // credentials merely because a redacted share cannot carry them. + for (id, mut channel) in current_channels { + if !imported_channels.contains(&id) { + if imported_active_namespaces.contains( + channel + .value + .get("namespace") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ) { + channel + .value + .as_object_mut() + .ok_or(BundleError::InvalidSchema)? + .insert("active".into(), serde_json::Value::Bool(false)); + } + if let Some(credentials) = current_credentials.get(&id) { + set.documents.push(channel); + set.documents.push(credentials.clone()); + } + } + } + additions.extend( + current + .documents() + .documents + .iter() + .filter(|document| document.kind == DocumentKind::DeviceProfile) + .cloned(), + ); + set.documents.extend(additions); + crate::cloud_sync_e2ee_documents::revalidate_sync_documents( + set, + current.observed_revision(), + ) + .map_err(BundleError::from) + } + + fn local_bundle_scope(&self) -> SyncScope { + SyncScope { + service_origin: "https://local.openless.invalid".into(), + owner_github_id: "1".into(), + vault_id: "00000000-0000-4000-8000-000000000001".into(), + key_id: "00000000-0000-4000-8000-000000000002".into(), + device_id: self.inner.device.id.clone(), + } + } + + fn write_local_bundle_backup(&self, artifact: &BundleArtifact) -> Result<(), BundleError> { + let directory = self.inner.data_dir.join("local-migration-backups"); + std::fs::create_dir_all(&directory).map_err(|_| BundleError::BackupFailed)?; + let id = uuid::Uuid::new_v4().to_string(); + let temporary = directory.join(format!("{id}.tmp")); + let target = directory.join(format!("{id}.omb")); + let result = (|| -> Result<(), BundleError> { + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary) + .map_err(|_| BundleError::BackupFailed)?; + std::io::Write::write_all(&mut file, &artifact.bytes) + .map_err(|_| BundleError::BackupFailed)?; + file.sync_all().map_err(|_| BundleError::BackupFailed)?; + std::fs::rename(&temporary, &target).map_err(|_| BundleError::BackupFailed) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result + } +} + #[cfg(test)] mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs index 4a30c0940..0a238f768 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs @@ -82,6 +82,56 @@ pub(super) fn canonicalize_native_documents( } doc.value = canonical; } + // Older sync snapshots kept public provider settings only in the + // credential record. Normalize those records into the channel projection + // before restore verification so legacy snapshots remain round-trippable. + let public_accounts: Vec<(String, std::collections::BTreeMap)> = set + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::ProviderCredentials) + .filter_map(|doc| { + let record: ProviderCredentialRecord = typed(&doc.value).ok()?; + Some((doc.id.clone(), record.accounts.clone())) + }) + .collect(); + for channel in set + .documents + .iter_mut() + .filter(|doc| doc.kind == DocumentKind::Channels) + { + let Some((_, accounts)) = public_accounts.iter().find(|(id, _)| id == &channel.id) else { + continue; + }; + let record: ChannelRecord = typed(&channel.value)?; + let public: std::collections::BTreeMap<_, _> = accounts + .iter() + .filter(|(account, value)| { + crate::cloud_sync_e2ee_documents::registry::public_credential_account( + record.namespace, + account, + ) && crate::cloud_sync_e2ee_documents::registry::public_credential_value_safe( + account, value, + ) + }) + .map(|(account, value)| (account.clone(), value.clone())) + .collect(); + if public.is_empty() { + continue; + } + let object = channel + .value + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + let configuration = object + .entry("configuration") + .or_insert_with(|| serde_json::json!({})); + let configuration = configuration + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + for (account, value) in public { + configuration.entry(account).or_insert(Value::String(value)); + } + } validate_sync_documents(set, desired.observed_revision()) } @@ -316,6 +366,16 @@ impl CoreSyncStore { { map.insert("codingAgentEnabled".into(), Value::Bool(false)); } + for channel in &channels { + let Some(record) = credentials.iter_mut().find(|record| { + record.channel_id == channel.id && record.namespace == channel.namespace + }) else { + return Err(DocumentError::InvalidReference); + }; + for (account, value) in &channel.configuration { + record.accounts.insert(account.clone(), value.clone()); + } + } validate_credential_set(&channels, &credentials)?; for (namespace, key) in [ (SyncNamespace::Asr, "activeAsrProvider"), diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index 4cad49904..55c81c85f 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -414,10 +414,47 @@ fn validate(set: DocumentSet) -> ValidatedSyncDocuments { validate_sync_documents(set, Revision::new(3)).unwrap() } +#[tokio::test] +async fn personal_bundle_keeps_receiver_device_profile() { + let fixture = Fixture::new(); + let captured = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let current = captured.documents; + let artifact = export_validated_bundle( + ¤t, + BundleExportOptions { + mode: crate::local_migration_bundle::BundleMode::PersonalMigration, + credential_policy: crate::local_migration_bundle::CredentialPolicy::PasswordEncrypted, + password: Some(BundlePassword::new("test-password").unwrap()), + app_version: "test".into(), + portable_android_preferences: None, + }, + ) + .unwrap(); + let decoded = decode_bundle( + &artifact.bytes, + Some(&BundlePassword::new("test-password").unwrap()), + captured.generation, + ) + .unwrap(); + let prepared = fixture + .store + .prepare_bundle_documents(¤t, decoded) + .unwrap(); + assert!(prepared + .documents() + .documents + .iter() + .any(|document| document.kind == DocumentKind::DeviceProfile + && document.id == current.documents().source_device.id)); +} + #[tokio::test] async fn disabled_fresh_start_never_requests_local_key() { let fixture = Fixture::new(); - fixture.store.recover_registered().await.unwrap(); assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); } diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 00283faf5..e26fde644 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -2899,11 +2899,7 @@ mod tests { let session_id = SessionId::new(); fixture .engine - .start( - session_id, - Arc::new(context), - fixture.progress.clone(), - ) + .start(session_id, Arc::new(context), fixture.progress.clone()) .await .unwrap(); let result = fixture @@ -2930,11 +2926,7 @@ mod tests { let session_id = SessionId::new(); fixture .engine - .start( - session_id, - Arc::new(context), - fixture.progress.clone(), - ) + .start(session_id, Arc::new(context), fixture.progress.clone()) .await .unwrap(); let result = fixture diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index a909be005..df48ab5e6 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -41,6 +41,7 @@ pub mod language_catalog; mod less_computer; pub mod llm_gemini; pub mod llm_protocol; +pub mod local_migration_bundle; mod marketplace; pub mod model_store; pub mod net; diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs new file mode 100644 index 000000000..d9ac36c8a --- /dev/null +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs @@ -0,0 +1,645 @@ +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::cloud_sync_e2ee_documents::{ + export_sync_documents, parse_redacted_sync_documents, parse_sync_documents, + revalidate_sync_documents, serialize_sync_documents, DocumentKind, Revision, + SyncDocumentSource, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::LogicalDocument; + +use super::crypto; +use super::types::*; + +const MANIFEST_ENTRY: &str = "manifest.json"; +const DOCUMENTS_ENTRY: &str = "documents.json"; +const CREDENTIALS_ENTRY: &str = "credentials.enc"; +const PLATFORM: &str = "android"; +const MAX_COMPRESSION_RATIO: u64 = 200; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct EntryDigest { + entry: String, + bytes: usize, + sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CredentialsManifest { + entry: String, + bytes: usize, + sha256: String, + encryption: String, + kdf: String, + aead: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Manifest { + format: String, + format_version: u32, + document_schema_version: u32, + bundle_id: String, + created_at: String, + producer: Producer, + platform: String, + mode: BundleMode, + documents: EntryDigest, + credentials: Option, + sections: SectionCounts, + permissions_migrated: bool, + device_profile_migrated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Producer { + app_version: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CredentialPayload<'a> { + schema_version: u32, + documents: &'a [Value], +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodedCredentialPayload { + schema_version: u32, + documents: Vec, +} + +pub async fn export_bundle( + source: &dyn SyncDocumentSource, + options: BundleExportOptions, +) -> BundleResult { + options.validate()?; + let exported = export_sync_documents(source) + .await + .map_err(BundleError::from)?; + assemble_bundle(exported.documents, exported.generation, &options) +} + +/// Build a Bundle from an already validated coherent logical snapshot. +/// This is the seam used by CoreSyncStore adapters, so hosts cannot combine +/// independent repository reads into a false snapshot. +pub fn export_validated_bundle( + documents: &ValidatedSyncDocuments, + options: BundleExportOptions, +) -> BundleResult { + options.validate()?; + let documents = if let Some(value) = &options.portable_android_preferences { + let mut set = documents.documents().clone(); + set.documents.retain(|document| { + !(document.kind == DocumentKind::UiPreferences + && document.id == "androidPortableSettings") + }); + set.documents.push(LogicalDocument { + id: "androidPortableSettings".into(), + kind: DocumentKind::UiPreferences, + schema_version: BUNDLE_DOCUMENT_SCHEMA_VERSION, + value: value.clone(), + }); + revalidate_sync_documents(set, documents.observed_revision()).map_err(BundleError::from)? + } else { + documents.clone() + }; + let revision = documents.observed_revision(); + assemble_bundle(documents, revision, &options) +} + +pub fn inspect_bundle(bytes: &[u8]) -> BundleResult { + let entries = read_entries(bytes)?; + let manifest = decode_manifest(&entries)?; + let documents = validate_document_entry(&manifest, &entries)?; + let counts = section_counts(&documents)?; + Ok(inspection(&manifest, &documents, false)).map(|mut value| { + value.section_counts = counts; + value + }) +} + +pub fn decode_bundle( + bytes: &[u8], + password: Option<&BundlePassword>, + observed_revision: Revision, +) -> BundleResult { + let entries = read_entries(bytes)?; + let manifest = decode_manifest(&entries)?; + let mut root = validate_document_entry(&manifest, &entries)?; + let mut credentials_unlocked = false; + if let Some(credentials) = &manifest.credentials { + let password = password.ok_or(BundleError::PasswordRequired)?; + let ciphertext = entries + .get(&credentials.entry) + .ok_or(BundleError::MalformedContainer)?; + let aad = crypto::aad( + &manifest.bundle_id, + &manifest.documents.sha256, + &credentials.entry, + ); + let plaintext = crypto::decrypt(password, ciphertext, &aad)?; + let payload: DecodedCredentialPayload = + serde_json::from_slice(&plaintext).map_err(|_| BundleError::InvalidPassword)?; + if payload.schema_version != BUNDLE_FORMAT_VERSION { + return Err(BundleError::UnsupportedVersion); + } + let documents = root + .get_mut("documents") + .and_then(Value::as_array_mut) + .ok_or(BundleError::InvalidSchema)?; + for document in payload.documents { + if document.get("kind").and_then(Value::as_str) != Some("provider_credentials") { + return Err(BundleError::InvalidSchema); + } + documents.push(document); + } + credentials_unlocked = true; + } + let bytes = serde_json::to_vec(&root).map_err(|_| BundleError::InvalidSchema)?; + let documents = if manifest.credentials.is_none() { + parse_redacted_sync_documents(&bytes, observed_revision) + } else { + parse_sync_documents(&bytes, observed_revision) + } + .map_err(BundleError::from)?; + let inspection = inspection(&manifest, &root, credentials_unlocked); + Ok(DecodedBundle { + inspection, + documents, + }) +} + +fn assemble_bundle( + documents: ValidatedSyncDocuments, + _generation: Revision, + options: &BundleExportOptions, +) -> BundleResult { + let bytes = serialize_sync_documents(&documents).map_err(BundleError::from)?; + let mut root: Value = serde_json::from_slice(&bytes).map_err(|_| BundleError::InvalidSchema)?; + let root_object = root.as_object_mut().ok_or(BundleError::InvalidSchema)?; + // A bundle never carries the source device identity or device profile. The + // target restore replaces this metadata with its own device profile. + root_object.insert( + "sourceDevice".into(), + json!({"id":"imported","os":"android","arch":"unknown","appVersion":options.app_version}), + ); + let source_documents = root_object + .get_mut("documents") + .and_then(Value::as_array_mut) + .ok_or(BundleError::InvalidSchema)?; + let mut credential_documents = Vec::new(); + source_documents.retain(|document| { + let kind = document.get("kind").and_then(Value::as_str); + if kind == Some("device_profile") { + return false; + } + if kind == Some("provider_credentials") { + credential_documents.push(document.clone()); + return false; + } + true + }); + for channel in source_documents + .iter_mut() + .filter(|document| document.get("kind").and_then(Value::as_str) == Some("channels")) + { + let Some(credential) = credential_documents + .iter() + .find(|document| document.get("id") == channel.get("id")) + else { + continue; + }; + let namespace = credential + .get("value") + .and_then(|value| value.get("namespace")) + .cloned() + .ok_or(BundleError::InvalidSchema)?; + let namespace = + serde_json::from_value::(namespace) + .map_err(|_| BundleError::InvalidSchema)?; + let mut configuration = serde_json::Map::new(); + if let Some(accounts) = credential + .get("value") + .and_then(|value| value.get("accounts")) + .and_then(Value::as_object) + { + for (account, value) in accounts { + if crate::cloud_sync_e2ee_documents::registry::public_credential_account( + namespace, account, + ) && crate::cloud_sync_e2ee_documents::registry::public_credential_value_safe( + account, + value.as_str().unwrap_or_default(), + ) { + configuration.insert(account.clone(), value.clone()); + } + } + } + channel + .get_mut("value") + .and_then(Value::as_object_mut) + .ok_or(BundleError::InvalidSchema)? + .insert("configuration".into(), Value::Object(configuration)); + } + if let Some(tombstones) = root_object + .get_mut("tombstones") + .and_then(Value::as_array_mut) + { + tombstones.retain(|tombstone| { + !(matches!(options.mode, BundleMode::ShareRedacted) + && matches!( + tombstone.get("kind").and_then(Value::as_str), + Some("provider_credentials" | "device_profile") + )) + }); + for tombstone in tombstones { + if let Some(object) = tombstone.as_object_mut() { + // Bundle tombstones are portable deletion markers, not cloud + // revision assertions from the source device. + object.insert("baseRevision".into(), json!(0)); + } + } + } + let documents_bytes = serde_json::to_vec(&root).map_err(|_| BundleError::InvalidSchema)?; + let documents_digest = crypto::sha256_hex(&documents_bytes); + let bundle_id = Uuid::new_v4().to_string(); + let mode = options.mode; + let encrypted_credentials = matches!( + options.credential_policy, + CredentialPolicy::PasswordEncrypted + ); + let credentials_bytes = if encrypted_credentials { + let password = options + .password + .as_ref() + .ok_or(BundleError::PasswordRequired)?; + let payload = serde_json::to_vec(&CredentialPayload { + schema_version: BUNDLE_FORMAT_VERSION, + documents: &credential_documents, + }) + .map_err(|_| BundleError::InvalidSchema)?; + let aad = crypto::aad(&bundle_id, &documents_digest, CREDENTIALS_ENTRY); + Some(crypto::encrypt(password, &payload, &aad)?) + } else { + None + }; + let manifest = Manifest { + format: BUNDLE_FORMAT.into(), + format_version: BUNDLE_FORMAT_VERSION, + document_schema_version: BUNDLE_DOCUMENT_SCHEMA_VERSION, + bundle_id, + created_at: chrono::Utc::now().to_rfc3339(), + producer: Producer { + app_version: options.app_version.clone(), + }, + platform: PLATFORM.into(), + mode, + documents: EntryDigest { + entry: DOCUMENTS_ENTRY.into(), + bytes: documents_bytes.len(), + sha256: documents_digest, + }, + credentials: credentials_bytes.as_ref().map(|value| CredentialsManifest { + entry: CREDENTIALS_ENTRY.into(), + bytes: value.len(), + sha256: crypto::sha256_hex(value), + encryption: "password_argon2id_xchacha20poly1305_v1".into(), + kdf: "argon2id".into(), + aead: "xchacha20poly1305".into(), + }), + sections: section_counts(&root)?, + permissions_migrated: false, + device_profile_migrated: false, + }; + let manifest_bytes = serde_json::to_vec(&manifest).map_err(|_| BundleError::InvalidSchema)?; + let archive = write_entries(BundleEntries::new( + manifest_bytes, + documents_bytes, + credentials_bytes, + ))?; + let inspection = inspection(&manifest, &root, false); + Ok(BundleArtifact { + bytes: Zeroizing::new(archive), + inspection, + }) +} + +fn inspection(manifest: &Manifest, root: &Value, credentials_unlocked: bool) -> BundleInspection { + let (document_count, tombstone_count) = root + .as_object() + .map(|object| { + ( + object + .get("documents") + .and_then(Value::as_array) + .map_or(0, Vec::len), + object + .get("tombstones") + .and_then(Value::as_array) + .map_or(0, Vec::len), + ) + }) + .unwrap_or_default(); + BundleInspection { + format: manifest.format.clone(), + format_version: manifest.format_version, + document_schema_version: manifest.document_schema_version, + bundle_id: manifest.bundle_id.clone(), + created_at: manifest.created_at.clone(), + platform: manifest.platform.clone(), + mode: manifest.mode, + document_count, + tombstone_count, + section_counts: manifest.sections.clone(), + credentials_present: manifest.credentials.is_some(), + credentials_encrypted: manifest.credentials.is_some(), + credentials_unlocked, + permissions_migrated: manifest.permissions_migrated, + device_profile_migrated: manifest.device_profile_migrated, + } +} + +fn decode_manifest(entries: &BTreeMap>) -> BundleResult { + let bytes = entries + .get(MANIFEST_ENTRY) + .ok_or(BundleError::MalformedContainer)?; + let manifest: Manifest = + serde_json::from_slice(bytes).map_err(|_| BundleError::InvalidSchema)?; + if manifest.format != BUNDLE_FORMAT + || manifest.format_version != BUNDLE_FORMAT_VERSION + || manifest.document_schema_version != BUNDLE_DOCUMENT_SCHEMA_VERSION + || manifest.platform != PLATFORM + || manifest.permissions_migrated + || manifest.device_profile_migrated + || manifest.documents.entry != DOCUMENTS_ENTRY + { + return Err(BundleError::UnsupportedVersion); + } + if manifest + .credentials + .as_ref() + .is_some_and(|_| matches!(manifest.mode, BundleMode::ShareRedacted)) + { + return Err(BundleError::InvalidSchema); + } + if manifest.credentials.as_ref().is_some_and(|credential| { + credential.entry != CREDENTIALS_ENTRY + || credential.encryption != "password_argon2id_xchacha20poly1305_v1" + || credential.kdf != "argon2id" + || credential.aead != "xchacha20poly1305" + }) { + return Err(BundleError::UnsupportedVersion); + } + Ok(manifest) +} + +fn validate_document_entry( + manifest: &Manifest, + entries: &BTreeMap>, +) -> BundleResult { + let documents = entries + .get(&manifest.documents.entry) + .ok_or(BundleError::MalformedContainer)?; + if documents.len() != manifest.documents.bytes + || crypto::sha256_hex(documents) != manifest.documents.sha256 + { + return Err(BundleError::DigestMismatch); + } + let root: Value = serde_json::from_slice(documents).map_err(|_| BundleError::InvalidSchema)?; + if root.get("schemaVersion").and_then(Value::as_u64) + != Some(BUNDLE_DOCUMENT_SCHEMA_VERSION as u64) + { + return Err(BundleError::UnsupportedVersion); + } + if root.get("sourceDevice").is_none() + || !root.get("documents").is_some_and(Value::is_array) + || !root.get("tombstones").is_some_and(Value::is_array) + { + return Err(BundleError::InvalidSchema); + } + if let Some(credentials) = &manifest.credentials { + let bytes = entries + .get(&credentials.entry) + .ok_or(BundleError::MalformedContainer)?; + if bytes.len() != credentials.bytes || crypto::sha256_hex(bytes) != credentials.sha256 { + return Err(BundleError::DigestMismatch); + } + } else if entries.contains_key(CREDENTIALS_ENTRY) { + return Err(BundleError::MalformedContainer); + } + Ok(root) +} + +fn section_counts(root: &Value) -> BundleResult { + let mut counts = SectionCounts::default(); + for document in root + .get("documents") + .and_then(Value::as_array) + .ok_or(BundleError::InvalidSchema)? + { + match document.get("kind").and_then(Value::as_str) { + Some("preferences") => counts.preferences += 1, + Some("ui_preferences") => counts.ui_preferences += 1, + Some("channels") => counts.channels += 1, + Some("dictionary") => counts.dictionary += 1, + Some("vocabulary_presets") => counts.vocabulary_presets += 1, + Some("corrections") => counts.corrections += 1, + Some("style_packs") => counts.style_packs += 1, + Some("history") => counts.history += 1, + Some("activity") => counts.activity += 1, + Some("provider_credentials") | Some("device_profile") => { + return Err(BundleError::InvalidSchema) + } + None => return Err(BundleError::InvalidSchema), + Some(_) => return Err(BundleError::UnsupportedVersion), + } + } + Ok(counts) +} + +fn read_entries(bytes: &[u8]) -> BundleResult>> { + if bytes.is_empty() || bytes.len() > MAX_BUNDLE_COMPRESSED_BYTES { + return Err(BundleError::TooLarge); + } + let mut archive = + zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| BundleError::MalformedContainer)?; + if archive.len() == 0 || archive.len() > MAX_BUNDLE_ENTRIES { + return Err(BundleError::TooLarge); + } + let mut entries = BTreeMap::new(); + let mut total = 0usize; + for index in 0..archive.len() { + let file = archive + .by_index(index) + .map_err(|_| BundleError::MalformedContainer)?; + let name = file.name().to_owned(); + if file.is_dir() + || file.encrypted() + || !safe_entry_name(&name) + || entries.contains_key(&name) + || !matches!( + name.as_str(), + MANIFEST_ENTRY | DOCUMENTS_ENTRY | CREDENTIALS_ENTRY + ) + { + return Err(BundleError::MalformedContainer); + } + let uncompressed = usize::try_from(file.size()).map_err(|_| BundleError::TooLarge)?; + let compressed = file.compressed_size(); + if uncompressed > MAX_BUNDLE_ENTRY_BYTES + || compressed == 0 && uncompressed > 0 + || compressed > 0 + && (uncompressed as u64) > compressed.saturating_mul(MAX_COMPRESSION_RATIO) + { + return Err(BundleError::TooLarge); + } + total = total + .checked_add(uncompressed) + .ok_or(BundleError::TooLarge)?; + if total > MAX_BUNDLE_UNCOMPRESSED_BYTES { + return Err(BundleError::TooLarge); + } + let mut content = Vec::with_capacity(uncompressed.min(64 * 1024)); + file.take((MAX_BUNDLE_ENTRY_BYTES + 1) as u64) + .read_to_end(&mut content) + .map_err(|_| BundleError::MalformedContainer)?; + if content.len() != uncompressed { + return Err(BundleError::MalformedContainer); + } + entries.insert(name, content); + } + if !entries.contains_key(MANIFEST_ENTRY) || !entries.contains_key(DOCUMENTS_ENTRY) { + return Err(BundleError::MalformedContainer); + } + Ok(entries) +} + +fn safe_entry_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('/') + && !name.starts_with('\\') + && !name.contains('\\') + && !name + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + && !name.chars().any(|character| character.is_control()) +} + +fn write_entries(entries: BundleEntries) -> BundleResult> { + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, bytes) in [ + (MANIFEST_ENTRY, entries.manifest), + (DOCUMENTS_ENTRY, entries.documents), + ] { + writer + .start_file(name, options) + .map_err(|_| BundleError::MalformedContainer)?; + writer + .write_all(&bytes) + .map_err(|_| BundleError::MalformedContainer)?; + } + if let Some(bytes) = entries.credentials { + writer + .start_file(CREDENTIALS_ENTRY, options) + .map_err(|_| BundleError::MalformedContainer)?; + writer + .write_all(&bytes) + .map_err(|_| BundleError::MalformedContainer)?; + } + writer + .finish() + .map(|cursor| cursor.into_inner()) + .map_err(|_| BundleError::MalformedContainer) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cloud_sync_e2ee_documents::parse_sync_documents; + + fn documents() -> ValidatedSyncDocuments { + let value = json!({ + "schemaVersion": 1, + "exportedAt": "2026-10-14T00:00:00Z", + "sourceDevice": {"id":"device","os":"android","arch":"arm64","appVersion":"1"}, + "documents": [ + {"id":"themeMode","kind":"preferences","schemaVersion":1,"value":"dark"}, + {"id":"llm:main","kind":"channels","schemaVersion":1,"value":{"id":"main","namespace":"llm","providerType":"requesty","name":"Main","enabled":true,"order":0,"active":true}}, + {"id":"llm:main","kind":"provider_credentials","schemaVersion":1,"value":{"channelId":"main","namespace":"llm","accounts":{"ark.api_key":"secret"}}} + ], + "tombstones": [] + }); + let bytes = serde_json::to_vec(&value).unwrap(); + parse_sync_documents(&bytes, Revision::new(0)).unwrap() + } + + #[test] + fn share_bundle_omits_device_and_credentials() { + let artifact = export_validated_bundle( + &documents(), + BundleExportOptions { + app_version: "2".into(), + ..BundleExportOptions::default() + }, + ) + .unwrap(); + let inspection = inspect_bundle(&artifact.bytes).unwrap(); + assert_eq!(inspection.credentials_present, false); + assert_eq!(inspection.device_profile_migrated, false); + assert_eq!(inspection.section_counts.channels, 1); + } + + #[test] + fn password_bundle_round_trips_credentials() { + let password = BundlePassword::new("Strong migration password 9").unwrap(); + let artifact = export_validated_bundle( + &documents(), + BundleExportOptions { + mode: BundleMode::PersonalMigration, + credential_policy: CredentialPolicy::PasswordEncrypted, + password: Some(password), + app_version: "2".into(), + portable_android_preferences: None, + }, + ) + .unwrap(); + assert!(inspect_bundle(&artifact.bytes).unwrap().credentials_present); + let wrong = BundlePassword::new("wrong password").unwrap(); + assert!(matches!( + decode_bundle(&artifact.bytes, Some(&wrong), Revision::new(0)), + Err(BundleError::InvalidPassword) + )); + let right = BundlePassword::new("Strong migration password 9").unwrap(); + let decoded = decode_bundle(&artifact.bytes, Some(&right), Revision::new(0)).unwrap(); + assert!(decoded.inspection.credentials_unlocked); + assert_eq!(decoded.documents.documents().documents.len(), 3); + } + + #[test] + fn rejects_path_traversal_entry() { + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + writer + .start_file("../manifest.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"{}").unwrap(); + let bytes = writer.finish().unwrap().into_inner(); + assert!(matches!( + read_entries(&bytes), + Err(BundleError::MalformedContainer) + )); + } +} diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs new file mode 100644 index 000000000..3b2caf162 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs @@ -0,0 +1,130 @@ +use std::fmt; + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + XChaCha20Poly1305, XNonce, +}; +use getrandom::fill; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use super::types::{BundleError, BundlePassword, BundleResult}; + +const ENVELOPE_VERSION: u8 = 1; +const SALT_BYTES: usize = 16; +const NONCE_BYTES: usize = 24; +const KEY_BYTES: usize = 32; +const ARGON_MEMORY_KIB: u32 = 65_536; +const ARGON_TIME: u32 = 3; +const ARGON_LANES: u32 = 4; +const HEADER_BYTES: usize = 1 + SALT_BYTES + NONCE_BYTES; + +#[derive(Clone)] +struct DerivedKey(Zeroizing<[u8; KEY_BYTES]>); + +impl fmt::Debug for DerivedKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DerivedKey([REDACTED])") + } +} + +fn derive_key(password: &BundlePassword, salt: &[u8; SALT_BYTES]) -> BundleResult { + let params = Params::new(ARGON_MEMORY_KIB, ARGON_TIME, ARGON_LANES, Some(KEY_BYTES)) + .map_err(|_| BundleError::InvalidPassword)?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Zeroizing::new([0u8; KEY_BYTES]); + argon + .hash_password_into(password.expose(), salt, key.as_mut()) + .map_err(|_| BundleError::InvalidPassword)?; + Ok(DerivedKey(key)) +} + +pub(crate) fn encrypt( + password: &BundlePassword, + plaintext: &[u8], + aad: &[u8], +) -> BundleResult> { + if plaintext.len() > super::types::MAX_CREDENTIAL_BYTES { + return Err(BundleError::TooLarge); + } + let mut salt = [0u8; SALT_BYTES]; + let mut nonce = [0u8; NONCE_BYTES]; + fill(&mut salt).map_err(|_| BundleError::InvalidPassword)?; + fill(&mut nonce).map_err(|_| BundleError::InvalidPassword)?; + let key = derive_key(password, &salt)?; + let cipher = XChaCha20Poly1305::new((&*key.0).into()); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| BundleError::InvalidPassword)?; + let mut result = Vec::with_capacity(HEADER_BYTES + ciphertext.len()); + result.push(ENVELOPE_VERSION); + result.extend_from_slice(&salt); + result.extend_from_slice(&nonce); + result.extend_from_slice(&ciphertext); + Ok(result) +} + +pub(crate) fn decrypt( + password: &BundlePassword, + envelope: &[u8], + aad: &[u8], +) -> BundleResult>> { + if envelope.len() < HEADER_BYTES + 16 + || envelope.len() > super::types::MAX_CREDENTIAL_BYTES + HEADER_BYTES + 16 + { + return Err(BundleError::InvalidPassword); + } + if envelope[0] != ENVELOPE_VERSION { + return Err(BundleError::UnsupportedVersion); + } + let salt: &[u8; SALT_BYTES] = envelope[1..1 + SALT_BYTES] + .try_into() + .map_err(|_| BundleError::InvalidPassword)?; + let nonce: &[u8; NONCE_BYTES] = envelope[1 + SALT_BYTES..HEADER_BYTES] + .try_into() + .map_err(|_| BundleError::InvalidPassword)?; + let key = derive_key(password, salt)?; + let cipher = XChaCha20Poly1305::new((&*key.0).into()); + let plaintext = cipher + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: &envelope[HEADER_BYTES..], + aad, + }, + ) + .map_err(|_| BundleError::InvalidPassword)?; + if plaintext.len() > super::types::MAX_CREDENTIAL_BYTES { + return Err(BundleError::TooLarge); + } + Ok(Zeroizing::new(plaintext)) +} + +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(64); + for byte in digest { + use std::fmt::Write; + let _ = write!(output, "{byte:02x}"); + } + output +} + +pub(crate) fn aad(bundle_id: &str, documents_digest: &str, entry: &str) -> Vec { + format!( + "openless-local-migration-bundle\0{}\0{}\0{}\0{}\0{}", + super::types::BUNDLE_FORMAT, + super::types::BUNDLE_FORMAT_VERSION, + bundle_id, + documents_digest, + entry, + ) + .into_bytes() +} diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs new file mode 100644 index 000000000..36367ce50 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs @@ -0,0 +1,10 @@ +mod container; +mod crypto; +mod types; + +pub use container::{decode_bundle, export_bundle, export_validated_bundle, inspect_bundle}; +pub use types::{ + BundleArtifact, BundleError, BundleExportOptions, BundleImportOptions, BundleInspection, + BundleMergePreview, BundleMode, BundlePassword, BundleResult, CredentialPolicy, DecodedBundle, + RestoreMode, SectionCounts, BUNDLE_FORMAT, BUNDLE_FORMAT_VERSION, +}; diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs new file mode 100644 index 000000000..eb4df7a3e --- /dev/null +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs @@ -0,0 +1,366 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::cloud_sync_e2ee_documents::{DocumentError, ValidatedSyncDocuments}; + +pub const BUNDLE_FORMAT: &str = "openless.android.migration-bundle"; +pub const BUNDLE_FORMAT_VERSION: u32 = 1; +pub const BUNDLE_DOCUMENT_SCHEMA_VERSION: u32 = 1; +pub const MAX_BUNDLE_COMPRESSED_BYTES: usize = 32 * 1024 * 1024; +pub const MAX_BUNDLE_UNCOMPRESSED_BYTES: usize = 64 * 1024 * 1024; +pub const MAX_BUNDLE_ENTRY_BYTES: usize = 32 * 1024 * 1024; +pub const MAX_BUNDLE_ENTRIES: usize = 128; +pub const MAX_PASSWORD_BYTES: usize = 4096; +pub const MAX_CREDENTIAL_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BundleMode { + ShareRedacted, + PersonalMigration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialPolicy { + Omit, + PasswordEncrypted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RestoreMode { + Merge, + Replace, +} + +#[derive(Clone)] +pub struct BundlePassword(Zeroizing>); + +impl BundlePassword { + pub fn new(password: impl AsRef<[u8]>) -> BundleResult { + let bytes = password.as_ref(); + if bytes.is_empty() || bytes.len() > MAX_PASSWORD_BYTES { + return Err(BundleError::InvalidPassword); + } + Ok(Self(Zeroizing::new(bytes.to_vec()))) + } + + pub(crate) fn expose(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for BundlePassword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("BundlePassword([REDACTED])") + } +} + +impl Drop for BundlePassword { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Debug, Clone)] +pub struct BundleExportOptions { + pub mode: BundleMode, + pub credential_policy: CredentialPolicy, + pub password: Option, + pub app_version: String, + /// Android-local, non-secret preferences that are outside Core repositories. + /// The value is validated against the migration allowlist before it enters the bundle. + pub portable_android_preferences: Option, +} + +impl Default for BundleExportOptions { + fn default() -> Self { + Self { + mode: BundleMode::ShareRedacted, + credential_policy: CredentialPolicy::Omit, + password: None, + app_version: "unknown".into(), + portable_android_preferences: None, + } + } +} + +impl BundleExportOptions { + pub(crate) fn validate(&self) -> BundleResult<()> { + if self.app_version.is_empty() || self.app_version.len() > 128 { + return Err(BundleError::InvalidOptions); + } + if matches!(self.mode, BundleMode::ShareRedacted) + && !matches!(self.credential_policy, CredentialPolicy::Omit) + { + return Err(BundleError::InvalidOptions); + } + if matches!(self.credential_policy, CredentialPolicy::PasswordEncrypted) + && self.password.is_none() + { + return Err(BundleError::PasswordRequired); + } + if matches!(self.mode, BundleMode::ShareRedacted) && self.password.is_some() { + return Err(BundleError::InvalidOptions); + } + if let Some(value) = &self.portable_android_preferences { + validate_portable_android_preferences(value)?; + } + Ok(()) + } +} + +const PORTABLE_ANDROID_PREFERENCE_KEYS: &[&str] = &[ + "schemaVersion", + "learningPrivacyVersion", + "preferences", + "strokeFrequency", + "pinyinLearnedPhrases", +]; +const PORTABLE_ANDROID_SETTINGS_KEYS: &[&str] = &[ + "locale", + "themeMode", + "hapticEnabled", + "hapticAmplitude", + "hapticDurationMs", + "englishSuggestionsEnabled", + "debugFeaturesEnabled", +]; + +pub(crate) fn validate_portable_android_preferences(value: &serde_json::Value) -> BundleResult<()> { + let object = value.as_object().ok_or(BundleError::InvalidSchema)?; + if object + .get("learningPrivacyVersion") + .and_then(serde_json::Value::as_u64) + != Some(1) + { + return Err(BundleError::InvalidSchema); + } + if object + .keys() + .any(|key| !PORTABLE_ANDROID_PREFERENCE_KEYS.contains(&key.as_str())) + || object + .get("schemaVersion") + .and_then(serde_json::Value::as_u64) + != Some(1) + { + return Err(BundleError::InvalidSchema); + } + for key in ["preferences", "strokeFrequency", "pinyinLearnedPhrases"] { + if !object.contains_key(key) { + return Err(BundleError::InvalidSchema); + } + } + let preferences = object + .get("preferences") + .ok_or(BundleError::InvalidSchema)?; + let preferences = preferences.as_object().ok_or(BundleError::InvalidSchema)?; + if let Some(locale) = preferences + .get("locale") + .and_then(serde_json::Value::as_str) + { + if !matches!( + locale, + "system" | "zh-CN" | "zh-TW" | "en" | "ja" | "ko" | "es" | "fr" | "de" + ) { + return Err(BundleError::InvalidSchema); + } + } + if let Some(theme) = preferences + .get("themeMode") + .and_then(serde_json::Value::as_str) + { + if !matches!(theme, "system" | "light" | "dark") { + return Err(BundleError::InvalidSchema); + } + } + if preferences + .keys() + .any(|key| !PORTABLE_ANDROID_SETTINGS_KEYS.contains(&key.as_str())) + { + return Err(BundleError::InvalidSchema); + } + if preferences + .get("locale") + .is_some_and(|value| !value.is_string()) + || preferences + .get("themeMode") + .is_some_and(|value| !value.is_string()) + || preferences + .get("hapticEnabled") + .is_some_and(|value| !value.is_boolean()) + || preferences + .get("englishSuggestionsEnabled") + .is_some_and(|value| !value.is_boolean()) + || preferences + .get("debugFeaturesEnabled") + .is_some_and(|value| !value.is_boolean()) + || preferences.get("hapticAmplitude").is_some_and(|value| { + value + .as_u64() + .is_none_or(|value| !(1..=255).contains(&value)) + }) + || preferences.get("hapticDurationMs").is_some_and(|value| { + value + .as_u64() + .is_none_or(|value| !(1..=1000).contains(&value)) + }) + { + return Err(BundleError::InvalidSchema); + } + for key in ["strokeFrequency", "pinyinLearnedPhrases"] { + if let Some(entries) = object.get(key) { + let entries = entries.as_object().ok_or(BundleError::InvalidSchema)?; + if entries.len() > 20_000 + || entries.iter().any(|(entry, value)| { + entry.is_empty() || entry.len() > 256 || !value.is_string() + }) + { + return Err(BundleError::InvalidSchema); + } + } + } + if serde_json::to_vec(value) + .map_err(|_| BundleError::InvalidSchema)? + .len() + > 4 * 1024 * 1024 + { + return Err(BundleError::TooLarge); + } + Ok(()) +} + +#[derive(Debug)] +pub struct BundleImportOptions { + pub mode: RestoreMode, + pub password: Option, +} + +impl Default for BundleImportOptions { + fn default() -> Self { + Self { + mode: RestoreMode::Merge, + password: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BundleInspection { + pub format: String, + pub format_version: u32, + pub document_schema_version: u32, + pub bundle_id: String, + pub created_at: String, + pub platform: String, + pub mode: BundleMode, + pub document_count: usize, + pub tombstone_count: usize, + pub section_counts: SectionCounts, + pub credentials_present: bool, + pub credentials_encrypted: bool, + pub credentials_unlocked: bool, + pub permissions_migrated: bool, + pub device_profile_migrated: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SectionCounts { + pub preferences: usize, + pub ui_preferences: usize, + pub channels: usize, + pub dictionary: usize, + pub vocabulary_presets: usize, + pub corrections: usize, + pub style_packs: usize, + pub history: usize, + pub activity: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BundleMergePreview { + pub inspection: BundleInspection, + pub observed_generation: crate::cloud_sync_e2ee_documents::Revision, + pub conflicts: Vec, +} + +#[derive(Debug)] +pub struct BundleArtifact { + pub bytes: Zeroizing>, + pub inspection: BundleInspection, +} + +#[derive(Debug)] +pub struct DecodedBundle { + pub inspection: BundleInspection, + pub documents: ValidatedSyncDocuments, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum BundleError { + #[error("migration_bundle_invalid_options")] + InvalidOptions, + #[error("migration_bundle_unsupported_version")] + UnsupportedVersion, + #[error("migration_bundle_malformed_container")] + MalformedContainer, + #[error("migration_bundle_too_large")] + TooLarge, + #[error("migration_bundle_digest_mismatch")] + DigestMismatch, + #[error("migration_bundle_password_required")] + PasswordRequired, + #[error("migration_bundle_invalid_password")] + InvalidPassword, + #[error("migration_bundle_invalid_schema")] + InvalidSchema, + #[error("migration_bundle_resource_invalid")] + InvalidResource, + #[error("migration_bundle_backup_failed")] + BackupFailed, + #[error("migration_bundle_stale_preview")] + StalePreview, + #[error("migration_bundle_restore_rolled_back")] + RestoreRolledBack, + #[error("migration_bundle_recovery_required")] + RecoveryRequired, + #[error("migration_bundle_document_error")] + Document(DocumentError), +} + +impl From for BundleError { + fn from(value: DocumentError) -> Self { + match value { + DocumentError::PayloadTooLarge => Self::TooLarge, + DocumentError::Unsupported => Self::UnsupportedVersion, + DocumentError::RestoreRolledBack => Self::RestoreRolledBack, + DocumentError::RecoveryRequired => Self::RecoveryRequired, + DocumentError::StalePreview => Self::StalePreview, + other => Self::Document(other), + } + } +} + +pub type BundleResult = Result; + +pub(crate) struct BundleEntries { + pub(crate) manifest: Vec, + pub(crate) documents: Vec, + pub(crate) credentials: Option>, +} + +impl BundleEntries { + pub(crate) fn new(manifest: Vec, documents: Vec, credentials: Option>) -> Self { + Self { + manifest, + documents, + credentials, + } + } +} diff --git a/openless-all/app/crates/openless-core/tests/local_bundle_contract.rs b/openless-all/app/crates/openless-core/tests/local_bundle_contract.rs new file mode 100644 index 000000000..6c8bb104a --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/local_bundle_contract.rs @@ -0,0 +1,156 @@ +use std::io::{Cursor, Write}; + +use openless_core::cloud_sync_e2ee_documents::{parse_sync_documents, Revision}; +use openless_core::local_migration_bundle::{ + decode_bundle, export_validated_bundle, inspect_bundle, BundleError, BundleExportOptions, + BundleMode, BundlePassword, CredentialPolicy, +}; +use serde_json::json; + +fn fixture() -> openless_core::cloud_sync_e2ee_documents::ValidatedSyncDocuments { + let value = json!({ + "schemaVersion": 1, + "exportedAt": "2026-10-14T00:00:00Z", + "sourceDevice": {"id":"source","os":"android","arch":"arm64","appVersion":"1"}, + "documents": [ + {"id":"themeMode","kind":"preferences","schemaVersion":1,"value":"dark"}, + {"id":"llm:main","kind":"channels","schemaVersion":1,"value":{"id":"main","namespace":"llm","providerType":"requesty","name":"Main","enabled":true,"order":0,"active":true}}, + {"id":"asr:asr-main","kind":"channels","schemaVersion":1,"value":{"id":"asr-main","namespace":"asr","providerType":"requesty","name":"Speech","enabled":true,"order":1,"active":false}}, + {"id":"omni:omni-provider","kind":"channels","schemaVersion":1,"value":{"id":"omni-provider","namespace":"omni","providerType":"omni-provider","name":"Omni","enabled":true,"order":2,"active":false}}, + {"id":"llm:main","kind":"provider_credentials","schemaVersion":1,"value":{"channelId":"main","namespace":"llm","accounts":{"ark.api_key":"secret","ark.endpoint":"https://example.test","ark.model_id":"model"}}}, + {"id":"asr:asr-main","kind":"provider_credentials","schemaVersion":1,"value":{"channelId":"asr-main","namespace":"asr","accounts":{"asr.api_key":"asr-secret","asr.endpoint":"https://asr.example.test","asr.model":"small"}}}, + {"id":"omni:omni-provider","kind":"provider_credentials","schemaVersion":1,"value":{"channelId":"omni-provider","namespace":"omni","accounts":{"omni.api_key":"omni-secret","omni.endpoint":"https://omni.example.test","omni.model":"vision"}}} + ], + "tombstones": [] + }); + parse_sync_documents(&serde_json::to_vec(&value).unwrap(), Revision::new(0)).unwrap() +} + +#[test] +fn share_contract_excludes_credentials_and_device_profile() { + let artifact = export_validated_bundle(&fixture(), BundleExportOptions::default()).unwrap(); + let decoded = decode_bundle(&artifact.bytes, None, Revision::new(0)).unwrap(); + assert!(!decoded.inspection.credentials_present); + assert!(decoded + .documents + .documents() + .documents + .iter() + .all(|document| { + document.kind + != openless_core::cloud_sync_e2ee_documents::DocumentKind::ProviderCredentials + })); + assert!(decoded + .documents + .documents() + .documents + .iter() + .all(|document| { + document.kind != openless_core::cloud_sync_e2ee_documents::DocumentKind::DeviceProfile + })); + let channel = decoded + .documents + .documents() + .documents + .iter() + .find(|document| { + document.kind == openless_core::cloud_sync_e2ee_documents::DocumentKind::Channels + && document.value["namespace"] == "llm" + }) + .unwrap(); + assert_eq!( + channel.value["configuration"]["ark.endpoint"], + "https://example.test" + ); + assert!(channel.value["configuration"].get("ark.api_key").is_none()); +} + +#[test] +fn personal_contract_requires_password_and_rejects_wrong_password() { + let password = BundlePassword::new("correct horse battery staple").unwrap(); + let artifact = export_validated_bundle( + &fixture(), + BundleExportOptions { + mode: BundleMode::PersonalMigration, + credential_policy: CredentialPolicy::PasswordEncrypted, + password: Some(password), + app_version: "test".into(), + portable_android_preferences: None, + }, + ) + .unwrap(); + assert!(matches!( + decode_bundle( + &artifact.bytes, + Some(&BundlePassword::new("wrong").unwrap()), + Revision::new(0) + ), + Err(BundleError::InvalidPassword) + )); + let decoded = decode_bundle( + &artifact.bytes, + Some(&BundlePassword::new("correct horse battery staple").unwrap()), + Revision::new(0), + ) + .unwrap(); + assert!(decoded.inspection.credentials_unlocked); + assert_eq!(decoded.documents.documents().documents.len(), 7); +} + +#[test] +fn personal_without_credentials_is_importable_as_redacted_documents() { + let artifact = export_validated_bundle( + &fixture(), + BundleExportOptions { + mode: BundleMode::PersonalMigration, + credential_policy: CredentialPolicy::Omit, + password: None, + app_version: "test".into(), + portable_android_preferences: None, + }, + ) + .unwrap(); + let decoded = decode_bundle(&artifact.bytes, None, Revision::new(0)).unwrap(); + assert!(!decoded.inspection.credentials_present); + assert!(decoded + .documents + .documents() + .documents + .iter() + .all(|document| { + document.kind + != openless_core::cloud_sync_e2ee_documents::DocumentKind::ProviderCredentials + })); +} + +#[test] +fn portable_preferences_require_all_sections() { + let result = export_validated_bundle( + &fixture(), + BundleExportOptions { + portable_android_preferences: Some(json!({ + "schemaVersion": 1, + "learningPrivacyVersion": 1, + })), + ..BundleExportOptions::default() + }, + ); + assert!(matches!(result, Err(BundleError::InvalidSchema))); +} + +#[test] +fn container_rejects_unknown_entries() { + let options = zip::write::SimpleFileOptions::default(); + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer.start_file("manifest.json", options).unwrap(); + writer.write_all(b"{}").unwrap(); + writer.start_file("documents.json", options).unwrap(); + writer.write_all(b"{}").unwrap(); + writer.start_file("hidden.bin", options).unwrap(); + writer.write_all(b"unexpected").unwrap(); + let bytes = writer.finish().unwrap().into_inner(); + assert!(matches!( + inspect_bundle(&bytes), + Err(BundleError::MalformedContainer) + )); +} diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index b0295ae8f..403b6ca54 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -59,6 +59,7 @@ const KOTLIN_FILES = [ 'OpenLessUpdateInstaller.kt', 'OpenLessContentReader.kt', 'OpenLessContentWriter.kt', + 'OpenLessMigrationBundlePolicy.kt', 'OpenLessImeService.kt', 'PendingEditReplacement.kt', 'ImeLearningPolicy.kt', @@ -90,6 +91,7 @@ const KOTLIN_TEST_FILES = [ 'ImePrivacyPolicyTest.kt', 'LatinInputModeTest.kt', 'OpenLessContentReaderTest.kt', + 'OpenLessMigrationBundleContractTest.kt', 'OpenLessCredentialCipherTest.kt', 'OpenLessShizukuBridgeTest.kt', 'OpenLessAccessibilityResultTest.kt', @@ -102,6 +104,7 @@ const KOTLIN_TEST_FILES = [ const KOTLIN_ANDROID_TEST_FILES = [ 'OpenLessCredentialVaultInstrumentedTest.kt', 'ImeLearningMigrationTest.kt', + 'OpenLessMigrationBundleInstrumentedTest.kt', ]; const XML_FILES = [ diff --git a/openless-all/app/scripts/local-bundle-contract.test.mjs b/openless-all/app/scripts/local-bundle-contract.test.mjs new file mode 100644 index 000000000..6f7278572 --- /dev/null +++ b/openless-all/app/scripts/local-bundle-contract.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const appRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const read = (path) => readFileSync(join(appRoot, path), 'utf8'); +const activity = read('android/kotlin/OpenLessKeyboardSettingsActivity.kt'); +const native = read('android/kotlin/OpenLessNative.kt'); +const policy = read('android/kotlin/OpenLessMigrationBundlePolicy.kt'); +const container = read('crates/openless-core/src/local_migration_bundle/container.rs'); +const bridge = read('src-tauri/src/android/native_bridge.rs'); +const scaffolding = read('scripts/copy-android-scaffolding.mjs'); + +assert.match(policy, /openless\.android\.migration-bundle/); +assert.match(activity, /nativeExportLocalMigrationBundle/); +assert.match(activity, /nativeInspectLocalMigrationBundle/); +assert.match(activity, /nativePreviewLocalMigrationBundle/); +assert.match(activity, /nativeImportLocalMigrationBundle/); +assert.match(activity, /launchLegacyImportPicker/); +assert.match(activity, /OpenLessSettingsExport\.import\(/); +assert.match(activity, /OpenLessContentWriter\.writeBytes/); +assert.doesNotMatch(activity, /pendingExportJson/); +assert.match(native, /nativeExportLocalMigrationBundle/); +assert.match(native, /nativePreviewLocalMigrationBundle/); +assert.match(native, /nativeImportLocalMigrationBundle/); +assert.match(container, /BundleMode::ShareRedacted/); +assert.match(container, /CredentialPolicy::PasswordEncrypted/); +assert.match(container, /file\.encrypted\(\)/); +assert.match(container, /safe_entry_name/); +assert.match(bridge, /publish_local_migration_state_changed/); +assert.match(scaffolding, /OpenLessMigrationBundlePolicy\.kt/); +assert.match(scaffolding, /OpenLessMigrationBundleContractTest\.kt/); +assert.match(scaffolding, /OpenLessMigrationBundleInstrumentedTest\.kt/); + +console.log('local migration bundle contract passed'); diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index c881f66e0..b1c28046a 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, OnceLock}; +use base64::Engine; use openless_core::{ BackendError, BackendErrorCode, DictationStartOptions, DictationStopOptions, OpenLessBackend, }; @@ -187,6 +188,232 @@ fn import_provider_settings_json(json: &str, selection: bool, credentials: bool) serde_json::json!({"applied":applied,"errors":errors}).to_string() } +fn local_bundle_error(error: impl std::fmt::Display) -> String { + serde_json::json!({"ok": false, "error": error.to_string()}).to_string() +} + +fn export_local_migration_bundle_json( + mode: &str, + include_credentials: bool, + password: &str, + android_preferences_json: &str, +) -> String { + let result = (|| -> Result { + let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; + let mode = match mode { + "share_redacted" => openless_core::local_migration_bundle::BundleMode::ShareRedacted, + "personal_migration" => { + openless_core::local_migration_bundle::BundleMode::PersonalMigration + } + _ => return Err("invalid migration bundle mode".into()), + }; + let (credential_policy, password) = if include_credentials { + let password = openless_core::local_migration_bundle::BundlePassword::new(password) + .map_err(|error| error.to_string())?; + ( + openless_core::local_migration_bundle::CredentialPolicy::PasswordEncrypted, + Some(password), + ) + } else { + ( + openless_core::local_migration_bundle::CredentialPolicy::Omit, + None, + ) + }; + let options = openless_core::local_migration_bundle::BundleExportOptions { + mode, + credential_policy, + password, + app_version: env!("CARGO_PKG_VERSION").into(), + portable_android_preferences: if android_preferences_json.is_empty() { + None + } else { + Some( + serde_json::from_str(android_preferences_json) + .map_err(|_| "invalid Android portable preferences")?, + ) + }, + }; + let artifact = + tauri::async_runtime::block_on(backend.export_local_migration_bundle(options)) + .map_err(|error| error.message)?; + Ok(serde_json::json!({ + "inspection": artifact.inspection, + "bundleBase64": base64::engine::general_purpose::STANDARD.encode(&artifact.bytes), + })) + })(); + match result { + Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), + Err(error) => local_bundle_error(error), + } +} + +fn inspect_local_migration_bundle_json(bundle_base64: &str) -> String { + let result = (|| -> Result { + let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(bundle_base64) + .map_err(|_| "invalid bundle encoding")?; + let inspection = backend + .inspect_local_migration_bundle(&bytes) + .map_err(|error| error.message)?; + serde_json::to_value(inspection).map_err(|_| "invalid bundle inspection".into()) + })(); + match result { + Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), + Err(error) => local_bundle_error(error), + } +} + +fn extract_local_migration_android_preferences_json( + bundle_base64: &str, + password: &str, +) -> String { + let result = (|| -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(bundle_base64) + .map_err(|_| "invalid bundle encoding")?; + let password = if password.is_empty() { + None + } else { + Some( + openless_core::local_migration_bundle::BundlePassword::new(password) + .map_err(|error| error.to_string())?, + ) + }; + let decoded = openless_core::local_migration_bundle::decode_bundle( + &bytes, + password.as_ref(), + openless_core::cloud_sync_e2ee_documents::Revision::new(0), + ) + .map_err(|error| error.to_string())?; + let preferences = decoded + .documents + .documents() + .documents + .iter() + .find(|document| { + document.kind + == openless_core::cloud_sync_e2ee_documents::DocumentKind::UiPreferences + && document.id == "androidPortableSettings" + }) + .map(|document| document.value.clone()) + .unwrap_or(serde_json::Value::Null); + Ok(serde_json::json!({"preferences": preferences})) + })(); + match result { + Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), + Err(error) => local_bundle_error(error), + } +} + +fn preview_local_migration_bundle_json( + bundle_base64: &str, + password: &str, + android_preferences_json: &str, +) -> String { + let result = (|| -> Result { + let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(bundle_base64) + .map_err(|_| "invalid bundle encoding")?; + let password = if password.is_empty() { + None + } else { + Some( + openless_core::local_migration_bundle::BundlePassword::new(password) + .map_err(|error| error.to_string())?, + ) + }; + let android_preferences = if android_preferences_json.is_empty() { + None + } else { + Some( + serde_json::from_str(android_preferences_json) + .map_err(|_| "invalid Android portable preferences")?, + ) + }; + let preview = tauri::async_runtime::block_on( + backend.preview_local_migration_bundle(&bytes, password, android_preferences), + ) + .map_err(|error| error.message)?; + serde_json::to_value(preview).map_err(|_| "invalid bundle preview".into()) + })(); + match result { + Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), + Err(error) => local_bundle_error(error), + } +} + +fn import_local_migration_bundle_json( + bundle_base64: &str, + password: &str, + mode: &str, + choices_json: &str, + expected_generation: &str, + android_preferences_json: &str, +) -> String { + let result = (|| -> Result { + let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(bundle_base64) + .map_err(|_| "invalid bundle encoding")?; + let password = if password.is_empty() { + None + } else { + Some( + openless_core::local_migration_bundle::BundlePassword::new(password) + .map_err(|error| error.to_string())?, + ) + }; + let restore_mode = match mode { + "merge" => openless_core::local_migration_bundle::RestoreMode::Merge, + "replace" => openless_core::local_migration_bundle::RestoreMode::Replace, + _ => return Err("invalid migration restore mode".into()), + }; + let choices: Vec = + serde_json::from_str(choices_json).map_err(|_| "invalid conflict choices")?; + let expected_generation = if expected_generation.is_empty() { + None + } else { + let value = expected_generation + .parse::() + .map_err(|_| "invalid expected generation")?; + if value.to_string() != expected_generation { + return Err("invalid expected generation".into()); + } + Some(openless_core::cloud_sync_e2ee_documents::Revision::new( + value, + )) + }; + let android_preferences = if android_preferences_json.is_empty() { + None + } else { + Some( + serde_json::from_str(android_preferences_json) + .map_err(|_| "invalid Android portable preferences")?, + ) + }; + let inspection = tauri::async_runtime::block_on(backend.import_local_migration_bundle( + &bytes, + password, + restore_mode, + &choices, + expected_generation, + android_preferences, + )) + .map_err(|error| error.message)?; + backend.publish_local_migration_state_changed(); + tauri::async_runtime::block_on(backend.refresh_and_publish_credentials()) + .map_err(|error| error.message)?; + serde_json::to_value(inspection).map_err(|_| "invalid bundle inspection".into()) + })(); + match result { + Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), + Err(error) => local_bundle_error(error), + } +} + pub fn register_android_backend(backend: Arc) { let _ = CORE_BACKEND.set(backend); } @@ -1415,6 +1642,138 @@ mod jni_exports { }; crate::android::jni::android::export_jstring(&mut env, &response) } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeExportLocalMigrationBundle( + env: *mut JNIEnv, + _class: JClass, + mode: jstring, + include_credentials: jboolean, + password: jstring, + android_preferences: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let mode = env.get_string(&JString::from_raw(mode)); + let password = env.get_string(&JString::from_raw(password)); + let android_preferences = env.get_string(&JString::from_raw(android_preferences)); + let response = match (mode, password, android_preferences) { + (Ok(mode), Ok(password), Ok(android_preferences)) => export_local_migration_bundle_json( + &mode.to_string_lossy(), + include_credentials != 0, + &password.to_string_lossy(), + &android_preferences.to_string_lossy(), + ), + _ => local_bundle_error("invalid JNI input"), + }; + crate::android::jni::android::export_jstring(&mut env, &response) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativePreviewLocalMigrationBundle( + env: *mut JNIEnv, + _class: JClass, + bundle_base64: jstring, + password: jstring, + android_preferences: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let values = ( + env.get_string(&JString::from_raw(bundle_base64)), + env.get_string(&JString::from_raw(password)), + env.get_string(&JString::from_raw(android_preferences)), + ); + let response = match values { + (Ok(bundle), Ok(password), Ok(android_preferences)) => preview_local_migration_bundle_json( + &bundle.to_string_lossy(), + &password.to_string_lossy(), + &android_preferences.to_string_lossy(), + ), + _ => local_bundle_error("invalid JNI input"), + }; + crate::android::jni::android::export_jstring(&mut env, &response) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeInspectLocalMigrationBundle( + env: *mut JNIEnv, + _class: JClass, + bundle_base64: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let response = match env.get_string(&JString::from_raw(bundle_base64)) { + Ok(bundle) => inspect_local_migration_bundle_json(&bundle.to_string_lossy()), + Err(_) => local_bundle_error("invalid JNI input"), + }; + crate::android::jni::android::export_jstring(&mut env, &response) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeExtractLocalMigrationAndroidPreferences( + env: *mut JNIEnv, + _class: JClass, + bundle_base64: jstring, + password: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let values = ( + env.get_string(&JString::from_raw(bundle_base64)), + env.get_string(&JString::from_raw(password)), + ); + let response = match values { + (Ok(bundle), Ok(password)) => extract_local_migration_android_preferences_json( + &bundle.to_string_lossy(), + &password.to_string_lossy(), + ), + _ => local_bundle_error("invalid JNI input"), + }; + crate::android::jni::android::export_jstring(&mut env, &response) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeImportLocalMigrationBundle( + env: *mut JNIEnv, + _class: JClass, + bundle_base64: jstring, + password: jstring, + mode: jstring, + choices_json: jstring, + expected_generation: jstring, + android_preferences: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let values = ( + env.get_string(&JString::from_raw(bundle_base64)), + env.get_string(&JString::from_raw(password)), + env.get_string(&JString::from_raw(mode)), + env.get_string(&JString::from_raw(choices_json)), + env.get_string(&JString::from_raw(expected_generation)), + env.get_string(&JString::from_raw(android_preferences)), + ); + let response = match values { + (Ok(bundle), Ok(password), Ok(mode), Ok(choices), Ok(expected_generation), Ok(android_preferences)) => { + import_local_migration_bundle_json( + &bundle.to_string_lossy(), + &password.to_string_lossy(), + &mode.to_string_lossy(), + &choices.to_string_lossy(), + &expected_generation.to_string_lossy(), + &android_preferences.to_string_lossy(), + ) + } + _ => local_bundle_error("invalid JNI input"), + }; + crate::android::jni::android::export_jstring(&mut env, &response) + } } #[cfg(test)] diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 9c4206c72..a7f909ae4 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -3118,6 +3118,7 @@ fn sync_channel( enabled: meta.enabled, order: meta.order.unwrap_or(u32::MAX), active: id == active, + configuration: BTreeMap::new(), } } From 68c63c17dc2e5303c59ca355566180b9847ff130 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 15 Oct 2026 04:04:07 +0800 Subject: [PATCH 03/15] feat(android): harden local migration restore paths Co-Authored-By: Claude Fable 5 --- .../app/crates/openless-core/src/api.rs | 1 + .../crates/openless-core/src/cloud_sync.rs | 61 +++++-- .../src/cloud_sync_e2ee_documents/merge.rs | 15 +- .../src/cloud_sync_transaction.rs | 3 + .../crates/openless-core/src/qa_service.rs | 157 ++++++++++-------- .../crates/openless-core/src/voice_session.rs | 48 ++++-- .../src-tauri/src/android/native_bridge.rs | 110 +++++++----- 7 files changed, 258 insertions(+), 137 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 953c397ca..6739fba53 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2769,6 +2769,7 @@ impl OpenLessBackend { marketplace, repositories.clone(), Arc::clone(&settings_write_gate), + encrypted_sync.is_none(), )) } else { None diff --git a/openless-all/app/crates/openless-core/src/cloud_sync.rs b/openless-all/app/crates/openless-core/src/cloud_sync.rs index 4a7a6c8f8..4ed8123b8 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync.rs @@ -59,6 +59,7 @@ pub(crate) struct CloudSyncService { marketplace: Arc, repositories: BackendRepositories, settings_gate: Arc>, + legacy_restore_allowed: bool, operation: tokio::sync::Mutex<()>, } @@ -67,11 +68,13 @@ impl CloudSyncService { marketplace: Arc, repositories: BackendRepositories, settings_gate: Arc>, + legacy_restore_allowed: bool, ) -> Self { Self { marketplace, repositories, settings_gate, + legacy_restore_allowed, operation: tokio::sync::Mutex::new(()), } } @@ -296,19 +299,28 @@ impl CloudSyncService { } fn restore_local(&self, payload: &CloudSyncPayload) -> Result<(), BackendError> { - // The legacy four-command protocol has no encrypted crash journal or Host-effect - // convergence. It must never bypass an installed E2EE repository write barrier. - if crate::cloud_sync_e2ee_store::gate::gate_for_path( - self.repositories.preferences.persistence_path(), - ) - .map_err(|error| BackendError::new(BackendErrorCode::InvalidState, error.to_string()))? - .is_some() - { + if !self.legacy_restore_allowed { return Err(BackendError::new( BackendErrorCode::Unsupported, "当前仓库暂不支持旧版云端恢复,请使用加密云同步的确认恢复流程。", )); } + let preferences_path = self + .repositories + .preferences + .persistence_path() + .to_path_buf(); + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &preferences_path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::Restore, + || self.restore_local_unchecked(payload), + ) + } + + fn restore_local_unchecked(&self, payload: &CloudSyncPayload) -> Result<(), BackendError> { + // The legacy protocol is still constrained by the same registered write barrier as + // encrypted sync and local migration. Validation and transaction logic below run inside + // the permit acquired by restore_local. validate_payload(payload).map_err(|_| invalid_remote())?; let mut next_packs = validated_native_packs(payload)?; let dictionary: Vec = payload @@ -658,7 +670,10 @@ mod encrypted_gate_tests { let _ = std::fs::remove_dir_all(&self.0); } } - fn service(repositories: BackendRepositories) -> CloudSyncService { + fn service( + repositories: BackendRepositories, + legacy_restore_allowed: bool, + ) -> CloudSyncService { let marketplace = MarketplaceService::new( crate::MarketplaceConfig::new("https://market.example.test").unwrap(), Arc::new(crate::InMemoryCredentialStore::default()), @@ -672,6 +687,7 @@ mod encrypted_gate_tests { Arc::new(marketplace), repositories, Arc::new(Mutex::new(())), + legacy_restore_allowed, ) } #[test] @@ -680,7 +696,7 @@ mod encrypted_gate_tests { let temp = Temp::new(); let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(&temp.0).unwrap(); let repositories = BackendRepositories::open(&temp.0).unwrap(); - let sync = service(repositories.clone()); + let sync = service(repositories.clone(), false); let mut payload = sync.capture(CloudSyncUiPreferences::default()).unwrap(); payload.dictionary.push(SyncDictionaryEntry { id: "legacy-extra".into(), @@ -710,11 +726,34 @@ mod encrypted_gate_tests { assert_eq!(gate.generation().unwrap(), generation); } } + #[test] + fn legacy_restore_commits_the_shared_gate_generation() { + let temp = Temp::new(); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(&temp.0).unwrap(); + let repositories = BackendRepositories::open(&temp.0).unwrap(); + let sync = service(repositories, true); + let mut payload = sync.capture(CloudSyncUiPreferences::default()).unwrap(); + payload.dictionary.push(SyncDictionaryEntry { + id: "legacy-extra".into(), + phrase: "generation bump".into(), + note: None, + enabled: true, + hits: 0, + created_at: "2026-09-26T00:00:00Z".into(), + }); + let generation = gate.generation().unwrap(); + sync.restore_local(&payload).unwrap(); + assert_eq!( + gate.generation().unwrap(), + generation.checked_next().unwrap() + ); + } + #[test] fn standalone_legacy_repositories_without_an_encrypted_gate_keep_their_existing_restore() { let temp = Temp::new(); let repositories = BackendRepositories::open(&temp.0).unwrap(); - let sync = service(repositories.clone()); + let sync = service(repositories.clone(), true); let mut payload = sync.capture(CloudSyncUiPreferences::default()).unwrap(); payload.dictionary.push(SyncDictionaryEntry { id: "legacy-extra".into(), diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs index 662246694..42776038a 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs @@ -137,13 +137,18 @@ impl MergePreview { let mut channel: ChannelRecord = serde_json::from_value(doc.value.clone()) .map_err(|_| DocumentError::InvalidDocument)?; if channel.namespace == *namespace { - channel.active = choice.as_deref() == Some(channel.id.as_str()); - if channel.active { - channel.enabled = true; + let desired_active = choice.as_deref() == Some(channel.id.as_str()); + if desired_active { found = true; } - doc.value = serde_json::to_value(channel) - .map_err(|_| DocumentError::InvalidDocument)?; + if channel.active != desired_active { + channel.active = desired_active; + if desired_active { + channel.enabled = true; + } + doc.value = serde_json::to_value(channel) + .map_err(|_| DocumentError::InvalidDocument)?; + } } } if !found { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs b/openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs index c369d7e83..a6dbc4b7e 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_transaction.rs @@ -85,6 +85,9 @@ impl RestoreTransaction { return Err(failure("云端恢复失败,本地数据已回滚。")); } } + for file in &self.files { + crate::cloud_sync_e2ee_store::gate::note_successful_write(&file.target); + } Ok(()) } } diff --git a/openless-all/app/crates/openless-core/src/qa_service.rs b/openless-all/app/crates/openless-core/src/qa_service.rs index 12fa6b05c..ae948b37c 100644 --- a/openless-all/app/crates/openless-core/src/qa_service.rs +++ b/openless-all/app/crates/openless-core/src/qa_service.rs @@ -59,6 +59,13 @@ pub(crate) struct QaPersistence { clock: Arc, } +struct CancelContext { + runtime_session_id: Option, + conversation_id: Option, + host_result: Result<(), BackendError>, + clear: bool, +} + impl QaPersistence { pub(crate) fn new( preferences: Arc, @@ -596,91 +603,109 @@ impl QaService { requested_session_id: Option, clear: bool, ) -> Result<(), BackendError> { + let hold = self.runtime_work.cleanup_hold(); + let context = match self.prepare_cancel(requested_session_id, clear) { + Ok(Some(context)) => context, + Ok(None) => { + hold.complete(); + return Ok(()); + } + Err(error) => { + hold.complete(); + return Err(error); + } + }; let service = self.clone(); self.runtime_work - .cleanup(Box::pin(async move { - service - .cancel_inner_owned(requested_session_id, clear) - .await - })) + .cleanup_with_hold( + hold, + Box::pin(async move { service.finish_cancel(context).await }), + ) .await } - async fn cancel_inner_owned( + fn prepare_cancel( &self, requested_session_id: Option, clear: bool, - ) -> Result<(), BackendError> { - let (runtime_session_id, conversation_id, host_result) = { - let _presentation = self - .presentation - .lock() - .expect("QA presentation lock poisoned"); - let (runtime_session_id, conversation_id, publish_cancelled) = { - let mut state = self.state.lock().expect("QA state lock poisoned"); - let active_session_id = state.snapshot.session_id; - if let Some(requested) = requested_session_id { - if Some(requested) != active_session_id { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "QA session is no longer active", - )); - } - } - if !clear - && matches!(state.snapshot.phase, QaPhase::Idle | QaPhase::Cancelled) - && active_session_id.is_none() - { - return Ok(()); - } - let publish_cancelled = - !matches!(state.snapshot.phase, QaPhase::Idle | QaPhase::Cancelled); - let runtime_session_id = matches!( - state.snapshot.phase, - QaPhase::Recording | QaPhase::Thinking | QaPhase::AwaitingApproval - ) - .then_some(active_session_id) - .flatten(); - if publish_cancelled { - state.snapshot.phase = QaPhase::Cancelled; + ) -> Result, BackendError> { + let _presentation = self + .presentation + .lock() + .expect("QA presentation lock poisoned"); + let (runtime_session_id, conversation_id, publish_cancelled) = { + let mut state = self.state.lock().expect("QA state lock poisoned"); + let active_session_id = state.snapshot.session_id; + if let Some(requested) = requested_session_id { + if Some(requested) != active_session_id { + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "QA session is no longer active", + )); } - state.snapshot.pending_approval_token = None; - state.snapshot.last_error = None; - let conversation_id = state.snapshot.conversation_id.take(); - (runtime_session_id, conversation_id, publish_cancelled) - }; - if let Some(session_id) = requested_session_id.or(runtime_session_id) { - self.voice_sessions.release(session_id); } + if !clear + && matches!(state.snapshot.phase, QaPhase::Idle | QaPhase::Cancelled) + && active_session_id.is_none() + { + return Ok(None); + } + let publish_cancelled = + !matches!(state.snapshot.phase, QaPhase::Idle | QaPhase::Cancelled); + let runtime_session_id = matches!( + state.snapshot.phase, + QaPhase::Recording | QaPhase::Thinking | QaPhase::AwaitingApproval + ) + .then_some(active_session_id) + .flatten(); if publish_cancelled { - self.publish_snapshot(QaStateKind::Cancelled, None); + state.snapshot.phase = QaPhase::Cancelled; } - // Closing state, events and Hide form one synchronous presentation - // transition. "Before await" alone is insufficient: another OS thread - // could otherwise open B between these locks and have A erase/hide it. - // Host actions remain outside the state lock so hosts can inspect QA. - let host_result = if clear { - { - let mut state = self.state.lock().expect("QA state lock poisoned"); - state.snapshot = QaSnapshot::default(); - } - self.publish_snapshot(QaStateKind::Idle, None); - self.host_actions.request(HostAction::HideQa) - } else { - Ok(()) - }; - (runtime_session_id, conversation_id, host_result) + state.snapshot.pending_approval_token = None; + state.snapshot.last_error = None; + let conversation_id = state.snapshot.conversation_id.take(); + (runtime_session_id, conversation_id, publish_cancelled) }; - let runtime_result = if let Some(session_id) = runtime_session_id { + if let Some(session_id) = requested_session_id.or(runtime_session_id) { + self.voice_sessions.release(session_id); + } + if publish_cancelled { + self.publish_snapshot(QaStateKind::Cancelled, None); + } + // Closing state, events and Hide form one synchronous presentation + // transition. "Before await" alone is insufficient: another OS thread + // could otherwise open B between these locks and have A erase/hide it. + // Host actions remain outside the state lock so hosts can inspect QA. + let host_result = if clear { + { + let mut state = self.state.lock().expect("QA state lock poisoned"); + state.snapshot = QaSnapshot::default(); + } + self.publish_snapshot(QaStateKind::Idle, None); + self.host_actions.request(HostAction::HideQa) + } else { + Ok(()) + }; + Ok(Some(CancelContext { + runtime_session_id, + conversation_id, + host_result, + clear, + })) + } + + async fn finish_cancel(&self, context: CancelContext) -> Result<(), BackendError> { + let runtime_result = if let Some(session_id) = context.runtime_session_id { self.runtime.cancel(session_id).await } else { Ok(()) }; - if clear { + if context.clear { // Only the captured conversation owner may lose its preview. - self.clear_edit_preview_best_effort(conversation_id).await; + self.clear_edit_preview_best_effort(context.conversation_id) + .await; } - host_result?; + context.host_result?; runtime_result } diff --git a/openless-all/app/crates/openless-core/src/voice_session.rs b/openless-all/app/crates/openless-core/src/voice_session.rs index 1a223d19f..b90b8a4f4 100644 --- a/openless-all/app/crates/openless-core/src/voice_session.rs +++ b/openless-all/app/crates/openless-core/src/voice_session.rs @@ -208,7 +208,7 @@ impl RuntimeActivityGate { } } - fn cleanup_hold(self: &Arc) -> RuntimeActivityHold { + pub(crate) fn cleanup_hold(self: &Arc) -> RuntimeActivityHold { let mut state = self.state.lock().expect("runtime activity lock poisoned"); state.active = state .active @@ -220,28 +220,16 @@ impl RuntimeActivityGate { } } - pub(crate) fn runtime_restore_idle(&self) -> bool { - self.state - .lock() - .is_ok_and(|state| state.active == 0 && !state.abandoned_cleanup) - } - - /// A restore-enabled Host owns cleanup through its existing TaskSpawner. - /// Dropping the IPC waiter does not release the lease or cancel cleanup. - /// Hosts without restore binding retain their original executor behavior. - pub(crate) fn cleanup( + pub(crate) fn cleanup_with_hold( self: &Arc, + hold: RuntimeActivityHold, work: futures_util::future::BoxFuture<'static, Result>, ) -> futures_util::future::BoxFuture<'static, Result> { - let gate = Arc::clone(self); let spawner = self .binding .get() .map(|binding| Arc::clone(&binding.spawner)); Box::pin(async move { - // Claim before handing the task to the scheduler: the caller may - // drop its main lease as soon as this future first yields. - let hold = gate.cleanup_hold(); let task = async move { let result = work.await; hold.complete(); @@ -263,6 +251,26 @@ impl RuntimeActivityGate { } }) } + + pub(crate) fn runtime_restore_idle(&self) -> bool { + self.state + .lock() + .is_ok_and(|state| state.active == 0 && !state.abandoned_cleanup) + } + + /// A restore-enabled Host owns cleanup through its existing TaskSpawner. + /// Dropping the IPC waiter does not release the lease or cancel cleanup. + /// Hosts without restore binding retain their original executor behavior. + pub(crate) fn cleanup( + self: &Arc, + work: futures_util::future::BoxFuture<'static, Result>, + ) -> futures_util::future::BoxFuture<'static, Result> { + let gate = Arc::clone(self); + Box::pin(async move { + let hold = gate.cleanup_hold(); + gate.cleanup_with_hold(hold, work).await + }) + } } pub(crate) struct RuntimeActivityHold { @@ -270,7 +278,7 @@ pub(crate) struct RuntimeActivityHold { clean: bool, } impl RuntimeActivityHold { - fn complete(mut self) { + pub(crate) fn complete(mut self) { // Consume the whole guard so async field capture cannot leave its // Drop on the caller while moving only the Copy completion flag. self.clean = true; @@ -415,6 +423,14 @@ mod tests { } } + #[tokio::test] + async fn dropping_unpolled_cleanup_does_not_latch_abandoned_state() { + let gate = Arc::new(RuntimeActivityGate::default()); + let cleanup = gate.cleanup(Box::pin(async { Ok::<(), BackendError>(()) })); + drop(cleanup); + assert!(gate.runtime_restore_idle()); + } + #[tokio::test] async fn runtime_restore_waits_for_owned_cleanup_after_waiter_is_dropped() { let gate = Arc::new(RuntimeActivityGate::default()); diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index b1c28046a..b834678fa 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -1655,15 +1655,21 @@ mod jni_exports { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); }; - let mode = env.get_string(&JString::from_raw(mode)); - let password = env.get_string(&JString::from_raw(password)); - let android_preferences = env.get_string(&JString::from_raw(android_preferences)); + let mode = env + .get_string(&JString::from_raw(mode)) + .map(|value| value.to_string_lossy().into_owned()); + let password = env + .get_string(&JString::from_raw(password)) + .map(|value| value.to_string_lossy().into_owned()); + let android_preferences = env + .get_string(&JString::from_raw(android_preferences)) + .map(|value| value.to_string_lossy().into_owned()); let response = match (mode, password, android_preferences) { (Ok(mode), Ok(password), Ok(android_preferences)) => export_local_migration_bundle_json( - &mode.to_string_lossy(), + &mode, include_credentials != 0, - &password.to_string_lossy(), - &android_preferences.to_string_lossy(), + &password, + &android_preferences, ), _ => local_bundle_error("invalid JNI input"), }; @@ -1681,16 +1687,20 @@ mod jni_exports { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); }; - let values = ( - env.get_string(&JString::from_raw(bundle_base64)), - env.get_string(&JString::from_raw(password)), - env.get_string(&JString::from_raw(android_preferences)), - ); - let response = match values { + let bundle_base64 = env + .get_string(&JString::from_raw(bundle_base64)) + .map(|value| value.to_string_lossy().into_owned()); + let password = env + .get_string(&JString::from_raw(password)) + .map(|value| value.to_string_lossy().into_owned()); + let android_preferences = env + .get_string(&JString::from_raw(android_preferences)) + .map(|value| value.to_string_lossy().into_owned()); + let response = match (bundle_base64, password, android_preferences) { (Ok(bundle), Ok(password), Ok(android_preferences)) => preview_local_migration_bundle_json( - &bundle.to_string_lossy(), - &password.to_string_lossy(), - &android_preferences.to_string_lossy(), + &bundle, + &password, + &android_preferences, ), _ => local_bundle_error("invalid JNI input"), }; @@ -1706,8 +1716,11 @@ mod jni_exports { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); }; - let response = match env.get_string(&JString::from_raw(bundle_base64)) { - Ok(bundle) => inspect_local_migration_bundle_json(&bundle.to_string_lossy()), + let bundle_base64 = env + .get_string(&JString::from_raw(bundle_base64)) + .map(|value| value.to_string_lossy().into_owned()); + let response = match bundle_base64 { + Ok(bundle) => inspect_local_migration_bundle_json(&bundle), Err(_) => local_bundle_error("invalid JNI input"), }; crate::android::jni::android::export_jstring(&mut env, &response) @@ -1723,14 +1736,16 @@ mod jni_exports { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); }; - let values = ( - env.get_string(&JString::from_raw(bundle_base64)), - env.get_string(&JString::from_raw(password)), - ); - let response = match values { + let bundle_base64 = env + .get_string(&JString::from_raw(bundle_base64)) + .map(|value| value.to_string_lossy().into_owned()); + let password = env + .get_string(&JString::from_raw(password)) + .map(|value| value.to_string_lossy().into_owned()); + let response = match (bundle_base64, password) { (Ok(bundle), Ok(password)) => extract_local_migration_android_preferences_json( - &bundle.to_string_lossy(), - &password.to_string_lossy(), + &bundle, + &password, ), _ => local_bundle_error("invalid JNI input"), }; @@ -1751,23 +1766,40 @@ mod jni_exports { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); }; - let values = ( - env.get_string(&JString::from_raw(bundle_base64)), - env.get_string(&JString::from_raw(password)), - env.get_string(&JString::from_raw(mode)), - env.get_string(&JString::from_raw(choices_json)), - env.get_string(&JString::from_raw(expected_generation)), - env.get_string(&JString::from_raw(android_preferences)), - ); - let response = match values { + let bundle_base64 = env + .get_string(&JString::from_raw(bundle_base64)) + .map(|value| value.to_string_lossy().into_owned()); + let password = env + .get_string(&JString::from_raw(password)) + .map(|value| value.to_string_lossy().into_owned()); + let mode = env + .get_string(&JString::from_raw(mode)) + .map(|value| value.to_string_lossy().into_owned()); + let choices_json = env + .get_string(&JString::from_raw(choices_json)) + .map(|value| value.to_string_lossy().into_owned()); + let expected_generation = env + .get_string(&JString::from_raw(expected_generation)) + .map(|value| value.to_string_lossy().into_owned()); + let android_preferences = env + .get_string(&JString::from_raw(android_preferences)) + .map(|value| value.to_string_lossy().into_owned()); + let response = match ( + bundle_base64, + password, + mode, + choices_json, + expected_generation, + android_preferences, + ) { (Ok(bundle), Ok(password), Ok(mode), Ok(choices), Ok(expected_generation), Ok(android_preferences)) => { import_local_migration_bundle_json( - &bundle.to_string_lossy(), - &password.to_string_lossy(), - &mode.to_string_lossy(), - &choices.to_string_lossy(), - &expected_generation.to_string_lossy(), - &android_preferences.to_string_lossy(), + &bundle, + &password, + &mode, + &choices, + &expected_generation, + &android_preferences, ) } _ => local_bundle_error("invalid JNI input"), From 9b4e606d6fda8589231415fea27e2109b78149a9 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 15 Oct 2026 04:29:32 +0800 Subject: [PATCH 04/15] fix: satisfy Linux core lints Co-Authored-By: Claude Fable 5 --- .../app/crates/openless-core/src/cloud_sync_e2ee/service.rs | 2 +- .../openless-core/src/cloud_sync_e2ee_store/journal.rs | 2 ++ .../openless-core/src/local_migration_bundle/container.rs | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs index a6dcccc0e..3b03b4a69 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs @@ -643,7 +643,7 @@ impl EncryptedSyncService { .map(|origin| canonical_origin(&origin).map_err(protocol_error)) .transpose()?; let token = token.trim(); - if origin.is_some() != !token.is_empty() { + if origin.is_some() == token.is_empty() { return Err(error("unsupported_protocol")); } self.0.auto_suspended.store(true, Ordering::Release); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs index 1cf20dc85..70a72e645 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs @@ -32,6 +32,8 @@ fn sync_directory(path: &std::path::Path) -> DocumentResult<()> { std::fs::File::open(path) .and_then(|file| file.sync_all()) .map_err(|_| DocumentError::JournalUnavailable)?; + #[cfg(not(unix))] + let _ = path; Ok(()) } diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs index d9ac36c8a..183029710 100644 --- a/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs @@ -473,7 +473,7 @@ fn read_entries(bytes: &[u8]) -> BundleResult>> { } let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| BundleError::MalformedContainer)?; - if archive.len() == 0 || archive.len() > MAX_BUNDLE_ENTRIES { + if archive.is_empty() || archive.len() > MAX_BUNDLE_ENTRIES { return Err(BundleError::TooLarge); } let mut entries = BTreeMap::new(); @@ -597,8 +597,8 @@ mod tests { ) .unwrap(); let inspection = inspect_bundle(&artifact.bytes).unwrap(); - assert_eq!(inspection.credentials_present, false); - assert_eq!(inspection.device_profile_migrated, false); + assert!(!inspection.credentials_present); + assert!(!inspection.device_profile_migrated); assert_eq!(inspection.section_counts.channels, 1); } From 6ad7f66c93657a4b3d36a1f60a7031cbdb6ce09c Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 15 Oct 2026 04:48:46 +0800 Subject: [PATCH 05/15] fix(android): use Activity configuration callback Co-Authored-By: Claude Fable 5 --- .../app/android/kotlin/OpenLessKeyboardSettingsActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt index aa1c373bb..9bbaa0626 100644 --- a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt @@ -100,7 +100,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { } @Suppress("DEPRECATION") - override fun onRetainCustomNonConfigurationInstance(): Any? = + override fun onRetainNonConfigurationInstance(): Any? = pendingExportMode?.let { PendingExportState(it, pendingExportIncludeCredentials, pendingExportPassword) } From d94cf6cdd788cdc047e7fbcb825e1d3b0dc93412 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 15 Oct 2026 13:19:01 +0800 Subject: [PATCH 06/15] feat(android): move local migration into app settings Expose Android local configuration migration from the application Privacy settings while keeping the native SAF, preview, password, backup, and restore flow isolated from keyboard settings. Co-Authored-By: Claude Fable 5 --- .../app/android/kotlin/OpenLessApplication.kt | 13 +- .../OpenLessKeyboardSettingsActivity.kt | 513 --------------- .../kotlin/OpenLessLocalMigrationActivity.kt | 591 ++++++++++++++++++ .../android/kotlin/OpenLessSettingsExport.kt | 7 +- .../app/scripts/copy-android-scaffolding.mjs | 1 + .../scripts/local-bundle-contract.test.mjs | 43 +- .../merge-android-overlay-manifest.mjs | 5 + .../src/commands/permissions_cmds.rs | 19 + openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src/i18n/de.ts | 14 +- openless-all/app/src/i18n/en.ts | 13 +- openless-all/app/src/i18n/es.ts | 13 +- openless-all/app/src/i18n/fr.ts | 13 +- openless-all/app/src/i18n/ja.ts | 13 +- openless-all/app/src/i18n/ko.ts | 13 +- openless-all/app/src/i18n/zh-CN.ts | 9 + openless-all/app/src/i18n/zh-TW.ts | 10 + openless-all/app/src/lib/ipc/index.ts | 3 + .../app/src/lib/ipc/local-migration.test.ts | 12 + .../app/src/lib/ipc/local-migration.ts | 6 + .../pages/settings/LocalMigrationSection.tsx | 72 +++ openless-all/app/src/pages/settings/tabs.tsx | 2 + 22 files changed, 853 insertions(+), 533 deletions(-) create mode 100644 openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt create mode 100644 openless-all/app/src/lib/ipc/local-migration.test.ts create mode 100644 openless-all/app/src/lib/ipc/local-migration.ts create mode 100644 openless-all/app/src/pages/settings/LocalMigrationSection.tsx diff --git a/openless-all/app/android/kotlin/OpenLessApplication.kt b/openless-all/app/android/kotlin/OpenLessApplication.kt index d31b1532e..d120c1a54 100644 --- a/openless-all/app/android/kotlin/OpenLessApplication.kt +++ b/openless-all/app/android/kotlin/OpenLessApplication.kt @@ -15,6 +15,8 @@ import android.util.Log /** Registers activity lifecycle hooks for overlay background trigger mode. */ class OpenLessApplication : Application() { + private var localMigrationStarted = false + override fun onCreate() { super.onCreate() OpenLessAppContext.initialize(this) @@ -54,6 +56,11 @@ class OpenLessApplication : Application() { } override fun onActivityStarted(activity: Activity) { + if (activity is OpenLessLocalMigrationActivity) { + localMigrationStarted = true + maybeHideOverlayOnForeground() + return + } // Must use `is MainActivity` — launcher opens // OpenLessBackendWarmupActivity (subclass), whose class name // does NOT end with "MainActivity", so endsWith() never fired @@ -93,7 +100,11 @@ class OpenLessApplication : Application() { } override fun onActivityStopped(activity: Activity) { - if (activity is MainActivity) { + if (activity is OpenLessLocalMigrationActivity) { + localMigrationStarted = false + return + } + if (activity is MainActivity && !localMigrationStarted) { // #region agent log Log.i( "OpenLessDbg58c22b", diff --git a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt index 9bbaa0626..4537ba904 100644 --- a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt @@ -32,21 +32,6 @@ import androidx.core.view.WindowInsetsCompat class OpenLessKeyboardSettingsActivity : Activity() { private val prefs by lazy { getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE) } - // The new migration flow captures only after the user has selected a SAF - // destination. Keep only small UI state across that picker round trip; the - // bundle itself never lives in an Activity field. - private var pendingExportMode: String? = null - private var pendingExportIncludeCredentials = false - private var pendingExportPassword: String? = null - private val requestCodeExportSave = 4201 - private val requestCodeImportOpen = 4202 - private val requestCodeLegacyImportOpen = 4203 - private val maxMigrationBundleBytes = OpenLessMigrationBundlePolicy.MAX_BUNDLE_BYTES - private data class PendingExportState( - val mode: String, - val includeCredentials: Boolean, - val password: String?, - ) private val englishUi by lazy { val locale = prefs.getString("locale", null) ?: resources.configuration.locales[0].toLanguageTag() !locale.startsWith("zh", ignoreCase = true) @@ -67,18 +52,6 @@ class OpenLessKeyboardSettingsActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val retained = lastNonConfigurationInstance as? PendingExportState - if (retained != null) { - pendingExportMode = retained.mode - pendingExportIncludeCredentials = retained.includeCredentials - pendingExportPassword = retained.password - } else if (savedInstanceState != null) { - pendingExportMode = savedInstanceState.getString("migrationExportMode") - pendingExportIncludeCredentials = savedInstanceState.getBoolean("migrationExportIncludeCredentials", false) - // Passwords are deliberately not persisted in saved state. If the - // process was killed while the picker was open, ask again on return. - pendingExportPassword = null - } // Explicit rather than relying on targetSdk 36's implicit // edge-to-edge enforcement — guarantees the IME-inset listener in // buildContent() actually fires so the "云笔记提交" URL/Token fields @@ -93,18 +66,6 @@ class OpenLessKeyboardSettingsActivity : Activity() { WindowCompat.getInsetsController(window, window.decorView) } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString("migrationExportMode", pendingExportMode) - outState.putBoolean("migrationExportIncludeCredentials", pendingExportIncludeCredentials) - } - - @Suppress("DEPRECATION") - override fun onRetainNonConfigurationInstance(): Any? = - pendingExportMode?.let { - PendingExportState(it, pendingExportIncludeCredentials, pendingExportPassword) - } - private fun buildContent(): View { val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -549,69 +510,6 @@ class OpenLessKeyboardSettingsActivity : Activity() { ), ) - content.addView(sectionLabel(ui("本地迁移 / 分享", "Local migration / sharing"))) - content.addView( - TextView(this).apply { - text = ui( - "默认分享模式会脱敏并排除所有凭据。个人迁移如需携带凭据,必须使用密码保护;系统权限、OAuth 会话、设备路径和运行时状态不会迁移。导入前会显示文件摘要,并自动创建本地备份。", - "Share mode is redacted and excludes all credentials. Personal migration credentials require a password. System permissions, OAuth sessions, device paths, and runtime state never migrate. Import shows a summary first and creates a local backup before applying.", - ) - textSize = 12f - setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) - setPadding(0, 0, 0, dp(10)) - }, - ) - val migrationRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } - migrationRow.addView( - TextView(this).apply { - text = ui("导出迁移文件 →", "Export migration bundle →") - textSize = 15f - setTypeface(typeface, android.graphics.Typeface.BOLD) - setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) - isClickable = true - setOnClickListener { showMigrationExportDialog() } - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - migrationRow.addView( - TextView(this).apply { - text = ui("导入迁移文件 →", "Import migration bundle →") - textSize = 15f - setTypeface(typeface, android.graphics.Typeface.BOLD) - setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) - isClickable = true - setOnClickListener { launchMigrationImportPicker() } - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - content.addView( - migrationRow, - LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { - bottomMargin = dp(16) - }, - ) - - content.addView( - TextView(this).apply { - text = ui("导入旧版 JSON 配置 →", "Import legacy JSON settings →") - textSize = 13f - setTextColor(tone(Color.rgb(170, 170, 170), Color.rgb(90, 90, 95))) - isClickable = true - setOnClickListener { launchLegacyImportPicker() } - setPadding(0, 0, 0, dp(16)) - }, - ) - - // Legacy v1/v2 JSON import remains available only through the old - // compatibility object and is intentionally not used by new export. - content.addView( - TextView(this).apply { - text = ui("旧版 JSON 导入仍可由兼容层处理;新导出始终使用带版本和校验的 .omb 文件。", "Legacy JSON import remains available to the compatibility layer; new exports always use versioned, digested .omb bundles.") - textSize = 11f - setTextColor(tone(Color.rgb(120, 120, 120), Color.rgb(150, 150, 155))) - }, - ) - // build_first_seen_wall_time is written by OpenLessApplication's // resetRestartStatsOnVersionBump() at the exact moment it last // zeroed the restart-cause counters below — i.e. "counting since @@ -639,417 +537,6 @@ class OpenLessKeyboardSettingsActivity : Activity() { return root } - private fun showMigrationExportDialog() { - val modes = arrayOf( - ui("分享(脱敏,不带凭据)", "Share (redacted, no credentials)"), - ui("个人迁移(可选密码保护凭据)", "Personal migration (optional password-protected credentials)"), - ) - android.app.AlertDialog.Builder(this) - .setTitle(ui("选择导出模式", "Choose export mode")) - .setItems(modes) { _, which -> - if (which == 0) { - launchMigrationExportPicker("share_redacted", false, null) - } else { - showMigrationPasswordDialog { password -> - launchMigrationExportPicker("personal_migration", true, password) - } - } - } - .setNegativeButton(ui("取消", "Cancel"), null) - .show() - } - - private fun showMigrationPasswordDialog(onConfirmed: (String) -> Unit) { - val field = EditText(this).apply { - inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD - hint = ui("至少输入一个密码字符", "Enter a password") - } - val container = FrameLayout(this).apply { - setPadding(dp(24), 0, dp(24), 0) - addView(field, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - } - android.app.AlertDialog.Builder(this) - .setTitle(ui("保护迁移凭据", "Protect migration credentials")) - .setMessage(ui("密码不会写入文件名、摘要或日志。请在目标设备导入时输入相同密码。", "The password is never written to the filename, summary, or logs. Enter the same password on the destination device.")) - .setView(container) - .setPositiveButton(ui("继续选择文件", "Choose destination")) { _, _ -> - val password = field.text.toString() - if (password.isBlank()) { - Toast.makeText(this, ui("密码不能为空", "Password cannot be empty"), Toast.LENGTH_SHORT).show() - } else { - onConfirmed(password) - } - } - .setNegativeButton(ui("取消", "Cancel"), null) - .show() - } - - private fun launchMigrationExportPicker(mode: String, includeCredentials: Boolean, password: String?) { - if (!OpenLessMigrationBundlePolicy.validateExport(mode, includeCredentials, password)) { - Toast.makeText(this, ui("导出选项无效", "Invalid export options"), Toast.LENGTH_SHORT).show() - return - } - pendingExportMode = mode - pendingExportIncludeCredentials = includeCredentials - pendingExportPassword = password - val timestamp = android.text.format.DateFormat.format("yyyyMMdd-HHmm", System.currentTimeMillis()) - val intent = android.content.Intent(android.content.Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(android.content.Intent.CATEGORY_OPENABLE) - type = "application/octet-stream" - putExtra(android.content.Intent.EXTRA_TITLE, "openless-migration-$timestamp.omb") - } - runCatching { startActivityForResult(intent, requestCodeExportSave) }.onFailure { - clearPendingExport() - Toast.makeText(this, ui("找不到文件管理器", "No file manager available"), Toast.LENGTH_LONG).show() - } - } - - private fun clearPendingExport() { - pendingExportMode = null - pendingExportIncludeCredentials = false - pendingExportPassword = null - } - - private fun launchMigrationImportPicker() { - val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(android.content.Intent.CATEGORY_OPENABLE) - type = "application/octet-stream" - } - runCatching { startActivityForResult(intent, requestCodeImportOpen) } - .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } - } - - private fun launchLegacyImportPicker() { - val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(android.content.Intent.CATEGORY_OPENABLE) - type = "application/json" - } - runCatching { startActivityForResult(intent, requestCodeLegacyImportOpen) } - .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (resultCode != RESULT_OK || data == null) { - if (requestCode == requestCodeExportSave) clearPendingExport() - return - } - when (requestCode) { - requestCodeExportSave -> { - val uri = data.data - val mode = pendingExportMode - val includeCredentials = pendingExportIncludeCredentials - val password = pendingExportPassword.orEmpty() - clearPendingExport() - if (uri == null || mode == null) return - if (includeCredentials && password.isEmpty()) { - showMigrationPasswordDialog { restoredPassword -> performMigrationExport(uri, mode, true, restoredPassword) } - } else { - performMigrationExport(uri, mode, includeCredentials, password) - } - } - requestCodeImportOpen -> { - val uri = data.data ?: return - Thread { - val result = runCatching { - val bytes = OpenLessContentReader.readBytes(this, uri.toString(), maxMigrationBundleBytes) - ?: error("read selected document failed") - val bundleBase64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) - val envelope = org.json.JSONObject(OpenLessNative.nativeInspectLocalMigrationBundle(bundleBase64)) - check(envelope.optBoolean("ok")) { envelope.optString("error", "invalid migration bundle") } - bundleBase64 to envelope.getJSONObject("payload") - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - result.onSuccess { (bundleBase64, inspection) -> showMigrationImportSummary(bundleBase64, inspection) } - .onFailure { error -> - Toast.makeText(this, ui("读取或校验失败:${error.message}", "Read or validation failed: ${error.message}"), Toast.LENGTH_LONG).show() - } - } - }.start() - } - requestCodeLegacyImportOpen -> { - val uri = data.data ?: return - Thread { - val json = OpenLessContentReader.readBytes(this, uri.toString(), 4 * 1024 * 1024) - ?.toString(Charsets.UTF_8) - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - if (json.isNullOrBlank()) { - Toast.makeText(this, ui("读取旧版配置失败或文件超过 4 MiB", "Read failed or legacy file exceeds 4 MiB"), Toast.LENGTH_SHORT).show() - } else { - showImportCategoryDialog(json) - } - } - }.start() - } - } - } - - private fun showImportCategoryDialog(json: String) { - val present = OpenLessSettingsExport.categoriesPresent(json) - if (present.isEmpty()) { - Toast.makeText(this, ui("这不是一个有效的配置文件", "Not a valid settings file"), Toast.LENGTH_SHORT).show() - return - } - val categories = present.toList() - val labels = categories.map { ui(it.labelZh, it.labelEn) }.toTypedArray() - val checked = BooleanArray(categories.size) { true } - android.app.AlertDialog.Builder(this) - .setTitle(ui("选择要导入的内容", "Choose what to import")) - .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } - .setPositiveButton(ui("导入", "Import")) { _, _ -> - val selected = categories.filterIndexed { index, _ -> checked[index] }.toSet() - if (selected.isEmpty()) { - Toast.makeText(this, ui("没有选择任何内容", "Nothing selected"), Toast.LENGTH_SHORT).show() - return@setPositiveButton - } - Thread { - val imported = runCatching { OpenLessSettingsExport.import(this, json, selected) } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - val result = imported.getOrNull() - val message = if (result == null) { - ui("配置文件无效,导入失败", "Invalid settings file; import failed") - } else { - buildString { - append(ui("已导入 ${result.applied.size} 项", "Imported ${result.applied.size} categories")) - for ((category, reason) in result.errors) { - append('\n').append(ui(category.labelZh, category.labelEn)).append(": ").append(reason) - } - } - } - android.app.AlertDialog.Builder(this) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() - setContentView(buildContent()) - } - }.start() - } - .setNegativeButton(ui("取消", "Cancel"), null) - .show() - } - - private fun performMigrationExport( - uri: android.net.Uri, - mode: String, - includeCredentials: Boolean, - password: String, - ) { - Thread { - val result = runCatching { - val envelope = org.json.JSONObject( - OpenLessNative.nativeExportLocalMigrationBundle( - mode, - includeCredentials, - password, - OpenLessSettingsExport.exportPortable(this), - ), - ) - check(envelope.optBoolean("ok")) { envelope.optString("error", "export failed") } - val payload = envelope.getJSONObject("payload") - val bytes = android.util.Base64.decode(payload.getString("bundleBase64"), android.util.Base64.DEFAULT) - check(bytes.size <= maxMigrationBundleBytes) { "bundle exceeds size limit" } - check(OpenLessContentWriter.writeBytes(this, uri.toString(), bytes)) { "write selected document failed" } - payload.getJSONObject("inspection") - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - result.onSuccess { inspection -> - Toast.makeText(this, ui("已导出迁移文件(${inspection.optInt("documentCount")} 项)", "Migration bundle exported (${inspection.optInt("documentCount")} documents)"), Toast.LENGTH_LONG).show() - }.onFailure { error -> - Toast.makeText(this, ui("导出失败:${error.message}", "Export failed: ${error.message}"), Toast.LENGTH_LONG).show() - } - } - }.start() - } - - private fun migrationInspectionText(inspection: org.json.JSONObject): String = buildString { - append(ui("格式:", "Format: ")).append(inspection.optString("format")).append('\n') - append(ui("版本:", "Version: ")).append(inspection.optInt("formatVersion")).append('\n') - append(ui("文档:", "Documents: ")).append(inspection.optInt("documentCount")).append('\n') - append(ui("渠道:", "Channels: ")).append(inspection.optJSONObject("sectionCounts")?.optInt("channels") ?: 0).append('\n') - append(ui("词典 / 纠错 / 风格包:", "Dictionary / corrections / style packs: ")) - .append(inspection.optJSONObject("sectionCounts")?.optInt("dictionary") ?: 0).append(" / ") - .append(inspection.optJSONObject("sectionCounts")?.optInt("corrections") ?: 0).append(" / ") - .append(inspection.optJSONObject("sectionCounts")?.optInt("stylePacks") ?: 0).append('\n') - append(ui("凭据:", "Credentials: ")).append( - if (inspection.optBoolean("credentialsEncrypted")) ui("已加密", "password protected") else ui("未携带", "not included"), - ).append('\n') - append(ui("系统权限不会迁移,目标设备需重新授权。", "System permissions do not migrate; authorize them again on the destination device.")) - } - - private fun showMigrationImportSummary(bundleBase64: String, inspection: org.json.JSONObject) { - android.app.AlertDialog.Builder(this) - .setTitle(ui("确认迁移文件", "Review migration bundle")) - .setMessage(migrationInspectionText(inspection)) - .setPositiveButton(ui("选择合并 / 替换", "Choose merge / replace")) { _, _ -> - val encrypted = inspection.optBoolean("credentialsEncrypted") - if (encrypted) { - showMigrationPasswordDialog { password -> showMigrationRestoreMode(bundleBase64, password) } - } else { - showMigrationRestoreMode(bundleBase64, "") - } - } - .setNegativeButton(ui("取消", "Cancel"), null) - .show() - } - - private fun showMigrationRestoreMode(bundleBase64: String, password: String) { - Thread { - val previewResult = runCatching { - val response = org.json.JSONObject( - OpenLessNative.nativePreviewLocalMigrationBundle( - bundleBase64, - password, - OpenLessSettingsExport.exportPortable(this@OpenLessKeyboardSettingsActivity), - ), - ) - check(response.optBoolean("ok")) { response.optString("error", "preview failed") } - response.getJSONObject("payload") - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - previewResult.onSuccess { preview -> - val conflicts = preview.getJSONArray("conflicts") - val expectedGeneration = preview.optString("observedGeneration") - chooseMigrationConflicts(bundleBase64, password, conflicts, 0, org.json.JSONArray()) { - showMigrationRestoreModeAfterConflicts(bundleBase64, password, expectedGeneration, conflicts, it) - } - }.onFailure { error -> - Toast.makeText(this, ui("预览失败:${error.message}", "Preview failed: ${error.message}"), Toast.LENGTH_LONG).show() - } - } - }.start() - } - - private fun chooseMigrationConflicts( - bundleBase64: String, - password: String, - conflicts: org.json.JSONArray, - index: Int, - choices: org.json.JSONArray, - onComplete: (org.json.JSONArray) -> Unit, - ) { - if (index >= conflicts.length()) { - onComplete(choices) - return - } - val conflict = conflicts.getJSONObject(index) - val kind = conflict.optString("kind") - val reason = conflict.optString("reason") - val credential = if (conflict.optBoolean("isCredentialUnit")) ui("(凭据关联渠道)", " (credential-linked channel)") else "" - var selected: String? = null - val dialog = android.app.AlertDialog.Builder(this) - .setTitle(ui("选择冲突 ${index + 1}/${conflicts.length()}", "Choose conflict ${index + 1}/${conflicts.length()}")) - .setMessage("$kind / $reason$credential") - .setSingleChoiceItems(arrayOf(ui("保留当前设备", "Keep this device"), ui("使用迁移文件", "Use migration file")), -1) { _, which -> - selected = if (which == 0) "local" else "remote" - } - .setPositiveButton(ui("继续", "Continue"), null) - .setNegativeButton(ui("取消", "Cancel"), null) - .create() - dialog.setOnShowListener { - dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val side = selected - if (side == null) { - Toast.makeText(this, ui("必须选择一侧", "Choose one side"), Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - choices.put(org.json.JSONObject().put("conflictId", conflict.getString("conflictId")).put("side", side)) - dialog.dismiss() - chooseMigrationConflicts(bundleBase64, password, conflicts, index + 1, choices, onComplete) - } - } - dialog.show() - } - - private fun portableConflictSide( - conflicts: org.json.JSONArray, - choices: org.json.JSONArray, - ): String? { - for (i in 0 until conflicts.length()) { - val conflict = conflicts.getJSONObject(i) - if (conflict.optString("documentId") != "androidPortableSettings") continue - val conflictId = conflict.optString("conflictId") - for (j in 0 until choices.length()) { - val choice = choices.getJSONObject(j) - if (choice.optString("conflictId") == conflictId) { - return choice.optString("side") - } - } - } - return null - } - - private fun showMigrationRestoreModeAfterConflicts( - bundleBase64: String, - password: String, - expectedGeneration: String, - conflicts: org.json.JSONArray, - choices: org.json.JSONArray, - ) { - val modes = arrayOf(ui("合并", "Merge"), ui("替换", "Replace")) - android.app.AlertDialog.Builder(this) - .setTitle(ui("导入方式", "Restore mode")) - .setItems(modes) { _, which -> - val mode = if (which == 0) "merge" else "replace" - Thread { - val applyPortable = mode == "replace" || portableConflictSide(conflicts, choices) != "local" - val mergePortableLearning = mode == "merge" && applyPortable - val result = runCatching { - val response = org.json.JSONObject( - OpenLessNative.nativeImportLocalMigrationBundle( - bundleBase64, - password, - mode, - choices.toString(), - expectedGeneration, - OpenLessSettingsExport.exportPortable(this@OpenLessKeyboardSettingsActivity), - ), - ) - check(response.optBoolean("ok")) { response.optString("error", "import failed") } - val payload = response.getJSONObject("payload") - val portableResponse = org.json.JSONObject( - OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), - ) - check(portableResponse.optBoolean("ok")) { portableResponse.optString("error", "portable settings read failed") } - val portable = portableResponse.getJSONObject("payload").opt("preferences") - if (portable !is org.json.JSONObject && portable != org.json.JSONObject.NULL) { - error("invalid portable Android settings") - } - Triple( - payload, - (portable as? org.json.JSONObject)?.toString(), - applyPortable to mergePortableLearning, - ) - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - result.onSuccess { (_, portableJson, portableOptions) -> - runCatching { - if (portableOptions.first) { - portableJson?.let { - OpenLessSettingsExport.importPortable(this, it, portableOptions.second) - } - } - }.onSuccess { - Toast.makeText(this, ui("导入完成,备份已保留", "Import complete; backup retained"), Toast.LENGTH_LONG).show() - setContentView(buildContent()) - }.onFailure { error -> - Toast.makeText(this, ui("核心数据已导入,但 Android 偏好未应用:${error.message}", "Core data imported, but Android preferences were not applied: ${error.message}"), Toast.LENGTH_LONG).show() - } - }.onFailure { error -> - Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() - } - } - }.start() - } - .setNegativeButton(ui("取消", "Cancel"), null) - .show() - } - /** Fires a one-shot vibration with the sliders' current (already-saved) values, so a change is felt immediately. */ private fun fireTestVibration(amplitude: Int, durationMs: Int) { runCatching { diff --git a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt new file mode 100644 index 000000000..dfc1bf85e --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt @@ -0,0 +1,591 @@ +package com.openless.app + +import android.app.Activity +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat + +/** + * Full-screen native flow for exporting and importing the versioned local + * migration bundle. SAF, password handling, preview, conflict resolution, + * and restore remain native because they depend on Activity lifecycle state. + */ +class OpenLessLocalMigrationActivity : Activity() { + private val prefs by lazy { getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE) } + + private var pendingExportMode: String? = null + private var pendingExportIncludeCredentials = false + private var pendingExportPassword: String? = null + private val requestCodeExportSave = 4201 + private val requestCodeImportOpen = 4202 + private val requestCodeLegacyImportOpen = 4203 + private val maxMigrationBundleBytes = OpenLessMigrationBundlePolicy.MAX_BUNDLE_BYTES + private data class PendingExportState( + val mode: String, + val includeCredentials: Boolean, + val password: String?, + ) + private val englishUi by lazy { + val locale = prefs.getString("locale", null) ?: resources.configuration.locales[0].toLanguageTag() + !locale.startsWith("zh", ignoreCase = true) + } + + private fun ui(zh: String, en: String) = if (englishUi) en else zh + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private val isDarkTheme: Boolean + get() = when (prefs.getString("theme_mode", null)) { + "light" -> false + "dark" -> true + else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_NO + } + + private fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val retained = lastNonConfigurationInstance as? PendingExportState + if (retained != null) { + pendingExportMode = retained.mode + pendingExportIncludeCredentials = retained.includeCredentials + pendingExportPassword = retained.password + } else if (savedInstanceState != null) { + pendingExportMode = savedInstanceState.getString("migrationExportMode") + pendingExportIncludeCredentials = savedInstanceState.getBoolean("migrationExportIncludeCredentials", false) + pendingExportPassword = null + } + WindowCompat.setDecorFitsSystemWindows(window, false) + setContentView(buildContent()) + WindowCompat.getInsetsController(window, window.decorView) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString("migrationExportMode", pendingExportMode) + outState.putBoolean("migrationExportIncludeCredentials", pendingExportIncludeCredentials) + } + + @Suppress("DEPRECATION") + override fun onRetainNonConfigurationInstance(): Any? = + pendingExportMode?.let { PendingExportState(it, pendingExportIncludeCredentials, pendingExportPassword) } + + private fun buildContent(): View { + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(tone(Color.rgb(30, 30, 30), Color.rgb(245, 245, 247))) + } + val header = LinearLayout(this).apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(8), dp(16), dp(8)) + } + header.addView( + TextView(this).apply { + text = "←" + textSize = 22f + setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) + gravity = Gravity.CENTER + setPadding(dp(16), dp(8), dp(16), dp(8)) + isClickable = true + setOnClickListener { finish() } + }, + ) + header.addView( + TextView(this).apply { + text = ui("本地配置迁移", "Local configuration migration") + textSize = 18f + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) + }, + ) + root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + + val scroll = ScrollView(this) + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(12), dp(20), dp(20)) + } + scroll.addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + root.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + view.setPadding(systemBars.left, systemBars.top, systemBars.right, maxOf(systemBars.bottom, imeBottom)) + insets + } + + content.addView(sectionLabel(ui("本地配置迁移", "Local configuration migration"))) + content.addView(TextView(this).apply { + text = ui( + "将所有可迁移的本地配置导出到版本化文件,或从另一台 Android 设备恢复。分享模式默认脱敏;个人迁移中的凭据必须使用密码保护。系统权限、OAuth 会话、设备路径和运行时状态不会迁移。导入前会显示来源、版本和变更摘要,并自动保留备份。", + "Export portable local configuration to a versioned bundle, or restore it on another Android device. Share mode is redacted by default; personal migration credentials require password protection. System permissions, OAuth sessions, device paths, and runtime state never migrate. Import shows the source, version, and change summary before keeping a local backup.", + ) + textSize = 13f + setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(80, 80, 85))) + setPadding(0, 0, 0, dp(16)) + }) + val actions = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + actions.addView(actionText(ui("导出迁移文件 →", "Export migration bundle →")) { showMigrationExportDialog() }) + actions.addView(actionText(ui("导入迁移文件 →", "Import migration bundle →")) { launchMigrationImportPicker() }) + content.addView(actions, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + content.addView(TextView(this).apply { + text = ui("旧版 JSON 导入 →", "Import legacy JSON settings →") + textSize = 13f + setTextColor(tone(Color.rgb(170, 170, 170), Color.rgb(90, 90, 95))) + isClickable = true + setOnClickListener { launchLegacyImportPicker() } + setPadding(0, dp(18), 0, dp(12)) + }) + content.addView(TextView(this).apply { + text = ui( + "旧版 JSON 导入由兼容层处理;新导出始终使用带版本和校验的 .omb 文件。", + "Legacy JSON import remains available through the compatibility layer; new exports always use versioned, digested .omb bundles.", + ) + textSize = 11f + setTextColor(tone(Color.rgb(120, 120, 120), Color.rgb(150, 150, 155))) + }) + return root + } + + private fun actionText(text: String, onClick: () -> Unit): View = TextView(this).apply { + this.text = text + textSize = 15f + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110))) + isClickable = true + setPadding(0, dp(8), 0, dp(8)) + setOnClickListener { onClick() } + } + + private fun showMigrationExportDialog() { + val modes = arrayOf( + ui("分享(脱敏,不带凭据)", "Share (redacted, no credentials)"), + ui("个人迁移(可选密码保护凭据)", "Personal migration (optional password-protected credentials)"), + ) + android.app.AlertDialog.Builder(this) + .setTitle(ui("选择导出模式", "Choose export mode")) + .setItems(modes) { _, which -> + if (which == 0) { + launchMigrationExportPicker("share_redacted", false, null) + } else { + showMigrationPasswordDialog { password -> + launchMigrationExportPicker("personal_migration", true, password) + } + } + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun showMigrationPasswordDialog(onConfirmed: (String) -> Unit) { + val field = EditText(this).apply { + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + hint = ui("至少输入一个密码字符", "Enter a password") + } + val container = FrameLayout(this).apply { + setPadding(dp(24), 0, dp(24), 0) + addView(field, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + android.app.AlertDialog.Builder(this) + .setTitle(ui("保护迁移凭据", "Protect migration credentials")) + .setMessage(ui("密码不会写入文件名、摘要或日志。请在目标设备导入时输入相同密码。", "The password is never written to the filename, summary, or logs. Enter the same password on the destination device.")) + .setView(container) + .setPositiveButton(ui("继续选择文件", "Choose destination")) { _, _ -> + val password = field.text.toString() + if (password.isBlank()) { + Toast.makeText(this, ui("密码不能为空", "Password cannot be empty"), Toast.LENGTH_SHORT).show() + } else { + onConfirmed(password) + } + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun launchMigrationExportPicker(mode: String, includeCredentials: Boolean, password: String?) { + if (!OpenLessMigrationBundlePolicy.validateExport(mode, includeCredentials, password)) { + Toast.makeText(this, ui("导出选项无效", "Invalid export options"), Toast.LENGTH_SHORT).show() + return + } + pendingExportMode = mode + pendingExportIncludeCredentials = includeCredentials + pendingExportPassword = password + val timestamp = android.text.format.DateFormat.format("yyyyMMdd-HHmm", System.currentTimeMillis()) + val intent = android.content.Intent(android.content.Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(android.content.Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + putExtra(android.content.Intent.EXTRA_TITLE, "openless-migration-$timestamp.omb") + } + runCatching { startActivityForResult(intent, requestCodeExportSave) }.onFailure { + clearPendingExport() + Toast.makeText(this, ui("找不到文件管理器", "No file manager available"), Toast.LENGTH_LONG).show() + } + } + + private fun clearPendingExport() { + pendingExportMode = null + pendingExportIncludeCredentials = false + pendingExportPassword = null + } + + private fun launchMigrationImportPicker() { + val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(android.content.Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + } + runCatching { startActivityForResult(intent, requestCodeImportOpen) } + .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } + } + + private fun launchLegacyImportPicker() { + val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(android.content.Intent.CATEGORY_OPENABLE) + type = "application/json" + } + runCatching { startActivityForResult(intent, requestCodeLegacyImportOpen) } + .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode != RESULT_OK || data == null) { + if (requestCode == requestCodeExportSave) clearPendingExport() + return + } + when (requestCode) { + requestCodeExportSave -> { + val uri = data.data + val mode = pendingExportMode + val includeCredentials = pendingExportIncludeCredentials + val password = pendingExportPassword.orEmpty() + clearPendingExport() + if (uri == null || mode == null) return + if (includeCredentials && password.isEmpty()) { + showMigrationPasswordDialog { restoredPassword -> performMigrationExport(uri, mode, true, restoredPassword) } + } else { + performMigrationExport(uri, mode, includeCredentials, password) + } + } + requestCodeImportOpen -> { + val uri = data.data ?: return + Thread { + val result = runCatching { + val bytes = OpenLessContentReader.readBytes(this, uri.toString(), maxMigrationBundleBytes) + ?: error("read selected document failed") + val bundleBase64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + val envelope = org.json.JSONObject(OpenLessNative.nativeInspectLocalMigrationBundle(bundleBase64)) + check(envelope.optBoolean("ok")) { envelope.optString("error", "invalid migration bundle") } + bundleBase64 to envelope.getJSONObject("payload") + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.onSuccess { (bundleBase64, inspection) -> showMigrationImportSummary(bundleBase64, inspection) } + .onFailure { error -> + Toast.makeText(this, ui("读取或校验失败:${error.message}", "Read or validation failed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + requestCodeLegacyImportOpen -> { + val uri = data.data ?: return + Thread { + val json = OpenLessContentReader.readBytes(this, uri.toString(), 4 * 1024 * 1024) + ?.toString(Charsets.UTF_8) + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + if (json.isNullOrBlank()) { + Toast.makeText(this, ui("读取旧版配置失败或文件超过 4 MiB", "Read failed or legacy file exceeds 4 MiB"), Toast.LENGTH_SHORT).show() + } else { + showImportCategoryDialog(json) + } + } + }.start() + } + } + } + + private fun showImportCategoryDialog(json: String) { + val present = OpenLessSettingsExport.categoriesPresent(json) + if (present.isEmpty()) { + Toast.makeText(this, ui("这不是一个有效的配置文件", "Not a valid settings file"), Toast.LENGTH_SHORT).show() + return + } + val categories = present.toList() + val labels = categories.map { ui(it.labelZh, it.labelEn) }.toTypedArray() + val checked = BooleanArray(categories.size) { true } + android.app.AlertDialog.Builder(this) + .setTitle(ui("选择要导入的内容", "Choose what to import")) + .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } + .setPositiveButton(ui("导入", "Import")) { _, _ -> + val selected = categories.filterIndexed { index, _ -> checked[index] }.toSet() + if (selected.isEmpty()) { + Toast.makeText(this, ui("没有选择任何内容", "Nothing selected"), Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + Thread { + val imported = runCatching { OpenLessSettingsExport.import(this, json, selected) } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + val result = imported.getOrNull() + val message = if (result == null) { + ui("配置文件无效,导入失败", "Invalid settings file; import failed") + } else { + buildString { + append(ui("已导入 ${result.applied.size} 项", "Imported ${result.applied.size} categories")) + for ((category, reason) in result.errors) { + append('\n').append(ui(category.labelZh, category.labelEn)).append(": ").append(reason) + } + } + } + android.app.AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + setContentView(buildContent()) + } + }.start() + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun performMigrationExport( + uri: android.net.Uri, + mode: String, + includeCredentials: Boolean, + password: String, + ) { + Thread { + val result = runCatching { + val envelope = org.json.JSONObject( + OpenLessNative.nativeExportLocalMigrationBundle( + mode, + includeCredentials, + password, + OpenLessSettingsExport.exportPortable(this), + ), + ) + check(envelope.optBoolean("ok")) { envelope.optString("error", "export failed") } + val payload = envelope.getJSONObject("payload") + val bytes = android.util.Base64.decode(payload.getString("bundleBase64"), android.util.Base64.DEFAULT) + check(bytes.size <= maxMigrationBundleBytes) { "bundle exceeds size limit" } + check(OpenLessContentWriter.writeBytes(this, uri.toString(), bytes)) { "write selected document failed" } + payload.getJSONObject("inspection") + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.onSuccess { inspection -> + Toast.makeText(this, ui("已导出迁移文件(${inspection.optInt("documentCount")} 项)", "Migration bundle exported (${inspection.optInt("documentCount")} documents)"), Toast.LENGTH_LONG).show() + }.onFailure { error -> + Toast.makeText(this, ui("导出失败:${error.message}", "Export failed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + + private fun migrationInspectionText(inspection: org.json.JSONObject): String = buildString { + append(ui("格式:", "Format: ")).append(inspection.optString("format")).append('\n') + append(ui("版本:", "Version: ")).append(inspection.optInt("formatVersion")).append('\n') + append(ui("文档:", "Documents: ")).append(inspection.optInt("documentCount")).append('\n') + append(ui("渠道:", "Channels: ")).append(inspection.optJSONObject("sectionCounts")?.optInt("channels") ?: 0).append('\n') + append(ui("词典 / 纠错 / 风格包:", "Dictionary / corrections / style packs: ")) + .append(inspection.optJSONObject("sectionCounts")?.optInt("dictionary") ?: 0).append(" / ") + .append(inspection.optJSONObject("sectionCounts")?.optInt("corrections") ?: 0).append(" / ") + .append(inspection.optJSONObject("sectionCounts")?.optInt("stylePacks") ?: 0).append('\n') + append(ui("凭据:", "Credentials: ")).append( + if (inspection.optBoolean("credentialsEncrypted")) ui("已加密", "password protected") else ui("未携带", "not included"), + ).append('\n') + append(ui("系统权限不会迁移,目标设备需重新授权。", "System permissions do not migrate; authorize them again on the destination device.")) + } + + private fun showMigrationImportSummary(bundleBase64: String, inspection: org.json.JSONObject) { + android.app.AlertDialog.Builder(this) + .setTitle(ui("确认迁移文件", "Review migration bundle")) + .setMessage(migrationInspectionText(inspection)) + .setPositiveButton(ui("选择合并 / 替换", "Choose merge / replace")) { _, _ -> + val encrypted = inspection.optBoolean("credentialsEncrypted") + if (encrypted) { + showMigrationPasswordDialog { password -> showMigrationRestoreMode(bundleBase64, password) } + } else { + showMigrationRestoreMode(bundleBase64, "") + } + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun showMigrationRestoreMode(bundleBase64: String, password: String) { + Thread { + val previewResult = runCatching { + val response = org.json.JSONObject( + OpenLessNative.nativePreviewLocalMigrationBundle( + bundleBase64, + password, + OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), + ), + ) + check(response.optBoolean("ok")) { response.optString("error", "preview failed") } + response.getJSONObject("payload") + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + previewResult.onSuccess { preview -> + val conflicts = preview.getJSONArray("conflicts") + val expectedGeneration = preview.optString("observedGeneration") + chooseMigrationConflicts(bundleBase64, password, conflicts, 0, org.json.JSONArray()) { + showMigrationRestoreModeAfterConflicts(bundleBase64, password, expectedGeneration, conflicts, it) + } + }.onFailure { error -> + Toast.makeText(this, ui("预览失败:${error.message}", "Preview failed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + + private fun chooseMigrationConflicts( + bundleBase64: String, + password: String, + conflicts: org.json.JSONArray, + index: Int, + choices: org.json.JSONArray, + onComplete: (org.json.JSONArray) -> Unit, + ) { + if (index >= conflicts.length()) { + onComplete(choices) + return + } + val conflict = conflicts.getJSONObject(index) + val kind = conflict.optString("kind") + val reason = conflict.optString("reason") + val credential = if (conflict.optBoolean("isCredentialUnit")) ui("(凭据关联渠道)", " (credential-linked channel)") else "" + var selected: String? = null + val dialog = android.app.AlertDialog.Builder(this) + .setTitle(ui("选择冲突 ${index + 1}/${conflicts.length()}", "Choose conflict ${index + 1}/${conflicts.length()}")) + .setMessage("$kind / $reason$credential") + .setSingleChoiceItems(arrayOf(ui("保留当前设备", "Keep this device"), ui("使用迁移文件", "Use migration file")), -1) { _, which -> + selected = if (which == 0) "local" else "remote" + } + .setPositiveButton(ui("继续", "Continue"), null) + .setNegativeButton(ui("取消", "Cancel"), null) + .create() + dialog.setOnShowListener { + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val side = selected + if (side == null) { + Toast.makeText(this, ui("必须选择一侧", "Choose one side"), Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + choices.put(org.json.JSONObject().put("conflictId", conflict.getString("conflictId")).put("side", side)) + dialog.dismiss() + chooseMigrationConflicts(bundleBase64, password, conflicts, index + 1, choices, onComplete) + } + } + dialog.show() + } + + private fun portableConflictSide( + conflicts: org.json.JSONArray, + choices: org.json.JSONArray, + ): String? { + for (i in 0 until conflicts.length()) { + val conflict = conflicts.getJSONObject(i) + if (conflict.optString("documentId") != "androidPortableSettings") continue + val conflictId = conflict.optString("conflictId") + for (j in 0 until choices.length()) { + val choice = choices.getJSONObject(j) + if (choice.optString("conflictId") == conflictId) { + return choice.optString("side") + } + } + } + return null + } + + private fun showMigrationRestoreModeAfterConflicts( + bundleBase64: String, + password: String, + expectedGeneration: String, + conflicts: org.json.JSONArray, + choices: org.json.JSONArray, + ) { + val modes = arrayOf(ui("合并", "Merge"), ui("替换", "Replace")) + android.app.AlertDialog.Builder(this) + .setTitle(ui("导入方式", "Restore mode")) + .setItems(modes) { _, which -> + val mode = if (which == 0) "merge" else "replace" + Thread { + val applyPortable = mode == "replace" || portableConflictSide(conflicts, choices) != "local" + val mergePortableLearning = mode == "merge" && applyPortable + val result = runCatching { + val response = org.json.JSONObject( + OpenLessNative.nativeImportLocalMigrationBundle( + bundleBase64, + password, + mode, + choices.toString(), + expectedGeneration, + OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), + ), + ) + check(response.optBoolean("ok")) { response.optString("error", "import failed") } + val payload = response.getJSONObject("payload") + val portableResponse = org.json.JSONObject( + OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), + ) + check(portableResponse.optBoolean("ok")) { portableResponse.optString("error", "portable settings read failed") } + val portable = portableResponse.getJSONObject("payload").opt("preferences") + if (portable !is org.json.JSONObject && portable != org.json.JSONObject.NULL) { + error("invalid portable Android settings") + } + Triple( + payload, + (portable as? org.json.JSONObject)?.toString(), + applyPortable to mergePortableLearning, + ) + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.onSuccess { (_, portableJson, portableOptions) -> + runCatching { + if (portableOptions.first) { + portableJson?.let { + OpenLessSettingsExport.importPortable(this, it, portableOptions.second) + } + } + }.onSuccess { + Toast.makeText(this, ui("导入完成,备份已保留", "Import complete; backup retained"), Toast.LENGTH_LONG).show() + setContentView(buildContent()) + }.onFailure { error -> + Toast.makeText(this, ui("核心数据已导入,但 Android 偏好未应用:${error.message}", "Core data imported, but Android preferences were not applied: ${error.message}"), Toast.LENGTH_LONG).show() + } + }.onFailure { error -> + Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() + } + } + }.start() + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun sectionLabel(text: String): View = TextView(this).apply { + this.text = text + textSize = 12f + setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) + setPadding(0, 0, 0, dp(8)) + } +} diff --git a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt index a2f352162..9ff44da18 100644 --- a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt +++ b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt @@ -4,13 +4,12 @@ import android.content.Context import org.json.JSONObject /** - * Coordinates the keyboard settings page's "导出/导入配置" feature — collects + * Coordinates the native local migration page's legacy JSON compatibility flow — collects * whichever of the seven categories the user selected into one JSON object * for export, and applies whichever categories are both present in an * imported file and still checked. Deliberately its own file rather than - * folded into OpenLessKeyboardSettingsActivity, which is already large and - * mostly UI-building code — this is a self-contained - * read-everything/write-everything concern with no View dependencies. + * folded into an Activity, keeping this self-contained + * read-everything/write-everything concern free of View dependencies. * * Plain, unencrypted JSON, by explicit product decision — a Cloud notes * token or (if CREDENTIALS is selected) an ASR/LLM API key ends up in the diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 403b6ca54..eeab15ffe 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -80,6 +80,7 @@ const KOTLIN_FILES = [ 'OpenLessBackendWarmupActivity.kt', 'OpenLessClipboardHistory.kt', 'OpenLessKeyboardSettingsActivity.kt', + 'OpenLessLocalMigrationActivity.kt', 'OpenLessProcessRestartStats.kt', 'OpenLessBuildInfo.kt', ]; diff --git a/openless-all/app/scripts/local-bundle-contract.test.mjs b/openless-all/app/scripts/local-bundle-contract.test.mjs index 6f7278572..421ae0662 100644 --- a/openless-all/app/scripts/local-bundle-contract.test.mjs +++ b/openless-all/app/scripts/local-bundle-contract.test.mjs @@ -5,22 +5,35 @@ import { dirname, join } from 'node:path'; const appRoot = dirname(dirname(fileURLToPath(import.meta.url))); const read = (path) => readFileSync(join(appRoot, path), 'utf8'); -const activity = read('android/kotlin/OpenLessKeyboardSettingsActivity.kt'); +const migrationActivity = read('android/kotlin/OpenLessLocalMigrationActivity.kt'); +const keyboardActivity = read('android/kotlin/OpenLessKeyboardSettingsActivity.kt'); const native = read('android/kotlin/OpenLessNative.kt'); const policy = read('android/kotlin/OpenLessMigrationBundlePolicy.kt'); const container = read('crates/openless-core/src/local_migration_bundle/container.rs'); const bridge = read('src-tauri/src/android/native_bridge.rs'); const scaffolding = read('scripts/copy-android-scaffolding.mjs'); +const manifestMerge = read('scripts/merge-android-overlay-manifest.mjs'); +const privacyTabs = read('src/pages/settings/tabs.tsx'); +const migrationSection = read('src/pages/settings/LocalMigrationSection.tsx'); +const migrationIpc = read('src/lib/ipc/local-migration.ts'); +const permissionsCommands = read('src-tauri/src/commands/permissions_cmds.rs'); +const mobileHandler = read('src-tauri/src/lib.rs'); +const application = read('android/kotlin/OpenLessApplication.kt'); assert.match(policy, /openless\.android\.migration-bundle/); -assert.match(activity, /nativeExportLocalMigrationBundle/); -assert.match(activity, /nativeInspectLocalMigrationBundle/); -assert.match(activity, /nativePreviewLocalMigrationBundle/); -assert.match(activity, /nativeImportLocalMigrationBundle/); -assert.match(activity, /launchLegacyImportPicker/); -assert.match(activity, /OpenLessSettingsExport\.import\(/); -assert.match(activity, /OpenLessContentWriter\.writeBytes/); -assert.doesNotMatch(activity, /pendingExportJson/); +assert.match(migrationActivity, /nativeExportLocalMigrationBundle/); +assert.match(migrationActivity, /nativeInspectLocalMigrationBundle/); +assert.match(migrationActivity, /nativePreviewLocalMigrationBundle/); +assert.match(migrationActivity, /nativeImportLocalMigrationBundle/); +assert.match(migrationActivity, /launchLegacyImportPicker/); +assert.match(migrationActivity, /OpenLessSettingsExport\.import\(/); +assert.match(migrationActivity, /OpenLessContentWriter\.writeBytes/); +assert.doesNotMatch(migrationActivity, /pendingExportJson/); +assert.doesNotMatch( + keyboardActivity, + /nativeExportLocalMigrationBundle|nativePreviewLocalMigrationBundle|nativeImportLocalMigrationBundle|本地迁移 \/ 分享/, +); +assert.match(scaffolding, /OpenLessLocalMigrationActivity\.kt/); assert.match(native, /nativeExportLocalMigrationBundle/); assert.match(native, /nativePreviewLocalMigrationBundle/); assert.match(native, /nativeImportLocalMigrationBundle/); @@ -33,4 +46,16 @@ assert.match(scaffolding, /OpenLessMigrationBundlePolicy\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleContractTest\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleInstrumentedTest\.kt/); +assert.match(manifestMerge, /OpenLessLocalMigrationActivity/); +assert.match(manifestMerge, /android:exported="false"/); +assert.match(privacyTabs, //); +assert.match(migrationSection, /openAndroidLocalMigration/); +assert.match(migrationSection, /settings\.localMigration/); +assert.match(migrationIpc, /open_android_local_migration/); +assert.match(permissionsCommands, /open_android_local_migration/); +assert.match(permissionsCommands, /OpenLessLocalMigrationActivity/); +assert.match(mobileHandler, /commands::open_android_local_migration/); +assert.match(application, /OpenLessLocalMigrationActivity/); +assert.match(application, /!localMigrationStarted/); + console.log('local migration bundle contract passed'); diff --git a/openless-all/app/scripts/merge-android-overlay-manifest.mjs b/openless-all/app/scripts/merge-android-overlay-manifest.mjs index f40d45162..400147b6a 100644 --- a/openless-all/app/scripts/merge-android-overlay-manifest.mjs +++ b/openless-all/app/scripts/merge-android-overlay-manifest.mjs @@ -86,6 +86,11 @@ const SERVICE_SNIPPETS = [ android:exported="false" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.openless" />`, + ``, ]; function printHelp() { diff --git a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs index f4c26a9b9..5f2a24dc9 100644 --- a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs +++ b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs @@ -27,6 +27,25 @@ pub fn show_android_overlay() -> Result<(), String> { crate::android::show_android_overlay() } +#[tauri::command] +pub fn open_android_local_migration() -> Result<(), String> { + #[cfg(target_os = "android")] + { + return crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::start_activity_class( + env, + context, + "com.openless.app.OpenLessLocalMigrationActivity", + ) + }); + } + + #[cfg(not(target_os = "android"))] + { + Err("Android local migration is unavailable on this platform".to_string()) + } +} + #[tauri::command] pub fn hide_android_overlay() -> Result<(), String> { crate::android::hide_android_overlay() diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index f56bf4995..5db611758 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -514,6 +514,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::get_android_overlay_status, $crate::commands::request_android_overlay_permission, $crate::commands::show_android_overlay, + $crate::commands::open_android_local_migration, $crate::commands::hide_android_overlay, $crate::commands::get_android_accessibility_status, $crate::commands::request_android_accessibility_permission, diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 2af45040d..98c0f4513 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1346,6 +1346,17 @@ export const de: typeof zhCN = { cursorContextDesc: 'Einen kurzen Textabschnitt rund um den Cursor als Referenz für Eigennamen, Homophone und Bezüge mit der Überarbeitung an das Modell senden (macOS / Windows / Android). Diktat, Schnellnotizen und Cloud-Notizen nutzen ihn, der Kontext selbst wird aber nie in Notizen, Verlauf oder Protokolle geschrieben; bei einem Cloud-Modell wird er mit der Überarbeitungsanfrage an diesen Anbieter gesendet. Diese Einstellung ist vom lokalen Lernen getrennt. Passwortfelder, Terminals und bekannte sensible Apps sind ausgeschlossen.', }, + localMigration: { + title: 'Lokale Konfigurationsmigration', + description: + 'Exportiere alle portablen lokalen Einstellungen in ein versioniertes Paket oder stelle sie auf einem anderen Android-Gerät wieder her. Der Freigabemodus ist standardmäßig anonymisiert; persönliche Migration darf Anmeldedaten nur passwortgeschützt enthalten. Der native Ablauf zeigt Quelle, Version, Kategorien und Änderungen vor dem Zusammenführen oder Ersetzen.', + safetyNote: + 'Systemberechtigungen, OAuth-Sitzungen, Gerätepfade und Laufzeitstatus werden nie migriert. Vor dem Import wird eine lokale Sicherung erstellt.', + open: 'Lokale Migration öffnen', + opening: 'Wird geöffnet…', + openFailed: + 'Die lokale Migration konnte nicht geöffnet werden. Versuche es erneut über die Einstellungen.', + }, codingConsole: { title: 'Claude-Konsole', desc: 'Prüft den lokalen Status von Claude Code und MCP (Computersteuerung). Führt Claude unter festgelegten Einschränkungen ohne eigene Oberfläche aus und zeigt Ausgabe und Kosten laufend an.', @@ -1519,7 +1530,8 @@ export const de: typeof zhCN = { lowLatencyDictationDesc: 'Hält einen Mikrofonstream bereit und bewahrt bis zu 500 ms im Speicher, damit Sprache direkt nach dem Kurzbefehl seltener verloren geht. Vor der Bestätigung wird Audio weder gespeichert noch hochgeladen; der Stromverbrauch steigt und die Mikrofonanzeige bleibt aktiv.', audioEnhancementLabel: 'Leichte Mikrofonverbesserung', - audioEnhancementDesc: 'Wendet eine leichte Verstärkung, weiche Begrenzung und konservative Rauschunterdrückung auf das lokale Mikrofon an. Keine Echounterdrückung. Standardmäßig aus.', + audioEnhancementDesc: + 'Wendet eine leichte Verstärkung, weiche Begrenzung und konservative Rauschunterdrückung auf das lokale Mikrofon an. Keine Echounterdrückung. Standardmäßig aus.', muteDuringRecordingLabel: 'Während der Aufnahme stummschalten', muteDuringRecordingDesc: 'Schaltet die Systemausgabe während der Spracheingabe vorübergehend stumm, um Lautsprecherechos zu vermeiden.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cdf5dc2da..ed452f463 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1322,6 +1322,16 @@ export const en: typeof zhCN = { cursorContextDesc: 'Send a short stretch of text around the cursor with polish requests as reference for names, homophones and pronouns (macOS / Windows / Android). Dictation, quick notes and cloud notes all use it, but the context itself is never written to notes, history or logs; with a cloud model it is sent to that provider with the polish request. This switch is separate from local vocabulary learning. Password fields, terminals and known sensitive apps are excluded.', }, + localMigration: { + title: 'Local configuration migration', + description: + 'Export all portable local configuration to a versioned bundle, or restore it on another Android device. Share mode is redacted by default; personal migration can carry credentials only with password protection. The native flow previews the source, version, categories, and changes before Merge or Replace.', + safetyNote: + 'System permissions, OAuth sessions, device paths, and runtime state never migrate. A local backup is created before import.', + open: 'Open local migration', + opening: 'Opening…', + openFailed: 'Could not open the local migration flow. Try again from Settings.', + }, codingConsole: { title: 'Claude Console', desc: 'Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.', @@ -1490,7 +1500,8 @@ export const en: typeof zhCN = { lowLatencyDictationDesc: 'Keep a microphone stream ready and retain up to 500 ms in memory so speech immediately after the hotkey is less likely to be missed. Audio is not saved or uploaded until you confirm recording; this uses more power and keeps the microphone indicator active.', audioEnhancementLabel: 'Lightweight microphone enhancement', - audioEnhancementDesc: 'Applies light gain, soft limiting, and conservative noise suppression to the local microphone. It does not include echo cancellation. Off by default.', + audioEnhancementDesc: + 'Applies light gain, soft limiting, and conservative noise suppression to the local microphone. It does not include echo cancellation. Off by default.', muteDuringRecordingLabel: 'Mute while recording', muteDuringRecordingDesc: 'Temporarily mute system output during voice input to avoid speaker echo.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 92a69078e..877fd14fb 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1339,6 +1339,16 @@ export const es: typeof zhCN = { cursorContextDesc: 'Envía un fragmento breve del texto cercano al cursor al modelo como referencia para nombres propios, homófonos y pronombres al pulir (macOS / Windows / Android). El dictado, las notas rápidas y las notas en la nube lo usan, pero el contexto en sí nunca se guarda en notas, historial ni registros; con un modelo en la nube se envía a ese proveedor junto con la solicitud de pulido. Es independiente del aprendizaje local. Se excluyen contraseñas, terminales y aplicaciones sensibles conocidas.', }, + localMigration: { + title: 'Migración de configuración local', + description: + 'Exporta toda la configuración local portable a un paquete versionado o restáurala en otro dispositivo Android. El modo de compartir está anonimizado de forma predeterminada; la migración personal solo puede incluir credenciales protegidas con contraseña. El flujo nativo muestra el origen, la versión, las categorías y los cambios antes de fusionar o reemplazar.', + safetyNote: + 'Los permisos del sistema, las sesiones OAuth, las rutas del dispositivo y el estado de ejecución nunca se migran. Se crea una copia local antes de importar.', + open: 'Abrir migración local', + opening: 'Abriendo…', + openFailed: 'No se pudo abrir la migración local. Inténtalo de nuevo desde Ajustes.', + }, codingConsole: { title: 'Consola de Claude', desc: 'Detecta Claude Code y MCP para controlar el ordenador. Ejecuta Claude sin interfaz, con límites de seguridad, y consulta la salida progresiva y el coste.', @@ -1511,7 +1521,8 @@ export const es: typeof zhCN = { lowLatencyDictationDesc: 'Mantén preparado el flujo del micrófono y conserva hasta 500 ms en memoria para reducir la pérdida de voz justo después del atajo. El audio no se guarda ni se sube antes de confirmar la grabación; consume más energía y mantiene activo el indicador del micrófono.', audioEnhancementLabel: 'Mejora ligera del micrófono', - audioEnhancementDesc: 'Aplica una ganancia ligera, limitación suave y reducción de ruido conservadora al micrófono local. No incluye cancelación de eco. Desactivada de forma predeterminada.', + audioEnhancementDesc: + 'Aplica una ganancia ligera, limitación suave y reducción de ruido conservadora al micrófono local. No incluye cancelación de eco. Desactivada de forma predeterminada.', muteDuringRecordingLabel: 'Silenciar durante la grabación', muteDuringRecordingDesc: 'Silencia temporalmente el sonido del sistema durante la entrada de voz para evitar el eco de los altavoces.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index c365a4985..66f280fc9 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1359,6 +1359,16 @@ export const fr: typeof zhCN = { cursorContextDesc: 'Envoyer un court extrait du texte autour du curseur au modèle comme référence pour les noms propres, les homophones et les pronoms lors de la reformulation (macOS / Windows / Android). La dictée, les notes rapides et les notes cloud l’utilisent, mais le contexte lui-même n’est jamais écrit dans les notes, l’historique ou les journaux ; avec un modèle cloud, il est envoyé à ce fournisseur avec la demande de reformulation. Ce réglage est indépendant de l’apprentissage local. Les champs de mot de passe, les terminaux et les applications sensibles connues sont exclus.', }, + localMigration: { + title: 'Migration de la configuration locale', + description: + 'Exportez toute la configuration locale portable dans un paquet versionné ou restaurez-la sur un autre appareil Android. Le mode partage est anonymisé par défaut ; une migration personnelle ne peut inclure des identifiants qu’avec une protection par mot de passe. Le flux natif affiche la source, la version, les catégories et les changements avant fusion ou remplacement.', + safetyNote: + 'Les autorisations système, sessions OAuth, chemins de l’appareil et états d’exécution ne sont jamais migrés. Une sauvegarde locale est créée avant l’importation.', + open: 'Ouvrir la migration locale', + opening: 'Ouverture…', + openFailed: 'Impossible d’ouvrir la migration locale. Réessayez depuis les réglages.', + }, codingConsole: { title: 'Console Claude', desc: 'Détectez Claude Code et le MCP de contrôle de l’ordinateur, puis exécutez Claude sans interface avec des protections. Consultez la sortie progressive et le coût.', @@ -1531,7 +1541,8 @@ export const fr: typeof zhCN = { lowLatencyDictationDesc: 'Garde un flux microphone prêt et conserve jusqu’à 500 ms en mémoire afin de réduire les mots perdus juste après le raccourci. Aucun audio n’est enregistré ni envoyé avant confirmation ; ce mode consomme davantage et maintient l’indicateur du microphone actif.', audioEnhancementLabel: 'Amélioration légère du microphone', - audioEnhancementDesc: 'Applique un gain léger, une limitation douce et une réduction prudente du bruit au microphone local. N’inclut pas l’annulation d’écho. Désactivée par défaut.', + audioEnhancementDesc: + 'Applique un gain léger, une limitation douce et une réduction prudente du bruit au microphone local. N’inclut pas l’annulation d’écho. Désactivée par défaut.', muteDuringRecordingLabel: 'Couper le son pendant l’enregistrement', muteDuringRecordingDesc: 'Coupe temporairement le son système pendant la saisie vocale pour éviter l’écho des haut-parleurs.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f49584fe9..35019ee96 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1308,6 +1308,16 @@ export const ja: typeof zhCN = { cursorContextDesc: '推敲時にカーソル付近の短い文章を参考情報としてモデルへ送信し、固有名詞・同音語・指示語の判断に使います(macOS / Windows / Android)。音声入力、クイックメモ、クラウドメモのいずれも参照しますが、コンテキスト自体がメモ・履歴・ログに書き込まれることはありません。クラウドモデル使用時は、推敲リクエストとともにそのプロバイダーへ送信されます。端末内の単語学習とは独立した設定です。パスワード欄、ターミナル、既知の機密アプリは除外します。', }, + localMigration: { + title: 'ローカル設定の移行', + description: + '移行可能なローカル設定をバージョン付きバンドルに書き出すか、別の Android 端末に復元します。共有モードはデフォルトで匿名化され、個人移行で認証情報を含める場合はパスワード保護が必要です。ネイティブ画面で統合または置換の前に、出所、バージョン、カテゴリ、変更内容を確認できます。', + safetyNote: + 'システム権限、OAuth セッション、端末パス、実行時状態は移行されません。インポート前にローカルバックアップを作成します。', + open: 'ローカル移行を開く', + opening: '開いています…', + openFailed: 'ローカル移行を開けませんでした。設定からもう一度お試しください。', + }, codingConsole: { title: 'Claude コンソール', desc: 'ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。', @@ -1478,7 +1488,8 @@ export const ja: typeof zhCN = { lowLatencyDictationDesc: 'マイクストリームを準備し、最大 500 ミリ秒をメモリに保持して、ホットキー直後の音声が欠落しにくくします。録音を確定するまで音声は保存・アップロードされません。電力を多く使い、マイク使用中の表示が続きます。', audioEnhancementLabel: '軽量マイク拡張', - audioEnhancementDesc: 'ローカルマイクに軽いノイズ抑制、ゲイン、ソフトリミッターを適用します。エコーキャンセルは含みません。既定ではオフです。', + audioEnhancementDesc: + 'ローカルマイクに軽いノイズ抑制、ゲイン、ソフトリミッターを適用します。エコーキャンセルは含みません。既定ではオフです。', muteDuringRecordingLabel: '録音中はミュート', muteDuringRecordingDesc: '録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 5e94f7c51..7f54e859a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1300,6 +1300,16 @@ export const ko: typeof zhCN = { cursorContextDesc: '다듬기 요청 시 커서 주변의 짧은 텍스트를 참고 자료로 모델에 보내 고유명사, 동음이의어, 지시어 판단에 사용합니다(macOS / Windows / Android). 받아쓰기, 빠른 메모, 클라우드 메모 모두 참고하지만 컨텍스트 자체는 메모, 기록, 로그에 저장되지 않습니다. 클라우드 모델을 사용하면 다듬기 요청과 함께 해당 제공업체로 전송됩니다. 기기 내 단어 학습과 별도 설정입니다. 비밀번호 입력란, 터미널, 알려진 민감한 앱은 제외됩니다.', }, + localMigration: { + title: '로컬 구성 마이그레이션', + description: + '이식 가능한 모든 로컬 구성을 버전이 지정된 번들로 내보내거나 다른 Android 기기에 복원합니다. 공유 모드는 기본적으로 비식별화되며, 개인 마이그레이션에 자격 증명을 포함하려면 비밀번호 보호가 필요합니다. 병합 또는 교체 전에 네이티브 화면에서 출처, 버전, 범주와 변경 내용을 확인할 수 있습니다.', + safetyNote: + '시스템 권한, OAuth 세션, 기기 경로와 실행 상태는 마이그레이션되지 않습니다. 가져오기 전에 로컬 백업을 만듭니다.', + open: '로컬 마이그레이션 열기', + opening: '여는 중…', + openFailed: '로컬 마이그레이션을 열 수 없습니다. 설정에서 다시 시도하세요.', + }, codingConsole: { title: 'Claude 콘솔', desc: '로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.', @@ -1466,7 +1476,8 @@ export const ko: typeof zhCN = { lowLatencyDictationDesc: '마이크 스트림을 준비하고 최대 500ms를 메모리에 보관해 단축키 직후의 음성이 누락될 가능성을 줄입니다. 녹음을 확정하기 전에는 오디오를 저장하거나 업로드하지 않습니다. 전력 사용량이 늘고 마이크 표시가 계속 켜져 있습니다.', audioEnhancementLabel: '가벼운 마이크 향상', - audioEnhancementDesc: '로컬 마이크에 가벼운 소음 억제, 게인과 소프트 리미터를 적용합니다. 에코 제거는 포함하지 않으며 기본값은 꺼짐입니다.', + audioEnhancementDesc: + '로컬 마이크에 가벼운 소음 억제, 게인과 소프트 리미터를 적용합니다. 에코 제거는 포함하지 않으며 기본값은 꺼짐입니다.', muteDuringRecordingLabel: '녹음 중 음소거', muteDuringRecordingDesc: '녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 0c15415f9..aa266c7ef 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1264,6 +1264,15 @@ export const zhCN = { cursorContextDesc: '润色时把光标附近的一小段文字发给模型作参考,用于专有词、同音词和指代的判断(macOS / Windows / Android)。听写、速记和云笔记都会参考,但上下文本身不会写入笔记、历史或日志;使用云端模型时会随本次润色请求发送给该模型服务。此开关与本地手改学词独立;排除密码框、终端及已知敏感应用。', }, + localMigration: { + title: '本地配置迁移', + description: + '将所有可迁移的本地配置导出为带版本的迁移文件,或在另一台 Android 设备上恢复。分享模式默认脱敏;个人迁移只有在密码保护下才可以携带凭据。原生流程会在合并或替换前展示来源、版本、类别和变更摘要。', + safetyNote: '系统权限、OAuth 会话、设备路径和运行时状态不会迁移。导入前会自动创建本地备份。', + open: '打开本地迁移', + opening: '正在打开…', + openFailed: '无法打开本地迁移流程,请从设置页重试。', + }, codingConsole: { title: 'Claude 控制台', desc: '检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 364b0794e..2377476fa 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1264,6 +1264,16 @@ export const zhTW: typeof zhCN = { cursorContextDesc: '潤色時把游標附近的一小段文字傳給模型作參考,用於專有詞、同音詞和指代的判斷(macOS / Windows / Android)。聽寫、速記和雲筆記都會參考,但上下文本身不會寫入筆記、歷史或日誌;使用雲端模型時會隨本次潤色請求傳送給該模型服務。此開關與本機手改學詞獨立;排除密碼欄位、終端機及已知敏感應用程式。', }, + localMigration: { + title: '本機設定遷移', + description: + '將所有可遷移的本機設定匯出為帶版本的遷移檔案,或在另一部 Android 裝置上還原。分享模式預設會去識別化;個人遷移只有在密碼保護下才能攜帶憑證。原生流程會在合併或取代前顯示來源、版本、類別與變更摘要。', + safetyNote: + '系統權限、OAuth 工作階段、裝置路徑與執行狀態不會遷移。匯入前會自動建立本機備份。', + open: '開啟本機遷移', + opening: '正在開啟…', + openFailed: '無法開啟本機遷移流程,請從設定頁重試。', + }, codingConsole: { title: 'Claude 主控臺', desc: '偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。', diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 4510f103b..61685646f 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -17,6 +17,9 @@ export { // shared export { isTauri, invokeOrMock, getPlatformCapabilities } from './shared'; +// local migration +export { openAndroidLocalMigration } from './local-migration'; + // settings export type { StartupSnapshot } from './settings'; export { diff --git a/openless-all/app/src/lib/ipc/local-migration.test.ts b/openless-all/app/src/lib/ipc/local-migration.test.ts new file mode 100644 index 000000000..933b4612e --- /dev/null +++ b/openless-all/app/src/lib/ipc/local-migration.test.ts @@ -0,0 +1,12 @@ +import { openAndroidLocalMigration } from './local-migration'; + +if (typeof window !== 'undefined') { + throw new Error('local migration IPC mock test must run without a browser window'); +} + +const result = await openAndroidLocalMigration(); +if (result !== undefined) { + throw new Error('Android local migration mock should resolve without a native result'); +} + +console.log('local-migration IPC mock contract passed'); diff --git a/openless-all/app/src/lib/ipc/local-migration.ts b/openless-all/app/src/lib/ipc/local-migration.ts new file mode 100644 index 000000000..5f6cae199 --- /dev/null +++ b/openless-all/app/src/lib/ipc/local-migration.ts @@ -0,0 +1,6 @@ +import { invokeOrMock } from './shared'; + +/** Opens the native Android SAF migration flow from the application settings. */ +export function openAndroidLocalMigration(): Promise { + return invokeOrMock('open_android_local_migration', undefined, () => undefined); +} diff --git a/openless-all/app/src/pages/settings/LocalMigrationSection.tsx b/openless-all/app/src/pages/settings/LocalMigrationSection.tsx new file mode 100644 index 000000000..70345c5ef --- /dev/null +++ b/openless-all/app/src/pages/settings/LocalMigrationSection.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getPlatformCapabilities, type PlatformCapabilities } from '../../lib/platform'; +import { openAndroidLocalMigration } from '../../lib/ipc'; +import { Btn, Card } from '../_atoms'; +import { SectionTitle } from './shared'; + +/** Application-level Android entry point for the native local migration flow. */ +export function LocalMigrationSection() { + const { t } = useTranslation(); + const [platform, setPlatform] = useState(null); + const [opening, setOpening] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + let alive = true; + void getPlatformCapabilities().then((next) => { + if (alive) setPlatform(next); + }); + return () => { + alive = false; + }; + }, []); + + if (platform?.platform !== 'android') return null; + + const openMigration = async () => { + setOpening(true); + setError(false); + try { + await openAndroidLocalMigration(); + } catch { + setError(true); + } finally { + setOpening(false); + } + }; + + return ( + + {t('settings.localMigration.title')} +

+ {t('settings.localMigration.description')} +

+

+ {t('settings.localMigration.safetyNote')} +

+ void openMigration()} disabled={opening}> + {opening ? t('settings.localMigration.opening') : t('settings.localMigration.open')} + + {error && ( +

+ {t('settings.localMigration.openFailed')} +

+ )} +
+ ); +} diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index c04d6d5e1..a5c8c4675 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -18,6 +18,7 @@ import { MarketplaceSection } from './MarketplaceSection'; import { PermissionsSection } from './PermissionsSection'; import { DataStorageSection } from './DataStorageSection'; import { CloudSyncSection } from './CloudSyncSection'; +import { LocalMigrationSection } from './LocalMigrationSection'; import { LocalModelsSection } from './models/LocalModelsSection'; import { LocalModelsNavContext } from './models/modelsNav'; import { DebugToolsSection } from './DebugToolsSection'; @@ -341,6 +342,7 @@ export function PrivacyTab() { + ); From 5ebacf98979b207ef18d9f129054b004de52e7a9 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 16 Oct 2026 15:09:42 +0800 Subject: [PATCH 07/15] fix(android): recover stale local migration previews Co-Authored-By: Claude Fable 5 --- .../kotlin/OpenLessLocalMigrationActivity.kt | 103 ++++++++++++++++-- .../scripts/local-bundle-contract.test.mjs | 10 ++ .../src-tauri/src/android/native_bridge.rs | 2 +- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt index dfc1bf85e..4ae167ab1 100644 --- a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt @@ -11,6 +11,8 @@ import android.view.ViewGroup import android.widget.EditText import android.widget.FrameLayout import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup import android.widget.ScrollView import android.widget.TextView import android.widget.Toast @@ -147,7 +149,7 @@ class OpenLessLocalMigrationActivity : Activity() { textSize = 13f setTextColor(tone(Color.rgb(170, 170, 170), Color.rgb(90, 90, 95))) isClickable = true - setOnClickListener { launchLegacyImportPicker() } + setOnClickListener { showLegacyImportWarning() } setPadding(0, dp(18), 0, dp(12)) }) content.addView(TextView(this).apply { @@ -251,6 +253,20 @@ class OpenLessLocalMigrationActivity : Activity() { .onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() } } + private fun showLegacyImportWarning() { + android.app.AlertDialog.Builder(this) + .setTitle(ui("导入旧版 JSON", "Import legacy JSON")) + .setMessage( + ui( + "旧版 JSON 是明文兼容格式,可能包含 API key 或 token,不具备 .omb 的密码加密和完整性校验。请只导入可信来源;新版 .omb 解压后的 documents.json 必须通过“导入迁移文件”导入。", + "Legacy JSON is a plaintext compatibility format and may contain API keys or tokens; it does not have the password encryption or integrity checks of .omb. Import only from a trusted source. The documents.json extracted from a new .omb bundle must be imported through “Import migration bundle”.", + ), + ) + .setPositiveButton(ui("选择文件", "Choose file")) { _, _ -> launchLegacyImportPicker() } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + private fun launchLegacyImportPicker() { val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(android.content.Intent.CATEGORY_OPENABLE) @@ -321,7 +337,14 @@ class OpenLessLocalMigrationActivity : Activity() { private fun showImportCategoryDialog(json: String) { val present = OpenLessSettingsExport.categoriesPresent(json) if (present.isEmpty()) { - Toast.makeText(this, ui("这不是一个有效的配置文件", "Not a valid settings file"), Toast.LENGTH_SHORT).show() + Toast.makeText( + this, + ui( + "这不是旧版设置 JSON;如果文件是 .omb 解压目录中的 documents.json,请使用“导入迁移文件”导入整个 .omb 文件。", + "This is not a legacy settings JSON. If it is documents.json from an extracted .omb bundle, use “Import migration bundle” with the complete .omb file.", + ), + Toast.LENGTH_LONG, + ).show() return } val categories = present.toList() @@ -428,7 +451,11 @@ class OpenLessLocalMigrationActivity : Activity() { .show() } - private fun showMigrationRestoreMode(bundleBase64: String, password: String) { + private fun showMigrationRestoreMode( + bundleBase64: String, + password: String, + allowStaleRetry: Boolean = true, + ) { Thread { val previewResult = runCatching { val response = org.json.JSONObject( @@ -439,7 +466,9 @@ class OpenLessLocalMigrationActivity : Activity() { ), ) check(response.optBoolean("ok")) { response.optString("error", "preview failed") } - response.getJSONObject("payload") + response.getJSONObject("payload").also { preview -> + check(preview.optString("observedGeneration").isNotBlank()) { "preview missing observed generation" } + } } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread @@ -447,7 +476,14 @@ class OpenLessLocalMigrationActivity : Activity() { val conflicts = preview.getJSONArray("conflicts") val expectedGeneration = preview.optString("observedGeneration") chooseMigrationConflicts(bundleBase64, password, conflicts, 0, org.json.JSONArray()) { - showMigrationRestoreModeAfterConflicts(bundleBase64, password, expectedGeneration, conflicts, it) + showMigrationRestoreModeAfterConflicts( + bundleBase64, + password, + expectedGeneration, + conflicts, + it, + allowStaleRetry, + ) } }.onFailure { error -> Toast.makeText(this, ui("预览失败:${error.message}", "Preview failed: ${error.message}"), Toast.LENGTH_LONG).show() @@ -472,19 +508,49 @@ class OpenLessLocalMigrationActivity : Activity() { val kind = conflict.optString("kind") val reason = conflict.optString("reason") val credential = if (conflict.optBoolean("isCredentialUnit")) ui("(凭据关联渠道)", " (credential-linked channel)") else "" - var selected: String? = null + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(24), dp(8), dp(24), 0) + } + content.addView(TextView(this).apply { + text = "$kind / $reason$credential" + textSize = 14f + setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(50, 50, 55))) + setPadding(0, 0, 0, dp(8)) + }) + val options = RadioGroup(this).apply { + orientation = LinearLayout.VERTICAL + } + fun option(text: String): RadioButton = RadioButton(this).apply { + this.text = text + textSize = 15f + minHeight = dp(48) + gravity = Gravity.CENTER_VERTICAL + setTextColor(tone(Color.WHITE, Color.rgb(35, 35, 40))) + buttonTintList = android.content.res.ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110)), tone(Color.rgb(190, 190, 190), Color.rgb(90, 90, 95))), + ) + } + val keepLocal = option(ui("保留当前设备", "Keep this device")).apply { id = View.generateViewId() } + val useBundle = option(ui("使用迁移文件", "Use migration file")).apply { id = View.generateViewId() } + options.addView(keepLocal) + options.addView(useBundle) + content.addView(options, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + val dialog = android.app.AlertDialog.Builder(this) .setTitle(ui("选择冲突 ${index + 1}/${conflicts.length()}", "Choose conflict ${index + 1}/${conflicts.length()}")) - .setMessage("$kind / $reason$credential") - .setSingleChoiceItems(arrayOf(ui("保留当前设备", "Keep this device"), ui("使用迁移文件", "Use migration file")), -1) { _, which -> - selected = if (which == 0) "local" else "remote" - } + .setView(content) .setPositiveButton(ui("继续", "Continue"), null) .setNegativeButton(ui("取消", "Cancel"), null) .create() dialog.setOnShowListener { dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val side = selected + val side = when (options.checkedRadioButtonId) { + keepLocal.id -> "local" + useBundle.id -> "remote" + else -> null + } if (side == null) { Toast.makeText(this, ui("必须选择一侧", "Choose one side"), Toast.LENGTH_SHORT).show() return@setOnClickListener @@ -521,6 +587,7 @@ class OpenLessLocalMigrationActivity : Activity() { expectedGeneration: String, conflicts: org.json.JSONArray, choices: org.json.JSONArray, + allowStaleRetry: Boolean, ) { val modes = arrayOf(ui("合并", "Merge"), ui("替换", "Replace")) android.app.AlertDialog.Builder(this) @@ -573,7 +640,19 @@ class OpenLessLocalMigrationActivity : Activity() { Toast.makeText(this, ui("核心数据已导入,但 Android 偏好未应用:${error.message}", "Core data imported, but Android preferences were not applied: ${error.message}"), Toast.LENGTH_LONG).show() } }.onFailure { error -> - Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() + if (allowStaleRetry && error.message?.contains("migration_bundle_stale_preview") == true) { + Toast.makeText( + this, + ui( + "本地配置在确认后发生变化,正在重新预览;请再次确认导入方式。", + "Local configuration changed after review. Rechecking the preview; please confirm the restore mode again.", + ), + Toast.LENGTH_LONG, + ).show() + showMigrationRestoreMode(bundleBase64, password, allowStaleRetry = false) + } else { + Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() + } } } }.start() diff --git a/openless-all/app/scripts/local-bundle-contract.test.mjs b/openless-all/app/scripts/local-bundle-contract.test.mjs index 421ae0662..6398ba119 100644 --- a/openless-all/app/scripts/local-bundle-contract.test.mjs +++ b/openless-all/app/scripts/local-bundle-contract.test.mjs @@ -26,8 +26,17 @@ assert.match(migrationActivity, /nativeInspectLocalMigrationBundle/); assert.match(migrationActivity, /nativePreviewLocalMigrationBundle/); assert.match(migrationActivity, /nativeImportLocalMigrationBundle/); assert.match(migrationActivity, /launchLegacyImportPicker/); +assert.match(migrationActivity, /showLegacyImportWarning/); assert.match(migrationActivity, /OpenLessSettingsExport\.import\(/); assert.match(migrationActivity, /OpenLessContentWriter\.writeBytes/); +assert.match(migrationActivity, /RadioGroup/); +assert.match(migrationActivity, /RadioButton/); +assert.match(migrationActivity, /checkedRadioButtonId/); +assert.doesNotMatch(migrationActivity, /setSingleChoiceItems/); +assert.match(migrationActivity, /migration_bundle_stale_preview/); +assert.match(migrationActivity, /showMigrationRestoreMode\(bundleBase64, password, allowStaleRetry = false\)/); +assert.match(migrationActivity, /observedGeneration.*isNotBlank/); +assert.match(migrationActivity, /documents\.json/); assert.doesNotMatch(migrationActivity, /pendingExportJson/); assert.doesNotMatch( keyboardActivity, @@ -42,6 +51,7 @@ assert.match(container, /CredentialPolicy::PasswordEncrypted/); assert.match(container, /file\.encrypted\(\)/); assert.match(container, /safe_entry_name/); assert.match(bridge, /publish_local_migration_state_changed/); +assert.match(bridge, /missing expected generation/); assert.match(scaffolding, /OpenLessMigrationBundlePolicy\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleContractTest\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleInstrumentedTest\.kt/); diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index b834678fa..55fe84e55 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -374,7 +374,7 @@ fn import_local_migration_bundle_json( let choices: Vec = serde_json::from_str(choices_json).map_err(|_| "invalid conflict choices")?; let expected_generation = if expected_generation.is_empty() { - None + return Err("missing expected generation".into()); } else { let value = expected_generation .parse::() From 6a8ca6b31248093b3982dbd79606d0a5b01052f7 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 16 Oct 2026 16:47:21 +0800 Subject: [PATCH 08/15] debug(android): instrument local migration flow Co-Authored-By: Claude Fable 5 --- .../kotlin/OpenLessLocalMigrationActivity.kt | 369 ++++++++++++++++-- 1 file changed, 337 insertions(+), 32 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt index 4ae167ab1..ec9e1ff2e 100644 --- a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt @@ -16,9 +16,11 @@ import android.widget.RadioGroup import android.widget.ScrollView import android.widget.TextView import android.widget.Toast +import android.util.Log import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import java.util.UUID /** * Full-screen native flow for exporting and importing the versioned local @@ -35,6 +37,7 @@ class OpenLessLocalMigrationActivity : Activity() { private val requestCodeImportOpen = 4202 private val requestCodeLegacyImportOpen = 4203 private val maxMigrationBundleBytes = OpenLessMigrationBundlePolicy.MAX_BUNDLE_BYTES + private val migrationDebugRunId = UUID.randomUUID().toString() private data class PendingExportState( val mode: String, val includeCredentials: Boolean, @@ -57,6 +60,42 @@ class OpenLessLocalMigrationActivity : Activity() { private fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light + private fun debugMigrationEvent( + probeId: String, + hypothesisIds: List, + event: String, + outcome: String, + safe: Map = emptyMap(), + ) { + val safeJson = org.json.JSONObject() + safe.forEach { (key, value) -> safeJson.put(key, value ?: org.json.JSONObject.NULL) } + val record = org.json.JSONObject() + .put("schema", "diagnosing-bugs.v1") + .put("session_id", "issue-1192-android-migration") + .put("run_id", migrationDebugRunId) + .put("probe_id", probeId) + .put("hypothesis_ids", org.json.JSONArray(hypothesisIds)) + .put("event", event) + .put("outcome", outcome) + .put("safe", safeJson) + .put("ts", System.currentTimeMillis()) + Log.i("DEBUG-MIGRATION-1192", record.toString()) + } + + private fun migrationErrorCode(error: Throwable): String { + val errorCode = Regex("migration_bundle_[a-z0-9_]+") + .find(error.message.orEmpty()) + ?.value + return errorCode ?: error::class.java.simpleName.ifBlank { "unknown_error" } + } + + private fun migrationResponseErrorCode(response: org.json.JSONObject): String { + val errorCode = Regex("migration_bundle_[a-z0-9_]+") + .find(response.optString("error")) + ?.value + return errorCode ?: "response_error" + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val retained = lastNonConfigurationInstance as? PendingExportState @@ -278,6 +317,18 @@ class OpenLessLocalMigrationActivity : Activity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { super.onActivityResult(requestCode, resultCode, data) + if (requestCode == requestCodeImportOpen) { + debugMigrationEvent( + "P1", + listOf("H1", "H5"), + "activity_result", + if (resultCode == RESULT_OK && data?.data != null) "accepted" else "cancelled", + mapOf( + "resultOk" to (resultCode == RESULT_OK), + "uriPresent" to (data?.data != null), + ), + ) + } if (resultCode != RESULT_OK || data == null) { if (requestCode == requestCodeExportSave) clearPendingExport() return @@ -300,17 +351,91 @@ class OpenLessLocalMigrationActivity : Activity() { val uri = data.data ?: return Thread { val result = runCatching { - val bytes = OpenLessContentReader.readBytes(this, uri.toString(), maxMigrationBundleBytes) - ?: error("read selected document failed") - val bundleBase64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) - val envelope = org.json.JSONObject(OpenLessNative.nativeInspectLocalMigrationBundle(bundleBase64)) - check(envelope.optBoolean("ok")) { envelope.optString("error", "invalid migration bundle") } - bundleBase64 to envelope.getJSONObject("payload") + val readResult = OpenLessContentReader.readStrict(this, uri.toString(), maxMigrationBundleBytes) + val bytes = when (readResult) { + is OpenLessReadResult.Success -> { + debugMigrationEvent( + "P1", + listOf("H1"), + "read_result", + "success", + mapOf("bytes" to readResult.bytes.size), + ) + readResult.bytes + } + is OpenLessReadResult.Failure -> { + debugMigrationEvent( + "P1", + listOf("H1"), + "read_result", + "failure", + mapOf("reason" to readResult.reason.name), + ) + error("read selected document failed") + } + } + val envelope = try { + debugMigrationEvent("P2", listOf("H2"), "inspect_call", "started") + org.json.JSONObject(OpenLessNative.nativeInspectLocalMigrationBundle( + android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP), + )) + } catch (error: Throwable) { + debugMigrationEvent( + "P2", + listOf("H2"), + "inspect_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) + throw error + } + val inspectOk = envelope.optBoolean("ok") + if (!inspectOk) { + debugMigrationEvent( + "P2", + listOf("H2"), + "inspect_result", + "failure", + mapOf("errorCode" to migrationResponseErrorCode(envelope)), + ) + check(false) { envelope.optString("error", "invalid migration bundle") } + } + val payload = try { + envelope.getJSONObject("payload") + } catch (error: Throwable) { + debugMigrationEvent( + "P2", + listOf("H2"), + "inspect_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) + throw error + } + debugMigrationEvent( + "P2", + listOf("H2"), + "inspect_result", + "success", + mapOf( + "documentCount" to payload.optInt("documentCount"), + "formatVersion" to payload.optInt("formatVersion"), + "credentialsEncrypted" to payload.optBoolean("credentialsEncrypted"), + ), + ) + android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) to payload } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread result.onSuccess { (bundleBase64, inspection) -> showMigrationImportSummary(bundleBase64, inspection) } .onFailure { error -> + debugMigrationEvent( + "P1", + listOf("H1", "H5"), + "import_entry_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) Toast.makeText(this, ui("读取或校验失败:${error.message}", "Read or validation failed: ${error.message}"), Toast.LENGTH_LONG).show() } } @@ -458,17 +583,68 @@ class OpenLessLocalMigrationActivity : Activity() { ) { Thread { val previewResult = runCatching { - val response = org.json.JSONObject( - OpenLessNative.nativePreviewLocalMigrationBundle( - bundleBase64, - password, - OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), + val response = try { + debugMigrationEvent( + "P3", + listOf("H3"), + "preview_call", + "started", + mapOf("allowStaleRetry" to allowStaleRetry), + ) + org.json.JSONObject( + OpenLessNative.nativePreviewLocalMigrationBundle( + bundleBase64, + password, + OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), + ), + ) + } catch (error: Throwable) { + debugMigrationEvent( + "P3", + listOf("H3"), + "preview_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) + throw error + } + if (!response.optBoolean("ok")) { + debugMigrationEvent( + "P3", + listOf("H3"), + "preview_result", + "failure", + mapOf("errorCode" to migrationResponseErrorCode(response)), + ) + throw IllegalStateException(response.optString("error", "preview failed")) + } + val preview = try { + response.getJSONObject("payload").also { preview -> + check(preview.optString("observedGeneration").isNotBlank()) { + "preview missing observed generation" + } + } + } catch (error: Throwable) { + debugMigrationEvent( + "P3", + listOf("H3"), + "preview_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) + throw error + } + debugMigrationEvent( + "P3", + listOf("H3"), + "preview_result", + "success", + mapOf( + "conflictCount" to (preview.optJSONArray("conflicts")?.length() ?: 0), + "observedGenerationPresent" to preview.optString("observedGeneration").isNotBlank(), ), ) - check(response.optBoolean("ok")) { response.optString("error", "preview failed") } - response.getJSONObject("payload").also { preview -> - check(preview.optString("observedGeneration").isNotBlank()) { "preview missing observed generation" } - } + preview } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread @@ -598,26 +774,121 @@ class OpenLessLocalMigrationActivity : Activity() { val applyPortable = mode == "replace" || portableConflictSide(conflicts, choices) != "local" val mergePortableLearning = mode == "merge" && applyPortable val result = runCatching { - val response = org.json.JSONObject( - OpenLessNative.nativeImportLocalMigrationBundle( - bundleBase64, - password, - mode, - choices.toString(), - expectedGeneration, - OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), - ), - ) - check(response.optBoolean("ok")) { response.optString("error", "import failed") } - val payload = response.getJSONObject("payload") - val portableResponse = org.json.JSONObject( - OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), + val response = try { + debugMigrationEvent( + "P4", + listOf("H3", "H4"), + "import_call", + "started", + mapOf( + "mode" to mode, + "choicesCount" to choices.length(), + "expectedGenerationPresent" to expectedGeneration.isNotBlank(), + ), + ) + org.json.JSONObject( + OpenLessNative.nativeImportLocalMigrationBundle( + bundleBase64, + password, + mode, + choices.toString(), + expectedGeneration, + OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), + ), + ) + } catch (error: Throwable) { + debugMigrationEvent( + "P4", + listOf("H3", "H4"), + "import_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error), "mode" to mode), + ) + throw error + } + if (!response.optBoolean("ok")) { + debugMigrationEvent( + "P4", + listOf("H3", "H4"), + "import_result", + "failure", + mapOf("errorCode" to migrationResponseErrorCode(response), "mode" to mode), + ) + throw IllegalStateException(response.optString("error", "import failed")) + } + val payload = try { + response.getJSONObject("payload") + } catch (error: Throwable) { + debugMigrationEvent( + "P4", + listOf("H4"), + "import_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error), "mode" to mode), + ) + throw error + } + debugMigrationEvent( + "P4", + listOf("H4"), + "import_result", + "success", + mapOf("mode" to mode), ) - check(portableResponse.optBoolean("ok")) { portableResponse.optString("error", "portable settings read failed") } - val portable = portableResponse.getJSONObject("payload").opt("preferences") + val portableResponse = try { + debugMigrationEvent("P5", listOf("H5"), "portable_extract_call", "started") + org.json.JSONObject( + OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), + ) + } catch (error: Throwable) { + debugMigrationEvent( + "P5", + listOf("H5"), + "portable_extract_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) + throw error + } + if (!portableResponse.optBoolean("ok")) { + debugMigrationEvent( + "P5", + listOf("H5"), + "portable_extract_result", + "failure", + mapOf("errorCode" to migrationResponseErrorCode(portableResponse)), + ) + throw IllegalStateException(portableResponse.optString("error", "portable settings read failed")) + } + val portable = try { + portableResponse.getJSONObject("payload").opt("preferences") + } catch (error: Throwable) { + debugMigrationEvent( + "P5", + listOf("H5"), + "portable_extract_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) + throw error + } if (portable !is org.json.JSONObject && portable != org.json.JSONObject.NULL) { + debugMigrationEvent( + "P5", + listOf("H5"), + "portable_extract_result", + "failure", + mapOf("errorCode" to "invalid_portable_preferences"), + ) error("invalid portable Android settings") } + debugMigrationEvent( + "P5", + listOf("H5"), + "portable_extract_result", + "success", + mapOf("preferencesPresent" to (portable is org.json.JSONObject)), + ) Triple( payload, (portable as? org.json.JSONObject)?.toString(), @@ -634,13 +905,38 @@ class OpenLessLocalMigrationActivity : Activity() { } } }.onSuccess { + debugMigrationEvent( + "P5", + listOf("H5"), + "portable_apply_result", + "success", + mapOf( + "applied" to portableOptions.first, + "mergeLearning" to portableOptions.second, + ), + ) Toast.makeText(this, ui("导入完成,备份已保留", "Import complete; backup retained"), Toast.LENGTH_LONG).show() setContentView(buildContent()) }.onFailure { error -> + debugMigrationEvent( + "P5", + listOf("H5"), + "portable_apply_result", + "failure", + mapOf("errorCode" to migrationErrorCode(error)), + ) Toast.makeText(this, ui("核心数据已导入,但 Android 偏好未应用:${error.message}", "Core data imported, but Android preferences were not applied: ${error.message}"), Toast.LENGTH_LONG).show() } }.onFailure { error -> - if (allowStaleRetry && error.message?.contains("migration_bundle_stale_preview") == true) { + val stale = error.message?.contains("migration_bundle_stale_preview") == true + if (stale && allowStaleRetry) { + debugMigrationEvent( + "P3", + listOf("H3"), + "stale_retry", + "started", + mapOf("retryAllowed" to true), + ) Toast.makeText( this, ui( @@ -651,6 +947,15 @@ class OpenLessLocalMigrationActivity : Activity() { ).show() showMigrationRestoreMode(bundleBase64, password, allowStaleRetry = false) } else { + if (stale) { + debugMigrationEvent( + "P3", + listOf("H3"), + "stale_retry", + "blocked", + mapOf("retryAllowed" to false), + ) + } Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() } } From 89786eb16df4ec558576112d19200b7eddbf273c Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 16 Oct 2026 20:19:28 +0800 Subject: [PATCH 09/15] debug(android): record migration generation handoff Co-Authored-By: Claude Fable 5 --- .../app/android/kotlin/OpenLessLocalMigrationActivity.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt index ec9e1ff2e..d48420832 100644 --- a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt @@ -642,6 +642,7 @@ class OpenLessLocalMigrationActivity : Activity() { mapOf( "conflictCount" to (preview.optJSONArray("conflicts")?.length() ?: 0), "observedGenerationPresent" to preview.optString("observedGeneration").isNotBlank(), + "observedGeneration" to preview.optString("observedGeneration").toLongOrNull(), ), ) preview @@ -784,6 +785,7 @@ class OpenLessLocalMigrationActivity : Activity() { "mode" to mode, "choicesCount" to choices.length(), "expectedGenerationPresent" to expectedGeneration.isNotBlank(), + "expectedGeneration" to expectedGeneration.toLongOrNull(), ), ) org.json.JSONObject( From 201b0ff09593c6ef67a1867093d96e4979f321bb Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 16 Oct 2026 21:42:07 +0800 Subject: [PATCH 10/15] debug(android): classify migration stale boundary Co-Authored-By: Claude Fable 5 --- .../kotlin/OpenLessLocalMigrationActivity.kt | 1 + .../app/android/kotlin/OpenLessNative.kt | 1 + .../openless-core/src/cloud_sync_e2ee/api.rs | 26 ++++ .../src/cloud_sync_e2ee_store/mod.rs | 121 +++++++++++++++++- .../scripts/local-bundle-contract.test.mjs | 7 + .../src-tauri/src/android/native_bridge.rs | 36 ++++-- 6 files changed, 180 insertions(+), 12 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt index d48420832..3cc75263a 100644 --- a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt @@ -796,6 +796,7 @@ class OpenLessLocalMigrationActivity : Activity() { choices.toString(), expectedGeneration, OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), + migrationDebugRunId, ), ) } catch (error: Throwable) { diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index bee22536f..0c2d979cf 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -154,5 +154,6 @@ object OpenLessNative { choicesJson: String, expectedGeneration: String, androidPreferencesJson: String, + diagnosticRunId: String, ): String } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs index bcc0d10da..0b36e0d77 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -61,6 +61,32 @@ impl OpenLessBackend { }) } + pub async fn import_local_migration_bundle_with_diagnostics( + &self, + bytes: &[u8], + password: Option, + mode: crate::local_migration_bundle::RestoreMode, + choices: &[crate::cloud_sync_e2ee_documents::ConflictChoice], + expected_generation: Option, + portable_android_preferences: Option, + diagnostic_run_id: &str, + ) -> Result { + self.local_migration_store + .import_local_bundle_with_diagnostics( + bytes, + password, + mode, + choices, + expected_generation, + portable_android_preferences, + Some(diagnostic_run_id), + ) + .await + .map_err(|error| { + BackendError::new(crate::BackendErrorCode::InvalidState, error.to_string()) + }) + } + pub async fn sign_out_account(&self) -> Result<(), BackendError> { if let Some(service) = &self.encrypted_sync { service.sign_out().await.map(|_| ()) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index 15e2c41ae..b688c7747 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -731,6 +731,28 @@ impl CoreSyncStore { choices: &[ConflictChoice], expected_generation: Option, portable_android_preferences: Option, + ) -> Result { + self.import_local_bundle_with_diagnostics( + bytes, + password, + mode, + choices, + expected_generation, + portable_android_preferences, + None, + ) + .await + } + + pub async fn import_local_bundle_with_diagnostics( + &self, + bytes: &[u8], + password: Option, + mode: RestoreMode, + choices: &[ConflictChoice], + expected_generation: Option, + portable_android_preferences: Option, + diagnostic_run_id: Option<&str>, ) -> Result { let scope = self.local_bundle_scope(); let captured = self @@ -742,7 +764,24 @@ impl CoreSyncStore { portable_android_preferences.clone(), )?; let generation = captured.generation; - if expected_generation.is_some_and(|expected| expected != generation) { + let expected_matches = expected_generation.is_none_or(|expected| expected == generation); + emit_local_migration_p6( + diagnostic_run_id, + "early_generation_check", + if expected_matches { + "matched" + } else if expected_generation.is_some_and(|expected| generation > expected) { + "advanced" + } else { + "mismatch" + }, + expected_generation, + Some(generation), + None, + None, + false, + ); + if !expected_matches { return Err(BundleError::StalePreview); } let decoded = decode_bundle(bytes, password.as_ref(), generation)?; @@ -784,9 +823,40 @@ impl CoreSyncStore { local_generation: generation, target_device: self.inner.device.clone(), }; - self.restore_scope(desired, context) + let plan_revision_matches = desired.observed_revision() == context.observed_revision; + emit_local_migration_p6( + diagnostic_run_id, + "restore_plan_check", + if plan_revision_matches { + "matched" + } else { + "mismatch" + }, + expected_generation, + Some(generation), + Some(desired.observed_revision()), + Some(context.observed_revision), + false, + ); + let result = self + .restore_scope(desired, context) .await - .map_err(BundleError::from)?; + .map_err(BundleError::from); + emit_local_migration_p6( + diagnostic_run_id, + "restore_result", + match &result { + Ok(_) => "success", + Err(BundleError::StalePreview) => "stale", + Err(_) => "failure", + }, + expected_generation, + Some(generation), + None, + None, + true, + ); + result?; Ok(inspection) } @@ -1049,5 +1119,50 @@ impl CoreSyncStore { } } +fn emit_local_migration_p6( + diagnostic_run_id: Option<&str>, + event: &'static str, + outcome: &'static str, + expected_generation: Option, + captured_generation: Option, + desired_observed_revision: Option, + context_observed_revision: Option, + restore_reached: bool, +) { + let Some(run_id) = diagnostic_run_id else { + return; + }; + let run_id = if run_id.len() <= 64 + && run_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') + { + run_id + } else { + "invalid" + }; + let record = serde_json::json!({ + "schema": "diagnosing-bugs.v1", + "session_id": "issue-1192-android-migration", + "run_id": run_id, + "probe_id": "P6", + "hypothesis_ids": ["H3", "H4"], + "event": event, + "outcome": outcome, + "safe": { + "expectedGeneration": expected_generation.map(|value| value.get()), + "capturedGeneration": captured_generation.map(|value| value.get()), + "desiredObservedRevision": desired_observed_revision.map(|value| value.get()), + "contextObservedRevision": context_observed_revision.map(|value| value.get()), + "restoreReached": restore_reached, + }, + "ts": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(), + }); + log::info!("[DEBUG-MIGRATION-1192] {record}"); +} + #[cfg(test)] mod tests; diff --git a/openless-all/app/scripts/local-bundle-contract.test.mjs b/openless-all/app/scripts/local-bundle-contract.test.mjs index 6398ba119..b6ccbeae3 100644 --- a/openless-all/app/scripts/local-bundle-contract.test.mjs +++ b/openless-all/app/scripts/local-bundle-contract.test.mjs @@ -10,6 +10,7 @@ const keyboardActivity = read('android/kotlin/OpenLessKeyboardSettingsActivity.k const native = read('android/kotlin/OpenLessNative.kt'); const policy = read('android/kotlin/OpenLessMigrationBundlePolicy.kt'); const container = read('crates/openless-core/src/local_migration_bundle/container.rs'); +const store = read('crates/openless-core/src/cloud_sync_e2ee_store/mod.rs'); const bridge = read('src-tauri/src/android/native_bridge.rs'); const scaffolding = read('scripts/copy-android-scaffolding.mjs'); const manifestMerge = read('scripts/merge-android-overlay-manifest.mjs'); @@ -52,6 +53,12 @@ assert.match(container, /file\.encrypted\(\)/); assert.match(container, /safe_entry_name/); assert.match(bridge, /publish_local_migration_state_changed/); assert.match(bridge, /missing expected generation/); +assert.match(bridge, /diagnostic_run_id/); +assert.match(store, /emit_local_migration_p6/); +assert.match(store, /"probe_id": "P6"/); +assert.match(store, /early_generation_check/); +assert.match(store, /restore_result/); +assert.doesNotMatch(store, /P7/); assert.match(scaffolding, /OpenLessMigrationBundlePolicy\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleContractTest\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleInstrumentedTest\.kt/); diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 55fe84e55..29e0ad39e 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -352,6 +352,7 @@ fn import_local_migration_bundle_json( choices_json: &str, expected_generation: &str, android_preferences_json: &str, + diagnostic_run_id: &str, ) -> String { let result = (|| -> Result { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; @@ -394,14 +395,17 @@ fn import_local_migration_bundle_json( .map_err(|_| "invalid Android portable preferences")?, ) }; - let inspection = tauri::async_runtime::block_on(backend.import_local_migration_bundle( - &bytes, - password, - restore_mode, - &choices, - expected_generation, - android_preferences, - )) + let inspection = tauri::async_runtime::block_on( + backend.import_local_migration_bundle_with_diagnostics( + &bytes, + password, + restore_mode, + &choices, + expected_generation, + android_preferences, + diagnostic_run_id, + ), + ) .map_err(|error| error.message)?; backend.publish_local_migration_state_changed(); tauri::async_runtime::block_on(backend.refresh_and_publish_credentials()) @@ -1762,6 +1766,7 @@ mod jni_exports { choices_json: jstring, expected_generation: jstring, android_preferences: jstring, + diagnostic_run_id: jstring, ) -> jstring { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); @@ -1784,6 +1789,9 @@ mod jni_exports { let android_preferences = env .get_string(&JString::from_raw(android_preferences)) .map(|value| value.to_string_lossy().into_owned()); + let diagnostic_run_id = env + .get_string(&JString::from_raw(diagnostic_run_id)) + .map(|value| value.to_string_lossy().into_owned()); let response = match ( bundle_base64, password, @@ -1791,8 +1799,17 @@ mod jni_exports { choices_json, expected_generation, android_preferences, + diagnostic_run_id, ) { - (Ok(bundle), Ok(password), Ok(mode), Ok(choices), Ok(expected_generation), Ok(android_preferences)) => { + ( + Ok(bundle), + Ok(password), + Ok(mode), + Ok(choices), + Ok(expected_generation), + Ok(android_preferences), + Ok(diagnostic_run_id), + ) => { import_local_migration_bundle_json( &bundle, &password, @@ -1800,6 +1817,7 @@ mod jni_exports { &choices, &expected_generation, &android_preferences, + &diagnostic_run_id, ) } _ => local_bundle_error("invalid JNI input"), From 43b60ce9e2429eb780977b87c09036d99957d225 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 16 Oct 2026 22:31:55 +0800 Subject: [PATCH 11/15] fix(android): align local restore document revision Co-Authored-By: Claude Fable 5 --- .../src/cloud_sync_e2ee_store/mod.rs | 2 +- .../src/cloud_sync_e2ee_store/tests.rs | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index b688c7747..c1099c124 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -819,7 +819,7 @@ impl CoreSyncStore { let context = RestoreContext { scope, operation_id: uuid::Uuid::new_v4().to_string(), - observed_revision: generation, + observed_revision: current.observed_revision(), local_generation: generation, target_device: self.inner.device.clone(), }; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index 55c81c85f..18313bdac 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -452,6 +452,39 @@ async fn personal_bundle_keeps_receiver_device_profile() { && document.id == current.documents().source_device.id)); } +#[tokio::test] +async fn local_bundle_restore_uses_document_revision_not_gate_generation() { + let fixture = Fixture::new(); + fixture + .store + .set_ui_preferences(SecretJson::new(json!({ + "locale": "zh-CN", + "fontScale": "medium", + }))) + .await + .unwrap(); + let artifact = fixture + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let generation = fixture.store.generation().unwrap(); + assert!(generation > Revision::new(0)); + + fixture + .store + .import_local_bundle( + &artifact.bytes, + None, + RestoreMode::Replace, + &[], + Some(generation), + None, + ) + .await + .expect("a local bundle captured at the current generation should restore"); +} + #[tokio::test] async fn disabled_fresh_start_never_requests_local_key() { let fixture = Fixture::new(); From d07d64593a921164a243a7db931a50971c99caa9 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 16 Oct 2026 23:10:39 +0800 Subject: [PATCH 12/15] fix(android): satisfy diagnostic lint checks Co-Authored-By: Claude Fable 5 --- .../app/crates/openless-core/src/cloud_sync_e2ee/api.rs | 1 + .../app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs index 0b36e0d77..6444b0aea 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -61,6 +61,7 @@ impl OpenLessBackend { }) } + #[allow(clippy::too_many_arguments)] pub async fn import_local_migration_bundle_with_diagnostics( &self, bytes: &[u8], diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index c1099c124..21021fd6c 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -744,6 +744,7 @@ impl CoreSyncStore { .await } + #[allow(clippy::too_many_arguments)] pub async fn import_local_bundle_with_diagnostics( &self, bytes: &[u8], @@ -1119,6 +1120,7 @@ impl CoreSyncStore { } } +#[allow(clippy::too_many_arguments)] fn emit_local_migration_p6( diagnostic_run_id: Option<&str>, event: &'static str, From 524067a803e342957ffbdf0d14daac490366d151 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 16 Oct 2026 23:31:33 +0800 Subject: [PATCH 13/15] chore(android): remove migration diagnostics Co-Authored-By: Claude Fable 5 --- .../kotlin/OpenLessLocalMigrationActivity.kt | 372 ++---------------- .../app/android/kotlin/OpenLessNative.kt | 1 - .../openless-core/src/cloud_sync_e2ee/api.rs | 27 -- .../src/cloud_sync_e2ee_store/mod.rs | 123 +----- .../scripts/local-bundle-contract.test.mjs | 7 - .../src-tauri/src/android/native_bridge.rs | 36 +- 6 files changed, 44 insertions(+), 522 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt index 3cc75263a..4ae167ab1 100644 --- a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt @@ -16,11 +16,9 @@ import android.widget.RadioGroup import android.widget.ScrollView import android.widget.TextView import android.widget.Toast -import android.util.Log import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat -import java.util.UUID /** * Full-screen native flow for exporting and importing the versioned local @@ -37,7 +35,6 @@ class OpenLessLocalMigrationActivity : Activity() { private val requestCodeImportOpen = 4202 private val requestCodeLegacyImportOpen = 4203 private val maxMigrationBundleBytes = OpenLessMigrationBundlePolicy.MAX_BUNDLE_BYTES - private val migrationDebugRunId = UUID.randomUUID().toString() private data class PendingExportState( val mode: String, val includeCredentials: Boolean, @@ -60,42 +57,6 @@ class OpenLessLocalMigrationActivity : Activity() { private fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light - private fun debugMigrationEvent( - probeId: String, - hypothesisIds: List, - event: String, - outcome: String, - safe: Map = emptyMap(), - ) { - val safeJson = org.json.JSONObject() - safe.forEach { (key, value) -> safeJson.put(key, value ?: org.json.JSONObject.NULL) } - val record = org.json.JSONObject() - .put("schema", "diagnosing-bugs.v1") - .put("session_id", "issue-1192-android-migration") - .put("run_id", migrationDebugRunId) - .put("probe_id", probeId) - .put("hypothesis_ids", org.json.JSONArray(hypothesisIds)) - .put("event", event) - .put("outcome", outcome) - .put("safe", safeJson) - .put("ts", System.currentTimeMillis()) - Log.i("DEBUG-MIGRATION-1192", record.toString()) - } - - private fun migrationErrorCode(error: Throwable): String { - val errorCode = Regex("migration_bundle_[a-z0-9_]+") - .find(error.message.orEmpty()) - ?.value - return errorCode ?: error::class.java.simpleName.ifBlank { "unknown_error" } - } - - private fun migrationResponseErrorCode(response: org.json.JSONObject): String { - val errorCode = Regex("migration_bundle_[a-z0-9_]+") - .find(response.optString("error")) - ?.value - return errorCode ?: "response_error" - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val retained = lastNonConfigurationInstance as? PendingExportState @@ -317,18 +278,6 @@ class OpenLessLocalMigrationActivity : Activity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { super.onActivityResult(requestCode, resultCode, data) - if (requestCode == requestCodeImportOpen) { - debugMigrationEvent( - "P1", - listOf("H1", "H5"), - "activity_result", - if (resultCode == RESULT_OK && data?.data != null) "accepted" else "cancelled", - mapOf( - "resultOk" to (resultCode == RESULT_OK), - "uriPresent" to (data?.data != null), - ), - ) - } if (resultCode != RESULT_OK || data == null) { if (requestCode == requestCodeExportSave) clearPendingExport() return @@ -351,91 +300,17 @@ class OpenLessLocalMigrationActivity : Activity() { val uri = data.data ?: return Thread { val result = runCatching { - val readResult = OpenLessContentReader.readStrict(this, uri.toString(), maxMigrationBundleBytes) - val bytes = when (readResult) { - is OpenLessReadResult.Success -> { - debugMigrationEvent( - "P1", - listOf("H1"), - "read_result", - "success", - mapOf("bytes" to readResult.bytes.size), - ) - readResult.bytes - } - is OpenLessReadResult.Failure -> { - debugMigrationEvent( - "P1", - listOf("H1"), - "read_result", - "failure", - mapOf("reason" to readResult.reason.name), - ) - error("read selected document failed") - } - } - val envelope = try { - debugMigrationEvent("P2", listOf("H2"), "inspect_call", "started") - org.json.JSONObject(OpenLessNative.nativeInspectLocalMigrationBundle( - android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP), - )) - } catch (error: Throwable) { - debugMigrationEvent( - "P2", - listOf("H2"), - "inspect_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) - throw error - } - val inspectOk = envelope.optBoolean("ok") - if (!inspectOk) { - debugMigrationEvent( - "P2", - listOf("H2"), - "inspect_result", - "failure", - mapOf("errorCode" to migrationResponseErrorCode(envelope)), - ) - check(false) { envelope.optString("error", "invalid migration bundle") } - } - val payload = try { - envelope.getJSONObject("payload") - } catch (error: Throwable) { - debugMigrationEvent( - "P2", - listOf("H2"), - "inspect_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) - throw error - } - debugMigrationEvent( - "P2", - listOf("H2"), - "inspect_result", - "success", - mapOf( - "documentCount" to payload.optInt("documentCount"), - "formatVersion" to payload.optInt("formatVersion"), - "credentialsEncrypted" to payload.optBoolean("credentialsEncrypted"), - ), - ) - android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) to payload + val bytes = OpenLessContentReader.readBytes(this, uri.toString(), maxMigrationBundleBytes) + ?: error("read selected document failed") + val bundleBase64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + val envelope = org.json.JSONObject(OpenLessNative.nativeInspectLocalMigrationBundle(bundleBase64)) + check(envelope.optBoolean("ok")) { envelope.optString("error", "invalid migration bundle") } + bundleBase64 to envelope.getJSONObject("payload") } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread result.onSuccess { (bundleBase64, inspection) -> showMigrationImportSummary(bundleBase64, inspection) } .onFailure { error -> - debugMigrationEvent( - "P1", - listOf("H1", "H5"), - "import_entry_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) Toast.makeText(this, ui("读取或校验失败:${error.message}", "Read or validation failed: ${error.message}"), Toast.LENGTH_LONG).show() } } @@ -583,69 +458,17 @@ class OpenLessLocalMigrationActivity : Activity() { ) { Thread { val previewResult = runCatching { - val response = try { - debugMigrationEvent( - "P3", - listOf("H3"), - "preview_call", - "started", - mapOf("allowStaleRetry" to allowStaleRetry), - ) - org.json.JSONObject( - OpenLessNative.nativePreviewLocalMigrationBundle( - bundleBase64, - password, - OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), - ), - ) - } catch (error: Throwable) { - debugMigrationEvent( - "P3", - listOf("H3"), - "preview_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) - throw error - } - if (!response.optBoolean("ok")) { - debugMigrationEvent( - "P3", - listOf("H3"), - "preview_result", - "failure", - mapOf("errorCode" to migrationResponseErrorCode(response)), - ) - throw IllegalStateException(response.optString("error", "preview failed")) - } - val preview = try { - response.getJSONObject("payload").also { preview -> - check(preview.optString("observedGeneration").isNotBlank()) { - "preview missing observed generation" - } - } - } catch (error: Throwable) { - debugMigrationEvent( - "P3", - listOf("H3"), - "preview_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) - throw error - } - debugMigrationEvent( - "P3", - listOf("H3"), - "preview_result", - "success", - mapOf( - "conflictCount" to (preview.optJSONArray("conflicts")?.length() ?: 0), - "observedGenerationPresent" to preview.optString("observedGeneration").isNotBlank(), - "observedGeneration" to preview.optString("observedGeneration").toLongOrNull(), + val response = org.json.JSONObject( + OpenLessNative.nativePreviewLocalMigrationBundle( + bundleBase64, + password, + OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), ), ) - preview + check(response.optBoolean("ok")) { response.optString("error", "preview failed") } + response.getJSONObject("payload").also { preview -> + check(preview.optString("observedGeneration").isNotBlank()) { "preview missing observed generation" } + } } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread @@ -775,123 +598,26 @@ class OpenLessLocalMigrationActivity : Activity() { val applyPortable = mode == "replace" || portableConflictSide(conflicts, choices) != "local" val mergePortableLearning = mode == "merge" && applyPortable val result = runCatching { - val response = try { - debugMigrationEvent( - "P4", - listOf("H3", "H4"), - "import_call", - "started", - mapOf( - "mode" to mode, - "choicesCount" to choices.length(), - "expectedGenerationPresent" to expectedGeneration.isNotBlank(), - "expectedGeneration" to expectedGeneration.toLongOrNull(), - ), - ) - org.json.JSONObject( - OpenLessNative.nativeImportLocalMigrationBundle( - bundleBase64, - password, - mode, - choices.toString(), - expectedGeneration, - OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), - migrationDebugRunId, - ), - ) - } catch (error: Throwable) { - debugMigrationEvent( - "P4", - listOf("H3", "H4"), - "import_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error), "mode" to mode), - ) - throw error - } - if (!response.optBoolean("ok")) { - debugMigrationEvent( - "P4", - listOf("H3", "H4"), - "import_result", - "failure", - mapOf("errorCode" to migrationResponseErrorCode(response), "mode" to mode), - ) - throw IllegalStateException(response.optString("error", "import failed")) - } - val payload = try { - response.getJSONObject("payload") - } catch (error: Throwable) { - debugMigrationEvent( - "P4", - listOf("H4"), - "import_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error), "mode" to mode), - ) - throw error - } - debugMigrationEvent( - "P4", - listOf("H4"), - "import_result", - "success", - mapOf("mode" to mode), + val response = org.json.JSONObject( + OpenLessNative.nativeImportLocalMigrationBundle( + bundleBase64, + password, + mode, + choices.toString(), + expectedGeneration, + OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), + ), ) - val portableResponse = try { - debugMigrationEvent("P5", listOf("H5"), "portable_extract_call", "started") - org.json.JSONObject( - OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), - ) - } catch (error: Throwable) { - debugMigrationEvent( - "P5", - listOf("H5"), - "portable_extract_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) - throw error - } - if (!portableResponse.optBoolean("ok")) { - debugMigrationEvent( - "P5", - listOf("H5"), - "portable_extract_result", - "failure", - mapOf("errorCode" to migrationResponseErrorCode(portableResponse)), - ) - throw IllegalStateException(portableResponse.optString("error", "portable settings read failed")) - } - val portable = try { - portableResponse.getJSONObject("payload").opt("preferences") - } catch (error: Throwable) { - debugMigrationEvent( - "P5", - listOf("H5"), - "portable_extract_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) - throw error - } + check(response.optBoolean("ok")) { response.optString("error", "import failed") } + val payload = response.getJSONObject("payload") + val portableResponse = org.json.JSONObject( + OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), + ) + check(portableResponse.optBoolean("ok")) { portableResponse.optString("error", "portable settings read failed") } + val portable = portableResponse.getJSONObject("payload").opt("preferences") if (portable !is org.json.JSONObject && portable != org.json.JSONObject.NULL) { - debugMigrationEvent( - "P5", - listOf("H5"), - "portable_extract_result", - "failure", - mapOf("errorCode" to "invalid_portable_preferences"), - ) error("invalid portable Android settings") } - debugMigrationEvent( - "P5", - listOf("H5"), - "portable_extract_result", - "success", - mapOf("preferencesPresent" to (portable is org.json.JSONObject)), - ) Triple( payload, (portable as? org.json.JSONObject)?.toString(), @@ -908,38 +634,13 @@ class OpenLessLocalMigrationActivity : Activity() { } } }.onSuccess { - debugMigrationEvent( - "P5", - listOf("H5"), - "portable_apply_result", - "success", - mapOf( - "applied" to portableOptions.first, - "mergeLearning" to portableOptions.second, - ), - ) Toast.makeText(this, ui("导入完成,备份已保留", "Import complete; backup retained"), Toast.LENGTH_LONG).show() setContentView(buildContent()) }.onFailure { error -> - debugMigrationEvent( - "P5", - listOf("H5"), - "portable_apply_result", - "failure", - mapOf("errorCode" to migrationErrorCode(error)), - ) Toast.makeText(this, ui("核心数据已导入,但 Android 偏好未应用:${error.message}", "Core data imported, but Android preferences were not applied: ${error.message}"), Toast.LENGTH_LONG).show() } }.onFailure { error -> - val stale = error.message?.contains("migration_bundle_stale_preview") == true - if (stale && allowStaleRetry) { - debugMigrationEvent( - "P3", - listOf("H3"), - "stale_retry", - "started", - mapOf("retryAllowed" to true), - ) + if (allowStaleRetry && error.message?.contains("migration_bundle_stale_preview") == true) { Toast.makeText( this, ui( @@ -950,15 +651,6 @@ class OpenLessLocalMigrationActivity : Activity() { ).show() showMigrationRestoreMode(bundleBase64, password, allowStaleRetry = false) } else { - if (stale) { - debugMigrationEvent( - "P3", - listOf("H3"), - "stale_retry", - "blocked", - mapOf("retryAllowed" to false), - ) - } Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() } } diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index 0c2d979cf..bee22536f 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -154,6 +154,5 @@ object OpenLessNative { choicesJson: String, expectedGeneration: String, androidPreferencesJson: String, - diagnosticRunId: String, ): String } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs index 6444b0aea..bcc0d10da 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -61,33 +61,6 @@ impl OpenLessBackend { }) } - #[allow(clippy::too_many_arguments)] - pub async fn import_local_migration_bundle_with_diagnostics( - &self, - bytes: &[u8], - password: Option, - mode: crate::local_migration_bundle::RestoreMode, - choices: &[crate::cloud_sync_e2ee_documents::ConflictChoice], - expected_generation: Option, - portable_android_preferences: Option, - diagnostic_run_id: &str, - ) -> Result { - self.local_migration_store - .import_local_bundle_with_diagnostics( - bytes, - password, - mode, - choices, - expected_generation, - portable_android_preferences, - Some(diagnostic_run_id), - ) - .await - .map_err(|error| { - BackendError::new(crate::BackendErrorCode::InvalidState, error.to_string()) - }) - } - pub async fn sign_out_account(&self) -> Result<(), BackendError> { if let Some(service) = &self.encrypted_sync { service.sign_out().await.map(|_| ()) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index 21021fd6c..984bb3152 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -731,29 +731,6 @@ impl CoreSyncStore { choices: &[ConflictChoice], expected_generation: Option, portable_android_preferences: Option, - ) -> Result { - self.import_local_bundle_with_diagnostics( - bytes, - password, - mode, - choices, - expected_generation, - portable_android_preferences, - None, - ) - .await - } - - #[allow(clippy::too_many_arguments)] - pub async fn import_local_bundle_with_diagnostics( - &self, - bytes: &[u8], - password: Option, - mode: RestoreMode, - choices: &[ConflictChoice], - expected_generation: Option, - portable_android_preferences: Option, - diagnostic_run_id: Option<&str>, ) -> Result { let scope = self.local_bundle_scope(); let captured = self @@ -765,24 +742,7 @@ impl CoreSyncStore { portable_android_preferences.clone(), )?; let generation = captured.generation; - let expected_matches = expected_generation.is_none_or(|expected| expected == generation); - emit_local_migration_p6( - diagnostic_run_id, - "early_generation_check", - if expected_matches { - "matched" - } else if expected_generation.is_some_and(|expected| generation > expected) { - "advanced" - } else { - "mismatch" - }, - expected_generation, - Some(generation), - None, - None, - false, - ); - if !expected_matches { + if expected_generation.is_some_and(|expected| expected != generation) { return Err(BundleError::StalePreview); } let decoded = decode_bundle(bytes, password.as_ref(), generation)?; @@ -824,40 +784,9 @@ impl CoreSyncStore { local_generation: generation, target_device: self.inner.device.clone(), }; - let plan_revision_matches = desired.observed_revision() == context.observed_revision; - emit_local_migration_p6( - diagnostic_run_id, - "restore_plan_check", - if plan_revision_matches { - "matched" - } else { - "mismatch" - }, - expected_generation, - Some(generation), - Some(desired.observed_revision()), - Some(context.observed_revision), - false, - ); - let result = self - .restore_scope(desired, context) + self.restore_scope(desired, context) .await - .map_err(BundleError::from); - emit_local_migration_p6( - diagnostic_run_id, - "restore_result", - match &result { - Ok(_) => "success", - Err(BundleError::StalePreview) => "stale", - Err(_) => "failure", - }, - expected_generation, - Some(generation), - None, - None, - true, - ); - result?; + .map_err(BundleError::from)?; Ok(inspection) } @@ -1120,51 +1049,5 @@ impl CoreSyncStore { } } -#[allow(clippy::too_many_arguments)] -fn emit_local_migration_p6( - diagnostic_run_id: Option<&str>, - event: &'static str, - outcome: &'static str, - expected_generation: Option, - captured_generation: Option, - desired_observed_revision: Option, - context_observed_revision: Option, - restore_reached: bool, -) { - let Some(run_id) = diagnostic_run_id else { - return; - }; - let run_id = if run_id.len() <= 64 - && run_id - .bytes() - .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') - { - run_id - } else { - "invalid" - }; - let record = serde_json::json!({ - "schema": "diagnosing-bugs.v1", - "session_id": "issue-1192-android-migration", - "run_id": run_id, - "probe_id": "P6", - "hypothesis_ids": ["H3", "H4"], - "event": event, - "outcome": outcome, - "safe": { - "expectedGeneration": expected_generation.map(|value| value.get()), - "capturedGeneration": captured_generation.map(|value| value.get()), - "desiredObservedRevision": desired_observed_revision.map(|value| value.get()), - "contextObservedRevision": context_observed_revision.map(|value| value.get()), - "restoreReached": restore_reached, - }, - "ts": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default(), - }); - log::info!("[DEBUG-MIGRATION-1192] {record}"); -} - #[cfg(test)] mod tests; diff --git a/openless-all/app/scripts/local-bundle-contract.test.mjs b/openless-all/app/scripts/local-bundle-contract.test.mjs index b6ccbeae3..6398ba119 100644 --- a/openless-all/app/scripts/local-bundle-contract.test.mjs +++ b/openless-all/app/scripts/local-bundle-contract.test.mjs @@ -10,7 +10,6 @@ const keyboardActivity = read('android/kotlin/OpenLessKeyboardSettingsActivity.k const native = read('android/kotlin/OpenLessNative.kt'); const policy = read('android/kotlin/OpenLessMigrationBundlePolicy.kt'); const container = read('crates/openless-core/src/local_migration_bundle/container.rs'); -const store = read('crates/openless-core/src/cloud_sync_e2ee_store/mod.rs'); const bridge = read('src-tauri/src/android/native_bridge.rs'); const scaffolding = read('scripts/copy-android-scaffolding.mjs'); const manifestMerge = read('scripts/merge-android-overlay-manifest.mjs'); @@ -53,12 +52,6 @@ assert.match(container, /file\.encrypted\(\)/); assert.match(container, /safe_entry_name/); assert.match(bridge, /publish_local_migration_state_changed/); assert.match(bridge, /missing expected generation/); -assert.match(bridge, /diagnostic_run_id/); -assert.match(store, /emit_local_migration_p6/); -assert.match(store, /"probe_id": "P6"/); -assert.match(store, /early_generation_check/); -assert.match(store, /restore_result/); -assert.doesNotMatch(store, /P7/); assert.match(scaffolding, /OpenLessMigrationBundlePolicy\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleContractTest\.kt/); assert.match(scaffolding, /OpenLessMigrationBundleInstrumentedTest\.kt/); diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 29e0ad39e..55fe84e55 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -352,7 +352,6 @@ fn import_local_migration_bundle_json( choices_json: &str, expected_generation: &str, android_preferences_json: &str, - diagnostic_run_id: &str, ) -> String { let result = (|| -> Result { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; @@ -395,17 +394,14 @@ fn import_local_migration_bundle_json( .map_err(|_| "invalid Android portable preferences")?, ) }; - let inspection = tauri::async_runtime::block_on( - backend.import_local_migration_bundle_with_diagnostics( - &bytes, - password, - restore_mode, - &choices, - expected_generation, - android_preferences, - diagnostic_run_id, - ), - ) + let inspection = tauri::async_runtime::block_on(backend.import_local_migration_bundle( + &bytes, + password, + restore_mode, + &choices, + expected_generation, + android_preferences, + )) .map_err(|error| error.message)?; backend.publish_local_migration_state_changed(); tauri::async_runtime::block_on(backend.refresh_and_publish_credentials()) @@ -1766,7 +1762,6 @@ mod jni_exports { choices_json: jstring, expected_generation: jstring, android_preferences: jstring, - diagnostic_run_id: jstring, ) -> jstring { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); @@ -1789,9 +1784,6 @@ mod jni_exports { let android_preferences = env .get_string(&JString::from_raw(android_preferences)) .map(|value| value.to_string_lossy().into_owned()); - let diagnostic_run_id = env - .get_string(&JString::from_raw(diagnostic_run_id)) - .map(|value| value.to_string_lossy().into_owned()); let response = match ( bundle_base64, password, @@ -1799,17 +1791,8 @@ mod jni_exports { choices_json, expected_generation, android_preferences, - diagnostic_run_id, ) { - ( - Ok(bundle), - Ok(password), - Ok(mode), - Ok(choices), - Ok(expected_generation), - Ok(android_preferences), - Ok(diagnostic_run_id), - ) => { + (Ok(bundle), Ok(password), Ok(mode), Ok(choices), Ok(expected_generation), Ok(android_preferences)) => { import_local_migration_bundle_json( &bundle, &password, @@ -1817,7 +1800,6 @@ mod jni_exports { &choices, &expected_generation, &android_preferences, - &diagnostic_run_id, ) } _ => local_bundle_error("invalid JNI input"), From c8cacf4fa898a6a67635434da46624ee6421a7c7 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 17 Oct 2026 01:40:18 +0800 Subject: [PATCH 14/15] chore: format upstream beta merge Co-Authored-By: Claude Fable 5 --- .../crates/openless-core/src/credentials.rs | 22 +++++-- .../openless-core/src/dictation_context.rs | 13 +++- .../openless-core/src/local_asr_service.rs | 38 ++++++++--- .../crates/openless-core/src/model_store.rs | 39 ++++++++--- .../openless-core/src/provider_rules.rs | 9 ++- .../openless-core/src/provider_service.rs | 64 +++++++++++++++---- .../openless-core/tests/local_asr_contract.rs | 33 +++++++--- 7 files changed, 167 insertions(+), 51 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index e020f0ceb..934ecc986 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -187,8 +187,10 @@ pub enum ProviderSlot { /// Legacy macOS engines retained only as disabled channel metadata. pub fn is_retired_macos_asr_provider(provider: &str) -> bool { - matches!(provider, "local-qwen3" | "local-qwen3-mlx" | "local-qwen3-c" - | "local-whisper" | "apple-whisper") + matches!( + provider, + "local-qwen3" | "local-qwen3-mlx" | "local-qwen3-c" | "local-whisper" | "apple-whisper" + ) } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -585,17 +587,25 @@ impl CredentialMetadata { let active = self.active_provider(ProviderSlot::Asr); let channels = self.channels.entry(ChannelKind::Asr).or_default(); let retiring_active = is_retired_macos_asr_provider(&active) - || channels.iter().any(|c| c.id == active && is_retired_macos_asr_provider(&c.provider_type)); + || channels + .iter() + .any(|c| c.id == active && is_retired_macos_asr_provider(&c.provider_type)); let mut changed = false; - for channel in channels.iter_mut().filter(|c| is_retired_macos_asr_provider(&c.provider_type)) { + for channel in channels + .iter_mut() + .filter(|c| is_retired_macos_asr_provider(&c.provider_type)) + { changed |= channel.enabled || channel.last_test.is_some(); channel.enabled = false; channel.last_test = None; } normalize_channel_order(channels); if retiring_active { - let next = channels.iter().find(|c| c.enabled) - .map(|c| c.id.clone()).unwrap_or_default(); + let next = channels + .iter() + .find(|c| c.enabled) + .map(|c| c.id.clone()) + .unwrap_or_default(); self.active_providers.insert(ProviderSlot::Asr, next); changed = true; } diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index 93a6cd75b..941bb3230 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -711,16 +711,23 @@ mod tests { #[test] fn textstream_stays_live_without_changing_the_users_batch_preference() { - let preferences = UserPreferences { stable_transcription_enabled: true, ..UserPreferences::default() }; + let preferences = UserPreferences { + stable_transcription_enabled: true, + ..UserPreferences::default() + }; let pack = builtin_style_pack_for_mode(PolishMode::Structured); for (provider, buffered) in [("experimental-textstream", false), ("volcengine", true)] { let context = DictationContext::capture( - &preferences, &pack, + &preferences, + &pack, DictationProviderInvocations::new( ProviderInvocation::for_provider(provider), ProviderInvocation::for_provider("openai"), ProviderInvocation::for_provider("omni"), - ), Vec::new(), Vec::new(), &DictationStartOptions::default(), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions::default(), ); assert_eq!(context.recording.transcribe_after_stop, buffered); } diff --git a/openless-all/app/crates/openless-core/src/local_asr_service.rs b/openless-all/app/crates/openless-core/src/local_asr_service.rs index 06251efaf..4f170eebd 100644 --- a/openless-all/app/crates/openless-core/src/local_asr_service.rs +++ b/openless-all/app/crates/openless-core/src/local_asr_service.rs @@ -365,8 +365,10 @@ impl LocalAsrService { if crate::experimental_asr::ExperimentalAsrModel::from_model_id(target.model_id()).is_some() || !self.runtime.supports_model(target) { - return Err(BackendError::new(BackendErrorCode::Unsupported, - "This local model is unavailable here; use Experimental local speech models")); + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "This local model is unavailable here; use Experimental local speech models", + )); } Ok(()) } @@ -432,8 +434,13 @@ impl LocalAsrService { ) -> Result<(), BackendError> { let provider_matches = match target.runtime { LocalAsrRuntime::Generic => { - let model = crate::LocalAsrModelId::from_wire_id(target.model_id()) - .ok_or_else(|| BackendError::new(BackendErrorCode::InvalidArgument, "Experimental models have their own activation switch"))?; + let model = + crate::LocalAsrModelId::from_wire_id(target.model_id()).ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "Experimental models have their own activation switch", + ) + })?; if model.is_qwen() { matches!( provider_type, @@ -474,8 +481,13 @@ async fn ensure_model_installed( store: &Arc, target: &LocalAsrTarget, ) -> Result<(), BackendError> { - if !adapter.supports_model(target) || crate::experimental_asr::ExperimentalAsrModel::from_model_id(target.model_id()).is_some() { - return Err(BackendError::new(BackendErrorCode::Unsupported, "Use Experimental local speech models")); + if !adapter.supports_model(target) + || crate::experimental_asr::ExperimentalAsrModel::from_model_id(target.model_id()).is_some() + { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "Use Experimental local speech models", + )); } let installed = if store.is_native(target)? { adapter @@ -746,8 +758,13 @@ impl LocalAsrApi for LocalAsrService { let adapter = Arc::clone(&self.runtime); Box::pin(async move { let mut models = store.list_models(runtime)?; - models.retain(|model| adapter.supports_model(&model.target) - && crate::experimental_asr::ExperimentalAsrModel::from_model_id(model.target.model_id()).is_none()); + models.retain(|model| { + adapter.supports_model(&model.target) + && crate::experimental_asr::ExperimentalAsrModel::from_model_id( + model.target.model_id(), + ) + .is_none() + }); let native = adapter .inspect_native_models( models @@ -1264,7 +1281,10 @@ impl LocalAsrApi for LocalAsrService { } let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, model_id)?; if !runtime.supports_model(&target) { - return Err(BackendError::new(BackendErrorCode::Unsupported, "This local ASR engine is retired or unavailable")); + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "This local ASR engine is retired or unavailable", + )); } if !model_store.is_native(&target)? && !model_store.is_installed(&target)? { return Err(BackendError::new( diff --git a/openless-all/app/crates/openless-core/src/model_store.rs b/openless-all/app/crates/openless-core/src/model_store.rs index 8160a5961..348470527 100644 --- a/openless-all/app/crates/openless-core/src/model_store.rs +++ b/openless-all/app/crates/openless-core/src/model_store.rs @@ -1315,11 +1315,19 @@ impl ModelStore { /// File-only status for opt-in pinned packages. Runtime caches, Python /// environments and licenses are not model-download progress. - pub fn pinned_model_status(&self, target: &LocalAsrTarget) -> Result { - let entry = self.catalog.find(target.runtime, target.model_id()) + pub fn pinned_model_status( + &self, + target: &LocalAsrTarget, + ) -> Result { + let entry = self + .catalog + .find(target.runtime, target.model_id()) .ok_or_else(|| invalid("unknown local ASR model"))?; let ModelFileSelector::Pinned(files) = &entry.selector else { - return Err(BackendError::new(BackendErrorCode::Unsupported, "model does not use pinned files")); + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "model does not use pinned files", + )); }; let manifest = ModelManifest::new(target.clone(), entry.repository.clone(), files.clone())?; self.status(&manifest) @@ -1501,7 +1509,8 @@ impl ModelStore { // Optional packages use smaller resumable ranges: large MLX/Core ML // objects must not repeatedly restart a 32 MiB body on slow links. // Existing Windows/native and legacy transfers retain their settings. - let chunk_bytes = model_download_chunk_bytes(&manifest.target, self.config.chunk_size_bytes); + let chunk_bytes = + model_download_chunk_bytes(&manifest.target, self.config.chunk_size_bytes); let end = (offset + chunk_bytes).min(file.size_bytes) - 1; let mut response = None; let mut last_error = None; @@ -1897,8 +1906,8 @@ impl ModelStore { paths.push(root.join(staging_dir_name(&entry.target))); for path in paths { let parent = path.parent().expect("known model path has a parent"); - if parent != root && std::fs::symlink_metadata(parent) - .is_ok_and(|m| m.file_type().is_symlink()) + if parent != root + && std::fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) { // A user-created indirection must not grant delete authority // over an arbitrary directory outside the managed root. @@ -3146,14 +3155,24 @@ mod tests { #[test] fn experimental_ranges_are_bounded_without_changing_windows_transfers() { for model in crate::experimental_asr::ExperimentalAsrModel::ALL { - assert_eq!(model_download_chunk_bytes(&model.target(), DEFAULT_MODEL_CHUNK_BYTES), 4 * 1024 * 1024); + assert_eq!( + model_download_chunk_bytes(&model.target(), DEFAULT_MODEL_CHUNK_BYTES), + 4 * 1024 * 1024 + ); assert_eq!(model_download_chunk_bytes(&model.target(), 4), 4); assert_eq!(model_download_chunk_bytes(&model.target(), 0), 1); } - let windows = LocalAsrTarget::parse(LocalAsrRuntime::SherpaOnnx, "sense-voice-small-zh").unwrap(); - assert_eq!(model_download_chunk_bytes(&windows, DEFAULT_MODEL_CHUNK_BYTES), DEFAULT_MODEL_CHUNK_BYTES); + let windows = + LocalAsrTarget::parse(LocalAsrRuntime::SherpaOnnx, "sense-voice-small-zh").unwrap(); + assert_eq!( + model_download_chunk_bytes(&windows, DEFAULT_MODEL_CHUNK_BYTES), + DEFAULT_MODEL_CHUNK_BYTES + ); let legacy = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "whisper-small").unwrap(); - assert_eq!(model_download_chunk_bytes(&legacy, DEFAULT_MODEL_CHUNK_BYTES), DEFAULT_MODEL_CHUNK_BYTES); + assert_eq!( + model_download_chunk_bytes(&legacy, DEFAULT_MODEL_CHUNK_BYTES), + DEFAULT_MODEL_CHUNK_BYTES + ); } use std::sync::atomic::AtomicUsize; diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index d55feb967..874798b1e 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -1597,7 +1597,14 @@ mod tests { assert_eq!(apple.validation_probe, ValidationProbe::AsrNativeSilence); assert!(apple.default_model.is_none()); assert_eq!(apple.auth_requirement, AuthRequirement::None); - for provider in ["local-whisper", "local-qwen3", "sherpa-onnx-local", "experimental-textstream", "experimental-sensevoice", "experimental-embedding-gemma"] { + for provider in [ + "local-whisper", + "local-qwen3", + "sherpa-onnx-local", + "experimental-textstream", + "experimental-sensevoice", + "experimental-embedding-gemma", + ] { assert_eq!( provider_descriptor(ProviderKind::Asr, provider) .unwrap() diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 3f761b50d..06b4bbb55 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -1335,7 +1335,8 @@ mod tests { _session_id: SessionId, _context: Arc, _partials: Arc, - ) -> BoxFuture<'static, Result, BackendError>> { + ) -> BoxFuture<'static, Result, BackendError>> + { panic!("credentials validation must never initialize a local inference engine"); } } @@ -1343,18 +1344,36 @@ mod tests { #[tokio::test] async fn retired_and_experimental_channels_reject_generic_probes_before_native_calls() { for provider in [ - "local-whisper", "local-qwen3", "local-qwen3-mlx", "local-qwen3-c", - "experimental-textstream", "experimental-sensevoice", "experimental-embedding-gemma", - "foundry-local-whisper", "sherpa-onnx-local", + "local-whisper", + "local-qwen3", + "local-qwen3-mlx", + "local-qwen3-c", + "experimental-textstream", + "experimental-sensevoice", + "experimental-embedding-gemma", + "foundry-local-whisper", + "sherpa-onnx-local", ] { let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, ChannelKind::Asr, provider, &[]).await; - let request = ProviderRequest { thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel) }; - let without = ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); - assert_eq!(without.validate(request.clone()).await.unwrap_err().code, BackendErrorCode::Unsupported); + let channel = + create_channel_with_values(&credentials, ChannelKind::Asr, provider, &[]).await; + let request = ProviderRequest { + thinking_enabled: false, + kind: ProviderKind::Asr, + channel_id: Some(channel), + }; + let without = + ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); + assert_eq!( + without.validate(request.clone()).await.unwrap_err().code, + BackendErrorCode::Unsupported + ); let with = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)) .with_native_transcription(Arc::new(ForbiddenNativeEngine)); - assert_eq!(with.validate(request).await.unwrap_err().code, BackendErrorCode::Unsupported); + assert_eq!( + with.validate(request).await.unwrap_err().code, + BackendErrorCode::Unsupported + ); } } @@ -1362,20 +1381,37 @@ mod tests { async fn apple_system_recognition_uses_the_host_probe_without_cloud_credentials() { struct AppleProbe(std::sync::atomic::AtomicBool); impl TranscriptionEngine for AppleProbe { - fn start(&self, _: SessionId, context: Arc, _: Arc) - -> BoxFuture<'static, Result, BackendError>> + fn start( + &self, + _: SessionId, + context: Arc, + _: Arc, + ) -> BoxFuture<'static, Result, BackendError>> { assert_eq!(context.asr.provider_type, "apple-speech"); self.0.store(true, std::sync::atomic::Ordering::SeqCst); - Box::pin(async { Err(BackendError::new(BackendErrorCode::Provider, "fixture native probe")) }) + Box::pin(async { + Err(BackendError::new( + BackendErrorCode::Provider, + "fixture native probe", + )) + }) } } let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, ChannelKind::Asr, "apple-speech", &[]).await; + let channel = + create_channel_with_values(&credentials, ChannelKind::Asr, "apple-speech", &[]).await; let native = Arc::new(AppleProbe(std::sync::atomic::AtomicBool::new(false))); let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)) .with_native_transcription(native.clone()); - let error = service.validate(ProviderRequest { thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel) }).await.unwrap_err(); + let error = service + .validate(ProviderRequest { + thinking_enabled: false, + kind: ProviderKind::Asr, + channel_id: Some(channel), + }) + .await + .unwrap_err(); assert_eq!(error.code, BackendErrorCode::Provider); assert!(native.0.load(std::sync::atomic::Ordering::SeqCst)); } diff --git a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs index 94c02c4c6..bc806c4c1 100644 --- a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs +++ b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs @@ -161,7 +161,9 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { if let Some(update) = self.during_validation.lock().unwrap().take() { update(); } - let installed = !self.missing_native.load(std::sync::atomic::Ordering::SeqCst); + let installed = !self + .missing_native + .load(std::sync::atomic::Ordering::SeqCst); Box::pin(async move { Ok(targets .into_iter() @@ -176,7 +178,8 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { } fn restart_required(&self, _: LocalAsrRuntime) -> bool { - self.restart_needed.load(std::sync::atomic::Ordering::SeqCst) + self.restart_needed + .load(std::sync::atomic::Ordering::SeqCst) } fn download_native_model( @@ -185,8 +188,12 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { _: FoundryRuntimeSource, _: openless_core::ModelPrepareProgressSink, ) -> BoxFuture<'static, Result<(), BackendError>> { - self.operations.lock().unwrap().push(format!("download:{}", target.model_id())); - self.restart_needed.store(true, std::sync::atomic::Ordering::SeqCst); + self.operations + .lock() + .unwrap() + .push(format!("download:{}", target.model_id())); + self.restart_needed + .store(true, std::sync::atomic::Ordering::SeqCst); self.release(target.runtime) } @@ -650,8 +657,12 @@ async fn local_asr_activation_owns_channel_creation_and_enabling() { // Even an unavailable/broken native loader must not be called just // because a downloaded model is enabled. - runtime.fail_prepare.store(true, std::sync::atomic::Ordering::SeqCst); - runtime.fail_preload.store(true, std::sync::atomic::Ordering::SeqCst); + runtime + .fail_prepare + .store(true, std::sync::atomic::Ordering::SeqCst); + runtime + .fail_preload + .store(true, std::sync::atomic::Ordering::SeqCst); runtime.operations.lock().unwrap().clear(); let result = backend.activate_local_asr(request).await.unwrap(); assert!(runtime.operations.lock().unwrap().is_empty()); @@ -1174,7 +1185,10 @@ async fn failed_old_runtime_release_preserves_selection_without_loading_any_repl backend.get_preferences().active_asr_provider, "local-qwen3-mlx" ); - assert_eq!(*runtime.operations.lock().unwrap(), ["release-lease:qwen3-asr-0.6b:2"]); + assert_eq!( + *runtime.operations.lock().unwrap(), + ["release-lease:qwen3-asr-0.6b:2"] + ); std::fs::remove_dir_all(data_dir).unwrap(); } @@ -1343,7 +1357,10 @@ async fn committed_channel_changes_are_published_even_when_runtime_release_fails .await .unwrap_err(); assert_eq!(error.code, BackendErrorCode::Platform); - assert_eq!(backend.active_provider(ProviderSlot::Asr).await.unwrap(), cloud); + assert_eq!( + backend.active_provider(ProviderSlot::Asr).await.unwrap(), + cloud + ); assert!(matches!( events.try_recv().unwrap().kind, BackendEventKind::CredentialsChanged(_) From 4c09075e25e0e4b9fa0da567fc65d0691446a1e7 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Mon, 19 Oct 2026 16:08:38 +0800 Subject: [PATCH 15/15] fix(android): make local configuration migration transactional --- .../app/android/kotlin/ImeLearningPolicy.kt | 8 +- .../kotlin/LitePinyinLearnedPhrases.kt | 12 +- .../app/android/kotlin/OpenLessApplication.kt | 7 +- .../OpenLessKeyboardSettingsActivity.kt | 17 +- .../kotlin/OpenLessLocalMigrationActivity.kt | 145 +--- .../android/kotlin/OpenLessMigrationState.kt | 123 +++ .../app/android/kotlin/OpenLessNative.kt | 6 +- .../android/kotlin/OpenLessSettingsExport.kt | 49 +- .../app/android/kotlin/StrokeUserFrequency.kt | 42 +- ...OpenLessMigrationBundleInstrumentedTest.kt | 90 ++ .../openless-core/src/cloud_sync_e2ee/api.rs | 19 +- .../src/cloud_sync_e2ee_documents/merge.rs | 32 + .../src/cloud_sync_e2ee_documents/mod.rs | 4 +- .../src/cloud_sync_e2ee_documents/restore.rs | 18 +- .../src/cloud_sync_e2ee_documents/types.rs | 16 + .../src/cloud_sync_e2ee_store/mod.rs | 341 ++++++-- .../src/cloud_sync_e2ee_store/tests.rs | 151 ++++ .../tests/local_migration.rs | 818 ++++++++++++++++++ .../local_migration_bundle/android_state.rs | 24 + .../src/local_migration_bundle/container.rs | 90 +- .../src/local_migration_bundle/mod.rs | 6 + .../src/local_migration_bundle/types.rs | 5 + .../tests/local_bundle_contract.rs | 99 +++ .../app/scripts/copy-android-scaffolding.mjs | 1 + .../scripts/local-bundle-contract.test.mjs | 11 +- openless-all/app/src-tauri/src/android/jni.rs | 4 +- .../src-tauri/src/android/local_migration.rs | 195 +++++ openless-all/app/src-tauri/src/android/mod.rs | 2 + .../src-tauri/src/android/native_bridge.rs | 175 +--- .../src/coordinator/restore_runtime.rs | 10 + 30 files changed, 2128 insertions(+), 392 deletions(-) create mode 100644 openless-all/app/android/kotlin/OpenLessMigrationState.kt create mode 100644 openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs create mode 100644 openless-all/app/crates/openless-core/src/local_migration_bundle/android_state.rs create mode 100644 openless-all/app/src-tauri/src/android/local_migration.rs diff --git a/openless-all/app/android/kotlin/ImeLearningPolicy.kt b/openless-all/app/android/kotlin/ImeLearningPolicy.kt index b7eac23c4..18f84f83d 100644 --- a/openless-all/app/android/kotlin/ImeLearningPolicy.kt +++ b/openless-all/app/android/kotlin/ImeLearningPolicy.kt @@ -26,9 +26,11 @@ internal object ImeLearningPolicy { fun migrate(context: Context): Boolean = runCatching { val marker = context.getSharedPreferences("openless_learning_privacy", Context.MODE_PRIVATE) if (marker.getInt("version", 0) >= PRIVACY_VERSION) return true - for (name in stores) { - if (!context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit()) return false + OpenLessMigrationState.settings { + for (name in stores) { + check(context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit()) + } + check(marker.edit().putInt("version", PRIVACY_VERSION).commit()) } - marker.edit().putInt("version", PRIVACY_VERSION).commit() }.getOrDefault(false) } diff --git a/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt b/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt index 46ac7e89e..963b0884a 100644 --- a/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt +++ b/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt @@ -33,9 +33,11 @@ internal class LitePinyinLearnedPhrases(context: Context) { */ fun observeSequence(combinedEncoding: String, text: String) { if (combinedEncoding.length < 2 || text.length < 2) return - val existing = preferences.getString(combinedEncoding, null)?.split(',') - val count = if (existing?.getOrNull(0) == text) (existing.getOrNull(1)?.toIntOrNull() ?: 0) + 1 else 1 - preferences.edit().putString(combinedEncoding, "$text,$count").apply() + OpenLessMigrationState.learn { + val existing = preferences.getString(combinedEncoding, null)?.split(',') + val count = if (existing?.getOrNull(0) == text) (existing.getOrNull(1)?.toIntOrNull() ?: 0) + 1 else 1 + preferences.edit().putString(combinedEncoding, "$text,$count").apply() + } } /** The learned text for this exact encoding, only once it has crossed the promotion threshold — null otherwise (including "seen once or twice but not yet promoted", which is deliberately invisible to callers). */ @@ -60,7 +62,9 @@ internal class LitePinyinLearnedPhrases(context: Context) { /** Replaces the entire store with [entries] — an import wholesale-restores a backup rather than merging with whatever's already on this device. */ fun importAll(entries: Map) { - check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Pinyin learning save failed" } + check(OpenLessMigrationState.settings { + check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Pinyin learning save failed" } + }) { "migration_android_busy" } } private companion object { diff --git a/openless-all/app/android/kotlin/OpenLessApplication.kt b/openless-all/app/android/kotlin/OpenLessApplication.kt index d120c1a54..a57590bc7 100644 --- a/openless-all/app/android/kotlin/OpenLessApplication.kt +++ b/openless-all/app/android/kotlin/OpenLessApplication.kt @@ -20,6 +20,7 @@ class OpenLessApplication : Application() { override fun onCreate() { super.onCreate() OpenLessAppContext.initialize(this) + OpenLessMigrationState.attach(this) // #region agent log // Baseline JNI Context so overlay/dictation notifies work even when // neither RuntimeService (IME) nor OverlayService has started yet. @@ -209,6 +210,7 @@ class OpenLessApplication : Application() { return null } val webView = findWebView(activity.window.decorView) ?: return + val mirrorRevision = OpenLessMigrationState.mirrorRevision() ?: return webView.evaluateJavascript( "(function(){return localStorage.getItem('ol.locale') || document.documentElement.lang || navigator.language;})()", ) { result -> @@ -216,7 +218,7 @@ class OpenLessApplication : Application() { if (!locale.matches(Regex("[a-z]{2,3}(-[a-z0-9]{2,8})*"))) return@evaluateJavascript val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE) if (prefs.getString("locale", null) != locale) { - prefs.edit().putString("locale", locale).apply() + OpenLessMigrationState.mirror(mirrorRevision) { prefs.edit().putString("locale", locale).apply() } } } } @@ -242,6 +244,7 @@ class OpenLessApplication : Application() { return null } val webView = findWebView(activity.window.decorView) ?: return + val mirrorRevision = OpenLessMigrationState.mirrorRevision() ?: return webView.evaluateJavascript( """ (function(){ @@ -261,7 +264,7 @@ class OpenLessApplication : Application() { if (theme != "dark" && theme != "light") return@evaluateJavascript val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE) if (prefs.getString("theme_mode", null) != theme) { - prefs.edit().putString("theme_mode", theme).apply() + OpenLessMigrationState.mirror(mirrorRevision) { prefs.edit().putString("theme_mode", theme).apply() } } } } diff --git a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt index 4537ba904..7fb4fc451 100644 --- a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt @@ -66,6 +66,13 @@ class OpenLessKeyboardSettingsActivity : Activity() { WindowCompat.getInsetsController(window, window.decorView) } + private fun saveMigrationSetting(write: () -> Unit) { + if (!OpenLessMigrationState.settings(write)) { + Toast.makeText(this, ui("正在恢复配置,请稍后重试", "Configuration restore is in progress; try again shortly"), Toast.LENGTH_SHORT).show() + setContentView(buildContent()) + } + } + private fun buildContent(): View { val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -202,7 +209,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { Switch(this).apply { isChecked = debugFeaturesEnabled setOnCheckedChangeListener { _, checked -> - prefs.edit().putBoolean("key_debug_features_enabled", checked).apply() + saveMigrationSetting { prefs.edit().putBoolean("key_debug_features_enabled", checked).apply() } // The restart-stats section's own visibility is decided // once, above, when this page was built — rebuild it so // toggling here shows/hides it immediately instead of @@ -232,7 +239,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { enabledRow.addView( Switch(this).apply { isChecked = prefs.getBoolean("key_haptic_enabled", true) - setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean("key_haptic_enabled", checked).apply() } + setOnCheckedChangeListener { _, checked -> saveMigrationSetting { prefs.edit().putBoolean("key_haptic_enabled", checked).apply() } } }, ) content.addView( @@ -255,7 +262,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { englishSuggestionsRow.addView( Switch(this).apply { isChecked = prefs.getBoolean("english_suggestions_enabled", true) - setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean("english_suggestions_enabled", checked).apply() } + setOnCheckedChangeListener { _, checked -> saveMigrationSetting { prefs.edit().putBoolean("english_suggestions_enabled", checked).apply() } } }, ) content.addView( @@ -284,7 +291,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { current = currentAmplitude, onChange = { value -> currentAmplitude = value - prefs.edit().putInt("key_haptic_amplitude", value).apply() + saveMigrationSetting { prefs.edit().putInt("key_haptic_amplitude", value).apply() } }, onRelease = { fireTestVibration(currentAmplitude, currentDurationMs) }, ), @@ -297,7 +304,7 @@ class OpenLessKeyboardSettingsActivity : Activity() { current = currentDurationMs, onChange = { value -> currentDurationMs = value - prefs.edit().putLong("key_haptic_duration_ms", value.toLong()).apply() + saveMigrationSetting { prefs.edit().putLong("key_haptic_duration_ms", value.toLong()).apply() } }, onRelease = { fireTestVibration(currentAmplitude, currentDurationMs) }, ), diff --git a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt index 4ae167ab1..659ec8179 100644 --- a/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessLocalMigrationActivity.kt @@ -399,7 +399,6 @@ class OpenLessLocalMigrationActivity : Activity() { mode, includeCredentials, password, - OpenLessSettingsExport.exportPortable(this), ), ) check(envelope.optBoolean("ok")) { envelope.optString("error", "export failed") } @@ -432,6 +431,8 @@ class OpenLessLocalMigrationActivity : Activity() { append(ui("凭据:", "Credentials: ")).append( if (inspection.optBoolean("credentialsEncrypted")) ui("已加密", "password protected") else ui("未携带", "not included"), ).append('\n') + val skipped = inspection.optInt("skippedHistory") + inspection.optInt("skippedActivity") + inspection.optInt("skippedTombstones") + if (skipped > 0) appendLine(ui("已跳过历史、统计及删除标记:$skipped 项", "Skipped history, activity and deletion markers: $skipped")) append(ui("系统权限不会迁移,目标设备需重新授权。", "System permissions do not migrate; authorize them again on the destination device.")) } @@ -451,39 +452,31 @@ class OpenLessLocalMigrationActivity : Activity() { .show() } - private fun showMigrationRestoreMode( - bundleBase64: String, - password: String, - allowStaleRetry: Boolean = true, - ) { + private fun showMigrationRestoreMode(bundleBase64: String, password: String) { + android.app.AlertDialog.Builder(this) + .setTitle(ui("导入方式", "Restore mode")) + .setItems(arrayOf(ui("合并", "Merge"), ui("替换", "Replace"))) { _, which -> + previewMigration(bundleBase64, password, if (which == 0) "merge" else "replace") + } + .setNegativeButton(ui("取消", "Cancel"), null) + .show() + } + + private fun previewMigration(bundleBase64: String, password: String, mode: String, allowStaleRetry: Boolean = true) { Thread { val previewResult = runCatching { - val response = org.json.JSONObject( - OpenLessNative.nativePreviewLocalMigrationBundle( - bundleBase64, - password, - OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), - ), - ) + val response = org.json.JSONObject(OpenLessNative.nativePreviewLocalMigrationBundle(bundleBase64, password, mode)) check(response.optBoolean("ok")) { response.optString("error", "preview failed") } response.getJSONObject("payload").also { preview -> check(preview.optString("observedGeneration").isNotBlank()) { "preview missing observed generation" } + check(preview.optString("observedAndroidRevision").isNotBlank()) { "preview missing Android revision" } } } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread previewResult.onSuccess { preview -> - val conflicts = preview.getJSONArray("conflicts") - val expectedGeneration = preview.optString("observedGeneration") - chooseMigrationConflicts(bundleBase64, password, conflicts, 0, org.json.JSONArray()) { - showMigrationRestoreModeAfterConflicts( - bundleBase64, - password, - expectedGeneration, - conflicts, - it, - allowStaleRetry, - ) + chooseMigrationConflicts(bundleBase64, password, preview.getJSONArray("conflicts"), 0, org.json.JSONArray()) { choices -> + confirmMigration(bundleBase64, password, mode, preview, choices, allowStaleRetry) } }.onFailure { error -> Toast.makeText(this, ui("预览失败:${error.message}", "Preview failed: ${error.message}"), Toast.LENGTH_LONG).show() @@ -513,7 +506,10 @@ class OpenLessLocalMigrationActivity : Activity() { setPadding(dp(24), dp(8), dp(24), 0) } content.addView(TextView(this).apply { - text = "$kind / $reason$credential" + text = buildString { + appendLine("$kind / ${conflict.optString("documentId")}") + append("$reason$credential") + } textSize = 14f setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(50, 50, 55))) setPadding(0, 0, 0, dp(8)) @@ -563,93 +559,46 @@ class OpenLessLocalMigrationActivity : Activity() { dialog.show() } - private fun portableConflictSide( - conflicts: org.json.JSONArray, - choices: org.json.JSONArray, - ): String? { - for (i in 0 until conflicts.length()) { - val conflict = conflicts.getJSONObject(i) - if (conflict.optString("documentId") != "androidPortableSettings") continue - val conflictId = conflict.optString("conflictId") - for (j in 0 until choices.length()) { - val choice = choices.getJSONObject(j) - if (choice.optString("conflictId") == conflictId) { - return choice.optString("side") - } - } - } - return null - } - - private fun showMigrationRestoreModeAfterConflicts( + private fun confirmMigration( bundleBase64: String, password: String, - expectedGeneration: String, - conflicts: org.json.JSONArray, + mode: String, + preview: org.json.JSONObject, choices: org.json.JSONArray, allowStaleRetry: Boolean, ) { - val modes = arrayOf(ui("合并", "Merge"), ui("替换", "Replace")) + val message = buildString { + append(ui("两种模式都会保留你明确选择的本机冲突项。历史、统计和系统权限不会被替换。", "Both modes honor your Keep this device choices. History, activity and system permissions are not replaced.")) + val removed = preview.getJSONArray("removedDocuments") + if (mode == "replace" && removed.length() > 0) { + appendLine().appendLine().append(ui("以下本机独有配置将移除(冲突中明确保留的项目除外):", "Receiver-only configuration to remove, except items explicitly kept in conflict choices:")) + for (i in 0 until removed.length()) { + val item = removed.getJSONObject(i) + appendLine().append(item.optString("kind")).append(" / ").append(item.optString("id")) + } + } + } android.app.AlertDialog.Builder(this) - .setTitle(ui("导入方式", "Restore mode")) - .setItems(modes) { _, which -> - val mode = if (which == 0) "merge" else "replace" + .setTitle(if (mode == "replace") ui("确认替换", "Confirm Replace") else ui("确认合并", "Confirm Merge")) + .setMessage(message) + .setPositiveButton(ui("应用配置", "Apply configuration")) { _, _ -> Thread { - val applyPortable = mode == "replace" || portableConflictSide(conflicts, choices) != "local" - val mergePortableLearning = mode == "merge" && applyPortable val result = runCatching { - val response = org.json.JSONObject( - OpenLessNative.nativeImportLocalMigrationBundle( - bundleBase64, - password, - mode, - choices.toString(), - expectedGeneration, - OpenLessSettingsExport.exportPortable(this@OpenLessLocalMigrationActivity), - ), - ) + val response = org.json.JSONObject(OpenLessNative.nativeImportLocalMigrationBundle( + bundleBase64, password, mode, choices.toString(), + preview.getString("observedGeneration"), preview.getString("observedAndroidRevision"), + )) check(response.optBoolean("ok")) { response.optString("error", "import failed") } - val payload = response.getJSONObject("payload") - val portableResponse = org.json.JSONObject( - OpenLessNative.nativeExtractLocalMigrationAndroidPreferences(bundleBase64, password), - ) - check(portableResponse.optBoolean("ok")) { portableResponse.optString("error", "portable settings read failed") } - val portable = portableResponse.getJSONObject("payload").opt("preferences") - if (portable !is org.json.JSONObject && portable != org.json.JSONObject.NULL) { - error("invalid portable Android settings") - } - Triple( - payload, - (portable as? org.json.JSONObject)?.toString(), - applyPortable to mergePortableLearning, - ) } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread - result.onSuccess { (_, portableJson, portableOptions) -> - runCatching { - if (portableOptions.first) { - portableJson?.let { - OpenLessSettingsExport.importPortable(this, it, portableOptions.second) - } - } - }.onSuccess { - Toast.makeText(this, ui("导入完成,备份已保留", "Import complete; backup retained"), Toast.LENGTH_LONG).show() - setContentView(buildContent()) - }.onFailure { error -> - Toast.makeText(this, ui("核心数据已导入,但 Android 偏好未应用:${error.message}", "Core data imported, but Android preferences were not applied: ${error.message}"), Toast.LENGTH_LONG).show() - } + result.onSuccess { + Toast.makeText(this, ui("导入完成,备份已保留", "Import complete; backup retained"), Toast.LENGTH_LONG).show() + setContentView(buildContent()) }.onFailure { error -> if (allowStaleRetry && error.message?.contains("migration_bundle_stale_preview") == true) { - Toast.makeText( - this, - ui( - "本地配置在确认后发生变化,正在重新预览;请再次确认导入方式。", - "Local configuration changed after review. Rechecking the preview; please confirm the restore mode again.", - ), - Toast.LENGTH_LONG, - ).show() - showMigrationRestoreMode(bundleBase64, password, allowStaleRetry = false) + Toast.makeText(this, ui("本机配置已变化,请重新确认预览", "Local configuration changed; review the new preview"), Toast.LENGTH_LONG).show() + previewMigration(bundleBase64, password, mode, allowStaleRetry = false) } else { Toast.makeText(this, ui("导入未完成:${error.message}", "Import not completed: ${error.message}"), Toast.LENGTH_LONG).show() } diff --git a/openless-all/app/android/kotlin/OpenLessMigrationState.kt b/openless-all/app/android/kotlin/OpenLessMigrationState.kt new file mode 100644 index 000000000..ae44d6e5f --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessMigrationState.kt @@ -0,0 +1,123 @@ +package com.openless.app + +import android.content.Context +import androidx.annotation.Keep +import org.json.JSONObject +import java.util.UUID + +/** The Core journal owns the decision; this gate only fences Android writers. */ +@Keep +object OpenLessMigrationState { + private val lock = Any() + private var revision = UUID.randomUUID().toString() + private var token: String? = null + private var recoveryRequired = false + private lateinit var app: Context + private var restoringThread: Thread? = null + private val learning = ArrayDeque<() -> Unit>() + + @Keep @JvmStatic + fun initialize(context: Context, recovering: Boolean) = synchronized(lock) { + attach(context) + check(token == null) + recoveryRequired = recoveryRequired && recovering + persistFence(recoveryRequired) + if (!recoveryRequired) drainLearning() + } + + fun attach(context: Context) = synchronized(lock) { + if (!::app.isInitialized) { + app = context.applicationContext + recoveryRequired = app.getSharedPreferences("openless_migration_gate", Context.MODE_PRIVATE).getBoolean("recovering", false) + } + } + + private fun persistFence(recovering: Boolean) { + check(app.getSharedPreferences("openless_migration_gate", Context.MODE_PRIVATE).edit().putBoolean("recovering", recovering).commit()) { "migration_android_fence_write_failed" } + } + + fun settings(write: () -> Unit): Boolean = synchronized(lock) { + if (restoringThread === Thread.currentThread()) { + write() + return@synchronized true + } + if (token != null || recoveryRequired) return@synchronized false + try { write() } finally { revision = UUID.randomUUID().toString() } + true + } + + fun learn(write: () -> Unit) = synchronized(lock) { + if (token != null || recoveryRequired) learning.addLast(write) + else { + try { write() } finally { revision = UUID.randomUUID().toString() } + } + } + + // A mirror is sampled again later; never replay an obsolete WebView value. + fun mirrorRevision(): String? = synchronized(lock) { + if (token != null || recoveryRequired) null else revision + } + + fun mirror(expectedRevision: String, write: () -> Unit) = synchronized(lock) { + if (expectedRevision == revision) settings(write) + } + + @Keep @JvmStatic + fun capture(context: Context): String = synchronized(lock) { + attach(context) + check(token == null && !recoveryRequired) { "migration_android_busy" } + check(ImeLearningPolicy.migrate(context)) { "migration_learning_cleanup_failed" } + JSONObject().put("revision", revision) + .put("value", JSONObject(OpenLessSettingsExport.exportPortable(context))).toString() + } + + @Keep @JvmStatic + fun acquire(expectedRevision: String): String = synchronized(lock) { + check(token == null) { "migration_android_busy" } + if (expectedRevision.isNotEmpty()) { + check(!recoveryRequired && expectedRevision == revision) { "migration_bundle_stale_preview" } + } + persistFence(true) + UUID.randomUUID().toString().also { token = it } + } + + @Keep @JvmStatic + fun captureLocked(context: Context, lease: String): String = synchronized(lock) { + check(token == lease) { "migration_android_invalid_lease" } + OpenLessSettingsExport.exportPortable(context) + } + + internal fun restore(lease: String, write: () -> Unit) = synchronized(lock) { + check(token == lease) { "migration_android_invalid_lease" } + restoringThread = Thread.currentThread() + try { write() } finally { + restoringThread = null + revision = UUID.randomUUID().toString() + } + } + + @Keep @JvmStatic + fun replace(context: Context, lease: String, json: String) = restore(lease) { + OpenLessSettingsExport.replacePortable(context, json) + } + + @Keep @JvmStatic + fun finish(lease: String, recovering: Boolean) = synchronized(lock) { + check(token == lease) { "migration_android_invalid_lease" } + try { persistFence(recovering) } catch (error: Throwable) { + token = null + recoveryRequired = true + throw error + } + token = null + recoveryRequired = recovering + if (!recovering) drainLearning() + } + + private fun drainLearning() { + while (learning.isNotEmpty()) { + learning.removeFirst().invoke() + revision = UUID.randomUUID().toString() + } + } +} diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index bee22536f..73c1e93b8 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -136,15 +136,13 @@ object OpenLessNative { mode: String, includeCredentials: Boolean, password: String, - androidPreferencesJson: String, ): String @JvmStatic external fun nativeInspectLocalMigrationBundle(bundleBase64: String): String - @JvmStatic external fun nativeExtractLocalMigrationAndroidPreferences(bundleBase64: String, password: String): String @JvmStatic external fun nativePreviewLocalMigrationBundle( bundleBase64: String, password: String, - androidPreferencesJson: String, + mode: String, ): String @JvmStatic external fun nativeImportLocalMigrationBundle( @@ -153,6 +151,6 @@ object OpenLessNative { mode: String, choicesJson: String, expectedGeneration: String, - androidPreferencesJson: String, + expectedAndroidRevision: String, ): String } diff --git a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt index 9ff44da18..3d2862a15 100644 --- a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt +++ b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt @@ -20,7 +20,6 @@ import org.json.JSONObject internal object OpenLessSettingsExport { const val VERSION = 2 private const val PREFS_STORE = "openless_ime_ui" - private const val MAX_LEARNING_ENTRIES = 20_000 /** One category = one row in the export/import checkbox dialog. Order here is the order shown. */ enum class Category(val key: String, val labelZh: String, val labelEn: String) { @@ -82,7 +81,6 @@ internal object OpenLessSettingsExport { /** Portable Android-only state that is not represented by Core repositories. */ fun exportPortable(context: Context): String { val app = context.applicationContext - check(ImeLearningPolicy.migrate(app)) { "Learning data cleanup failed" } val prefs = app.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE) return JSONObject().apply { put("schemaVersion", 1) @@ -103,15 +101,13 @@ internal object OpenLessSettingsExport { fun validatePortable(json: String): Boolean = runCatching { parsePortable(json) }.isSuccess - fun importPortable(context: Context, json: String, mergeLearning: Boolean = false) { + internal fun replacePortable(context: Context, json: String, beforeWrite: (String) -> Unit = {}) { val app = context.applicationContext val root = parsePortable(json) - require(root.optInt("learningPrivacyVersion", 0) >= ImeLearningPolicy.PRIVACY_VERSION) { - "Portable learning data is outdated" - } val prefs = app.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE) val values = root.getJSONObject("preferences") val editor = prefs.edit() + for (key in listOf("locale", "theme_mode", "key_haptic_enabled", "key_haptic_amplitude", "key_haptic_duration_ms", "english_suggestions_enabled", "key_debug_features_enabled")) editor.remove(key) if (values.has("locale")) editor.putString("locale", values.getString("locale")) if (values.has("themeMode")) editor.putString("theme_mode", values.getString("themeMode")) if (values.has("hapticEnabled")) editor.putBoolean("key_haptic_enabled", values.getBoolean("hapticEnabled")) @@ -119,34 +115,18 @@ internal object OpenLessSettingsExport { if (values.has("hapticDurationMs")) editor.putLong("key_haptic_duration_ms", values.getLong("hapticDurationMs")) if (values.has("englishSuggestionsEnabled")) editor.putBoolean("english_suggestions_enabled", values.getBoolean("englishSuggestionsEnabled")) if (values.has("debugFeaturesEnabled")) editor.putBoolean("key_debug_features_enabled", values.getBoolean("debugFeaturesEnabled")) + beforeWrite("preferences") check(editor.commit()) { "Portable settings save failed" } - check(ImeLearningPolicy.migrate(app)) { "Learning data cleanup failed" } - val stroke = root.getJSONObject("strokeFrequency").toStringMap() - val pinyin = root.getJSONObject("pinyinLearnedPhrases").toStringMap() - if (mergeLearning) { - StrokeUserFrequency(app).importAll(mergeLearningEntries(StrokeUserFrequency(app).exportAll(), stroke)) - LitePinyinLearnedPhrases(app).importAll(mergeLearningEntries(LitePinyinLearnedPhrases(app).exportAll(), pinyin)) - } else { - StrokeUserFrequency(app).importAll(stroke) - LitePinyinLearnedPhrases(app).importAll(pinyin) - } - } - - private fun mergeLearningEntries( - current: Map, - incoming: Map, - ): Map { - val merged = LinkedHashMap(MAX_LEARNING_ENTRIES) - incoming.forEach { (key, value) -> merged[key] = value } - current.forEach { (key, value) -> - if (merged.size < MAX_LEARNING_ENTRIES) merged.putIfAbsent(key, value) - } - return merged + beforeWrite("strokeFrequency") + StrokeUserFrequency(app).importAll(root.getJSONObject("strokeFrequency").toStringMap()) + beforeWrite("pinyinLearnedPhrases") + LitePinyinLearnedPhrases(app).importAll(root.getJSONObject("pinyinLearnedPhrases").toStringMap()) } private fun parsePortable(json: String): JSONObject { + require(json.toByteArray(Charsets.UTF_8).size <= 4 * 1024 * 1024) val root = JSONObject(json) - require(root.optInt("schemaVersion", 0) == 1) { "Unsupported portable settings version" } + require(root.opt("schemaVersion") == 1 && root.opt("learningPrivacyVersion") == ImeLearningPolicy.PRIVACY_VERSION) { "Unsupported portable settings version" } val allowed = setOf("schemaVersion", "learningPrivacyVersion", "preferences", "strokeFrequency", "pinyinLearnedPhrases") val keys = root.keys() while (keys.hasNext()) require(keys.next() in allowed) { "Unknown portable settings field" } @@ -154,14 +134,19 @@ internal object OpenLessSettingsExport { val allowedValues = setOf("locale", "themeMode", "hapticEnabled", "hapticAmplitude", "hapticDurationMs", "englishSuggestionsEnabled", "debugFeaturesEnabled") val valueKeys = values.keys() while (valueKeys.hasNext()) require(valueKeys.next() in allowedValues) { "Unknown portable preference" } + for (key in listOf("locale", "themeMode")) if (values.has(key)) require(values.get(key) is String) + for (key in listOf("hapticEnabled", "englishSuggestionsEnabled", "debugFeaturesEnabled")) if (values.has(key)) require(values.get(key) is Boolean) + for (key in listOf("hapticAmplitude", "hapticDurationMs")) if (values.has(key)) require(values.get(key) is Int || values.get(key) is Long) if (values.has("locale")) require(values.getString("locale") in setOf("system", "zh-CN", "zh-TW", "en", "ja", "ko", "es", "fr", "de")) if (values.has("themeMode")) require(values.getString("themeMode") in setOf("system", "light", "dark")) if (values.has("hapticAmplitude")) require(values.getInt("hapticAmplitude") in 1..255) if (values.has("hapticDurationMs")) require(values.getLong("hapticDurationMs") in 1L..1000L) require(root.getJSONObject("strokeFrequency").length() <= 20000) require(root.getJSONObject("pinyinLearnedPhrases").length() <= 20000) - root.getJSONObject("strokeFrequency").toStringMap() - root.getJSONObject("pinyinLearnedPhrases").toStringMap() + for (key in listOf("strokeFrequency", "pinyinLearnedPhrases")) { + val entries = root.getJSONObject(key).toStringMap() + require(entries.keys.all { it.isNotEmpty() && it.toByteArray(Charsets.UTF_8).size <= 256 }) + } return root } @@ -199,7 +184,7 @@ internal object OpenLessSettingsExport { val amplitude = value.getInt("amplitude") val duration = value.getLong("durationMs") require(amplitude in 1..255 && duration in 1..1000) { "Invalid haptic settings" } - check(prefs.edit().putBoolean("key_haptic_enabled", enabled).putInt("key_haptic_amplitude", amplitude).putLong("key_haptic_duration_ms", duration).commit()) { "Settings save failed" } + check(OpenLessMigrationState.settings { check(prefs.edit().putBoolean("key_haptic_enabled", enabled).putInt("key_haptic_amplitude", amplitude).putLong("key_haptic_duration_ms", duration).commit()) }) { "migration_android_busy" } } Category.STROKE_FREQUENCY -> StrokeUserFrequency(app).importAll(value.toStringMap()) Category.PINYIN_LEARNED_PHRASES -> { diff --git a/openless-all/app/android/kotlin/StrokeUserFrequency.kt b/openless-all/app/android/kotlin/StrokeUserFrequency.kt index ee6d1d7a9..1cd3bef38 100644 --- a/openless-all/app/android/kotlin/StrokeUserFrequency.kt +++ b/openless-all/app/android/kotlin/StrokeUserFrequency.kt @@ -29,26 +29,30 @@ internal class StrokeUserFrequency(context: Context) { fun record(context: String, candidate: String) { if (candidate.isBlank()) return - val entryKey = key(context, candidate) - val isNewEntry = !preferences.contains(entryKey) - val old = preferences.getString(entryKey, null)?.split(',') - val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 - val now = System.currentTimeMillis() - preferences.edit().putString(entryKey, "$count,$now").apply() - // Re-recording an existing entry (by far the common case — the same - // handful of characters/phrases typed over and over) never touches - // preferences.all at all now. Only a genuinely new key can push the - // count past MAX_ENTRIES, so that's the only case worth checking. - if (isNewEntry) { - val size = resolvedSize() + 1 - approxSize = size - if (size > MAX_ENTRIES) trimIfNeeded() + OpenLessMigrationState.learn { + val entryKey = key(context, candidate) + val isNewEntry = !preferences.contains(entryKey) + val old = preferences.getString(entryKey, null)?.split(',') + val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 + val now = System.currentTimeMillis() + preferences.edit().putString(entryKey, "$count,$now").apply() + // Re-recording an existing entry (by far the common case — the same + // handful of characters/phrases typed over and over) never touches + // preferences.all at all now. Only a genuinely new key can push the + // count past MAX_ENTRIES, so that's the only case worth checking. + if (isNewEntry) { + val size = resolvedSize() + 1 + approxSize = size + if (size > MAX_ENTRIES) trimIfNeeded() + } } } fun clear() { - preferences.edit().clear().apply() - approxSize = 0 + check(OpenLessMigrationState.settings { + preferences.edit().clear().apply() + approxSize = 0 + }) { "migration_android_busy" } } /** Current entry count, for showing usage against [MAX_ENTRIES] in settings. */ @@ -64,8 +68,10 @@ internal class StrokeUserFrequency(context: Context) { /** Replaces the entire store with [entries] — an import wholesale-restores a backup rather than merging with whatever's already on this device. */ fun importAll(entries: Map) { - check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Stroke learning save failed" } - approxSize = entries.size + check(OpenLessMigrationState.settings { + check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Stroke learning save failed" } + approxSize = entries.size + }) { "migration_android_busy" } } private fun key(context: String, candidate: String) = "$context$SEPARATOR$candidate" diff --git a/openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt b/openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt index 4912ccd1d..8f02b2db0 100644 --- a/openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt +++ b/openless-all/app/android/kotlin/androidTest/OpenLessMigrationBundleInstrumentedTest.kt @@ -1,14 +1,104 @@ package com.openless.app +import android.content.Context +import android.content.ContextWrapper +import android.content.SharedPreferences import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse +import org.json.JSONObject import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class OpenLessMigrationBundleInstrumentedTest { + private fun isolatedContext(): Context { + val target = InstrumentationRegistry.getInstrumentation().targetContext + val prefix = "migration-test-${java.util.UUID.randomUUID()}-" + return object : ContextWrapper(target) { + override fun getApplicationContext(): Context = this + override fun getSharedPreferences(name: String, mode: Int): SharedPreferences = target.getSharedPreferences(prefix + name, mode) + } + } + + @Test + fun failedAndroidWritesCanRestoreEveryStoreAndOptionalField() { + val context = isolatedContext() + OpenLessMigrationState.initialize(context, false) + context.getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE).edit().putString("unrelated", "keep").commit() + val capture = JSONObject(OpenLessMigrationState.capture(context)) + val before = capture.getJSONObject("value").toString() + val next = JSONObject(before).apply { + getJSONObject("preferences").put("locale", "zh-CN").put("themeMode", "dark") + getJSONObject("strokeFrequency").put("new-word", "1,2") + getJSONObject("pinyinLearnedPhrases").put("xx", "new-word,3") + }.toString() + for (stage in listOf("preferences", "strokeFrequency", "pinyinLearnedPhrases")) { + val lease = OpenLessMigrationState.acquire(JSONObject(OpenLessMigrationState.capture(context)).getString("revision")) + try { + val result = runCatching { + OpenLessMigrationState.restore(lease) { + OpenLessSettingsExport.replacePortable(context, next) { if (it == stage) error("injected write failure") } + } + } + assertTrue(result.isFailure) + OpenLessMigrationState.replace(context, lease, before) + assertEquals(before, OpenLessMigrationState.captureLocked(context, lease)) + assertFalse(context.getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE).contains("locale")) + assertEquals("keep", context.getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE).getString("unrelated", null)) + } finally { OpenLessMigrationState.finish(lease, false) } + } + } + + @Test + fun writerFenceDefersLearningRejectsSettingsAndDropsOldMirrors() { + val context = isolatedContext() + OpenLessMigrationState.initialize(context, false) + val captured = JSONObject(OpenLessMigrationState.capture(context)) + val oldMirror = OpenLessMigrationState.mirrorRevision()!! + val lease = OpenLessMigrationState.acquire(captured.getString("revision")) + try { + assertFalse(OpenLessMigrationState.settings { error("must not run") }) + StrokeUserFrequency(context).record("test", "word") + assertEquals(0, JSONObject(OpenLessMigrationState.captureLocked(context, lease)).getJSONObject("strokeFrequency").length()) + OpenLessMigrationState.replace(context, lease, captured.getJSONObject("value").toString()) + } finally { OpenLessMigrationState.finish(lease, false) } + assertEquals(1, JSONObject(OpenLessMigrationState.capture(context)).getJSONObject("value").getJSONObject("strokeFrequency").length()) + var oldMirrorApplied = false + OpenLessMigrationState.mirror(oldMirror) { oldMirrorApplied = true } + assertFalse(oldMirrorApplied) + assertTrue(runCatching { OpenLessMigrationState.acquire(captured.getString("revision")) }.isFailure) + } + + @Test + fun failedRecoveryKeepsWritersFencedUntilRecoveryFinishes() { + val context = isolatedContext() + OpenLessMigrationState.initialize(context, false) + val snapshot = JSONObject(OpenLessMigrationState.capture(context)) + val lease = OpenLessMigrationState.acquire(snapshot.getString("revision")) + OpenLessMigrationState.finish(lease, true) + assertFalse(OpenLessMigrationState.settings { error("must not run") }) + val recovery = OpenLessMigrationState.acquire("") + try { OpenLessMigrationState.replace(context, recovery, snapshot.getJSONObject("value").toString()) } + finally { OpenLessMigrationState.finish(recovery, false) } + assertTrue(OpenLessMigrationState.settings {}) + } + + @Test + fun portableSchemaDoesNotCoerceWrongTypesOrAcceptOutdatedLearning() { + val context = isolatedContext() + OpenLessMigrationState.initialize(context, false) + val valid = JSONObject(OpenLessMigrationState.capture(context)).getJSONObject("value").toString() + assertTrue(OpenLessSettingsExport.validatePortable(valid)) + for ((key, value) in listOf("hapticEnabled" to "true", "hapticAmplitude" to 0, "hapticAmplitude" to 1.5, "locale" to 1, "themeMode" to "invalid")) { + val invalid = JSONObject(valid).apply { getJSONObject("preferences").put(key, value) } + assertFalse(OpenLessSettingsExport.validatePortable(invalid.toString())) + } + assertFalse(OpenLessSettingsExport.validatePortable(JSONObject(valid).put("learningPrivacyVersion", 0).toString())) + } + @Test fun appContextIsAvailableAndSharePolicyIsRedacted() { val context = InstrumentationRegistry.getInstrumentation().targetContext diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs index bcc0d10da..ba1acdd88 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -2,6 +2,17 @@ use super::OpenLessBackend; use crate::{cloud_sync_e2ee::*, BackendError}; impl OpenLessBackend { + pub fn bind_android_migration_state( + &self, + state: std::sync::Arc, + ) -> Result<(), BackendError> { + self.local_migration_store + .bind_android_migration_state(state) + .map_err(|error| { + BackendError::new(crate::BackendErrorCode::InvalidState, error.to_string()) + }) + } + pub async fn export_local_migration_bundle( &self, options: crate::local_migration_bundle::BundleExportOptions, @@ -27,10 +38,10 @@ impl OpenLessBackend { &self, bytes: &[u8], password: Option, - portable_android_preferences: Option, + mode: crate::local_migration_bundle::RestoreMode, ) -> Result { self.local_migration_store - .preview_local_bundle(bytes, password, portable_android_preferences) + .preview_local_bundle(bytes, password, mode) .await .map_err(|error| { BackendError::new(crate::BackendErrorCode::InvalidState, error.to_string()) @@ -44,7 +55,7 @@ impl OpenLessBackend { mode: crate::local_migration_bundle::RestoreMode, choices: &[crate::cloud_sync_e2ee_documents::ConflictChoice], expected_generation: Option, - portable_android_preferences: Option, + expected_android_revision: Option, ) -> Result { self.local_migration_store .import_local_bundle( @@ -53,7 +64,7 @@ impl OpenLessBackend { mode, choices, expected_generation, - portable_android_preferences, + expected_android_revision, ) .await .map_err(|error| { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs index 42776038a..7d863dd1a 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs @@ -65,6 +65,7 @@ pub struct MergePreview { active_choices: BTreeMap>, active_conflicts: BTreeMap, Option)>, agreed_tie_order: BTreeMap>>, + replace_local_units: Option, } impl fmt::Debug for MergePreview { @@ -108,6 +109,17 @@ impl MergePreview { } } for (id, (namespace, local, remote)) in std::mem::take(&mut self.active_conflicts) { + if selected[&id] == ConflictSide::Local { + if let (Some(units), Some(channel_id)) = (&self.replace_local_units, &local) { + let key = DocumentKey { + kind: DocumentKind::Channels, + id: format!("{}:{channel_id}", namespace.as_str()), + }; + if let Some(unit) = units.get(&key) { + self.accepted.entry(key).or_insert_with(|| unit.clone()); + } + } + } self.active_choices.insert( namespace, match selected[&id] { @@ -193,6 +205,7 @@ pub fn diff_sync_documents( active_choices: BTreeMap::new(), active_conflicts: BTreeMap::new(), agreed_tie_order, + replace_local_units: None, }; for key in keys { let ancestor = base.as_ref().and_then(|map| map.get(&key)); @@ -303,6 +316,25 @@ pub fn diff_sync_documents( Ok(preview) } +/// Local Replace removes receiver-only units, but still honors every explicit choice. +pub fn diff_local_replace_documents( + local: &ValidatedSyncDocuments, + incoming: &ValidatedSyncDocuments, +) -> DocumentResult { + let mut preview = diff_sync_documents(None, local, incoming)?; + let incoming_units = units(&incoming.set); + preview + .accepted + .retain(|key, _| incoming_units.contains_key(key)); + for namespace in [SyncNamespace::Asr, SyncNamespace::Llm, SyncNamespace::Omni] { + if active_channel(&incoming.set, namespace)?.is_none() { + preview.active_choices.insert(namespace, None); + } + } + preview.replace_local_units = Some(units(&local.set)); + Ok(preview) +} + fn active_channel(set: &DocumentSet, namespace: SyncNamespace) -> DocumentResult> { for doc in set .documents diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs index 4b234b297..282df893a 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs @@ -13,8 +13,8 @@ pub use export::{ export_sync_documents, secret_rows, ui_preferences, vocabulary_records, }; pub use merge::{ - diff_sync_documents, record_missing_tombstones, ConflictChoice, ConflictReason, ConflictSide, - MergePreview, RedactedConflict, + diff_local_replace_documents, diff_sync_documents, record_missing_tombstones, ConflictChoice, + ConflictReason, ConflictSide, MergePreview, RedactedConflict, }; pub use restore::{ apply_sync_restore, prepare_sync_restore, recover_sync_restore, CryptoJournalProtector, diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs index c44dc72ba..ffd4a1d4f 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs @@ -19,6 +19,13 @@ use super::validate::{uuid_v4, validate_scope, validate_sync_documents}; pub struct RestorePlan { desired: ValidatedSyncDocuments, context: RestoreContext, + android_revision: Option, +} +impl RestorePlan { + pub(crate) fn with_android_revision(mut self, revision: Option) -> Self { + self.android_revision = revision; + self + } } impl fmt::Debug for RestorePlan { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -151,7 +158,11 @@ pub fn prepare_sync_restore( { return Err(DocumentError::StalePreview); } - Ok(RestorePlan { desired, context }) + Ok(RestorePlan { + desired, + context, + android_revision: None, + }) } /// No caller cancellation branch is allowed after the durable journal exists. @@ -171,6 +182,7 @@ pub async fn apply_sync_restore( Some(plan.context.local_generation), ) .await?; + lease.prepare_target(&plan.desired, plan.android_revision.as_deref())?; let before = export_snapshot(lease.capture().await?)?; if before.generation != plan.context.local_generation { return Err(DocumentError::StalePreview); @@ -433,6 +445,8 @@ pub async fn recover_sync_restore( .map_err(|_| DocumentError::RecoveryRequired)?; if let Some(mut receipt) = lease.completed_restore(&journal.operation_id)? { // New user mutations may exist after this receipt. Never replay an obsolete image. + lease.prepare_target(if receipt.committed { &after } else { &before }, None)?; + lease.finish_local_state()?; receipt.journal_cleanup_pending = storage.clear(scope).await.is_err(); return Ok(if receipt.committed { RecoveryOutcome::Committed(receipt) @@ -443,6 +457,7 @@ pub async fn recover_sync_restore( lease.mark_recovery_pending(&journal.operation_id)?; lease.mark_journal_ready(&journal.operation_id)?; let committed = journal.phase == Phase::Committed; + lease.prepare_target(if committed { &after } else { &before }, None)?; replace_and_verify(lease.as_mut(), if committed { after } else { before }) .await .map_err(|_| DocumentError::RecoveryRequired)?; @@ -522,6 +537,7 @@ async fn finish( ) -> DocumentResult { let mut receipt = lease.finish_restore(&journal.operation_id, journal.phase == Phase::Committed)?; + lease.finish_local_state()?; receipt.journal_cleanup_pending = storage.clear(journal.scope.clone()).await.is_err(); Ok(receipt) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs index 14a5bd1c7..06d091337 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs @@ -346,6 +346,22 @@ pub trait RestoreBackend: Send + Sync { } pub trait RestoreLease: Send { + /// Release a local Host fence after the durable Core receipt, before removing the journal. + fn finish_local_state(&mut self) -> DocumentResult<()> { + Ok(()) + } + /// Local migration Hosts may attach an external store before the before-image is captured. + fn prepare_target( + &mut self, + _target: &ValidatedSyncDocuments, + revision: Option<&str>, + ) -> DocumentResult<()> { + if revision.is_some() { + Err(DocumentError::Unsupported) + } else { + Ok(()) + } + } fn capture(&mut self) -> BoxFuture<'_, DocumentResult>; /// Local-only side effects never enter cloud documents. The encrypted journal may retain diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index 984bb3152..40f24129c 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -47,6 +47,8 @@ struct Inner { metadata: tokio::sync::Mutex<()>, runtime_idle: std::sync::Mutex bool + Send + Sync>>>, runtime_effects: std::sync::Mutex>>, + android_migration: + std::sync::OnceLock>, } impl CoreSyncStore { @@ -83,6 +85,7 @@ impl CoreSyncStore { metadata: tokio::sync::Mutex::new(()), runtime_idle: std::sync::Mutex::new(None), runtime_effects: std::sync::Mutex::new(None), + android_migration: std::sync::OnceLock::new(), }), }) } @@ -90,6 +93,16 @@ impl CoreSyncStore { pub fn generation(&self) -> DocumentResult { self.inner.gate.generation() } + + pub fn bind_android_migration_state( + &self, + state: Arc, + ) -> DocumentResult<()> { + self.inner + .android_migration + .set(state) + .map_err(|_| DocumentError::InvalidDocument) + } pub fn device(&self) -> SourceDevice { self.inner.device.clone() } @@ -239,11 +252,22 @@ impl CoreSyncStore { &self, desired: ValidatedSyncDocuments, context: RestoreContext, + ) -> DocumentResult { + self.restore_scope_with_android_revision(desired, context, None) + .await + } + + async fn restore_scope_with_android_revision( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + android_revision: Option, ) -> DocumentResult { self.validate_local_scope(&context.scope)?; let scope = context.scope.clone(); let desired = native::canonicalize_native_documents(desired)?; - let plan = crate::cloud_sync_e2ee_documents::prepare_sync_restore(desired, context)?; + let plan = crate::cloud_sync_e2ee_documents::prepare_sync_restore(desired, context)? + .with_android_revision(android_revision); let store = self.clone(); // Detach only the owned, journalled operation: dropping an IPC waiter cannot cancel it. self.spawn_owned(async move { @@ -528,6 +552,14 @@ struct NativeLease { scope: SyncScope, scope_id: String, permit: ExclusivePermit, + android: Option>, +} +impl Drop for NativeLease { + fn drop(&mut self) { + if let Some(android) = &mut self.android { + let _ = android.finish(self.store.inner.gate.recovery_required().unwrap_or(true)); + } + } } impl RestoreBackend for CoreSyncStore { fn acquire( @@ -553,12 +585,49 @@ impl RestoreBackend for CoreSyncStore { scope, scope_id, permit, + android: None, }) as Box) }) } } impl RestoreLease for NativeLease { + fn finish_local_state(&mut self) -> DocumentResult<()> { + if let Some(android) = &mut self.android { + android + .finish(false) + .map_err(|_| DocumentError::RecoveryRequired)?; + self.android = None; + } + Ok(()) + } + fn prepare_target( + &mut self, + target: &ValidatedSyncDocuments, + revision: Option<&str>, + ) -> DocumentResult<()> { + if self.scope != self.store.local_bundle_scope() { + return if revision.is_none() { + Ok(()) + } else { + Err(DocumentError::Unsupported) + }; + } + if let Some(value) = android_settings(target) { + crate::local_migration_bundle::validate_portable_android_preferences(value) + .map_err(|_| DocumentError::InvalidDocument)?; + let state = self + .store + .inner + .android_migration + .get() + .ok_or(DocumentError::Unsupported)?; + self.android = Some(state.acquire(revision)?); + } else if revision.is_some() { + return Err(DocumentError::InvalidDocument); + } + Ok(()) + } fn capture( &mut self, ) -> BoxFuture<'_, DocumentResult> { @@ -571,9 +640,21 @@ impl RestoreLease for NativeLease { .read_scope(self.scope.clone()) .await?, )?; - self.store + let mut snapshot = self + .store .capture_native(&self.scope, &self.permit, &state) - .await + .await?; + if let Some(android) = &self.android { + let value = android.capture()?; + snapshot.retained_documents.retain(|doc| { + !(doc.kind == DocumentKind::UiPreferences + && doc.id == crate::local_migration_bundle::ANDROID_SETTINGS_ID) + }); + snapshot + .retained_documents + .push(android_document(value.into_value())); + } + Ok(snapshot) }) } fn capture_rollback_state(&mut self) -> BoxFuture<'_, DocumentResult> { @@ -606,7 +687,12 @@ impl RestoreLease for NativeLease { self.store.runtime_effects()?; self.store .replace_native(&self.scope, &documents, &self.permit) - .await + .await?; + if let (Some(android), Some(value)) = (&mut self.android, android_settings(&documents)) + { + android.replace(&SecretJson::new(value.clone()))?; + } + Ok(()) }) } fn reload(&mut self) -> BoxFuture<'_, DocumentResult<()>> { @@ -649,16 +735,82 @@ fn receipt(value: gate::GateRestoreReceipt) -> crate::cloud_sync_e2ee_documents: } } +fn android_settings(documents: &ValidatedSyncDocuments) -> Option<&serde_json::Value> { + documents + .documents() + .documents + .iter() + .find(|doc| { + doc.kind == DocumentKind::UiPreferences + && doc.id == crate::local_migration_bundle::ANDROID_SETTINGS_ID + }) + .map(|doc| &doc.value) +} + +fn android_document(value: serde_json::Value) -> LogicalDocument { + LogicalDocument { + id: crate::local_migration_bundle::ANDROID_SETTINGS_ID.into(), + kind: DocumentKind::UiPreferences, + schema_version: crate::cloud_sync_e2ee_documents::DOCUMENT_SCHEMA_VERSION, + value, + } +} + +fn local_bundle_diff( + current: &ValidatedSyncDocuments, + incoming: &ValidatedSyncDocuments, + mode: RestoreMode, +) -> DocumentResult { + match mode { + RestoreMode::Merge => { + crate::cloud_sync_e2ee_documents::diff_sync_documents(None, current, incoming) + } + RestoreMode::Replace => { + crate::cloud_sync_e2ee_documents::diff_local_replace_documents(current, incoming) + } + } +} + +fn merge_android_learning( + current: &ValidatedSyncDocuments, + desired: &mut ValidatedSyncDocuments, +) -> Result<(), BundleError> { + let Some(local) = android_settings(current) else { + return Ok(()); + }; + let Some(target) = desired.set.documents.iter_mut().find(|doc| { + doc.kind == DocumentKind::UiPreferences + && doc.id == crate::local_migration_bundle::ANDROID_SETTINGS_ID + }) else { + return Ok(()); + }; + for key in ["strokeFrequency", "pinyinLearnedPhrases"] { + let incoming = target.value[key] + .as_object_mut() + .ok_or(BundleError::InvalidSchema)?; + for (id, value) in local[key].as_object().ok_or(BundleError::InvalidSchema)? { + if incoming.len() >= 20_000 { + break; + } + incoming.entry(id.clone()).or_insert_with(|| value.clone()); + } + } + crate::local_migration_bundle::validate_portable_android_preferences(&target.value) +} + impl CoreSyncStore { /// Export the complete local state without binding the user file to a cloud account. pub async fn export_local_bundle( &self, options: BundleExportOptions, ) -> Result { - let captured = self - .export_scope(self.local_bundle_scope()) - .await - .map_err(BundleError::from)?; + let (captured, _) = self.capture_local_bundle().await?; + let options = BundleExportOptions { + portable_android_preferences: android_settings(&captured.documents) + .cloned() + .or(options.portable_android_preferences), + ..options + }; export_validated_bundle(&captured.documents, options) } @@ -667,56 +819,82 @@ impl CoreSyncStore { crate::local_migration_bundle::inspect_bundle(bytes) } - fn overlay_portable_android_preferences( - current: &ValidatedSyncDocuments, - value: Option, - ) -> Result { - let Some(value) = value else { - return Ok(current.clone()); - }; - let mut set = current.documents().clone(); - set.documents.retain(|document| { - !(document.kind == DocumentKind::UiPreferences - && document.id == "androidPortableSettings") - }); - set.documents.push(LogicalDocument { - id: "androidPortableSettings".into(), - kind: DocumentKind::UiPreferences, - schema_version: crate::cloud_sync_e2ee_documents::DOCUMENT_SCHEMA_VERSION, - value, - }); - crate::cloud_sync_e2ee_documents::revalidate_sync_documents( - set, - current.observed_revision(), - ) - .map_err(BundleError::from) + async fn capture_local_bundle( + &self, + ) -> Result<(ExportedDocuments, Option), BundleError> { + let platform = self.inner.android_migration.get(); + let android = platform.map(|state| state.capture()).transpose()?; + let mut captured = self.export_scope(self.local_bundle_scope()).await?; + if let Some(snapshot) = &android { + crate::local_migration_bundle::validate_portable_android_preferences( + snapshot.value.expose(), + )?; + captured.documents.set.documents.retain(|doc| { + !(doc.kind == DocumentKind::UiPreferences + && doc.id == crate::local_migration_bundle::ANDROID_SETTINGS_ID) + }); + captured + .documents + .set + .documents + .push(android_document(snapshot.value.expose().clone())); + captured.documents = crate::cloud_sync_e2ee_documents::revalidate_sync_documents( + captured.documents.set.clone(), + captured.documents.observed_revision(), + )?; + if platform.expect("captured platform").capture()?.revision != snapshot.revision { + return Err(BundleError::StalePreview); + } + } + Ok((captured, android.map(|snapshot| snapshot.revision))) } pub async fn preview_local_bundle( &self, bytes: &[u8], password: Option, - portable_android_preferences: Option, + mode: RestoreMode, ) -> Result { - let scope = self.local_bundle_scope(); - let captured = self.export_scope(scope).await.map_err(BundleError::from)?; + let (captured, observed_android_revision) = self.capture_local_bundle().await?; let generation = captured.generation; - let current = Self::overlay_portable_android_preferences( - &captured.documents, - portable_android_preferences, - )?; + let current = captured.documents; let decoded = decode_bundle(bytes, password.as_ref(), generation)?; let inspection = decoded.inspection.clone(); - let bundle_documents = self.prepare_bundle_documents(¤t, decoded)?; - let preview = crate::cloud_sync_e2ee_documents::diff_sync_documents( - None, - ¤t, - &bundle_documents, - ) - .map_err(BundleError::from)?; + let incoming = self.prepare_bundle_documents(¤t, decoded)?; + let removed_documents = if mode == RestoreMode::Replace { + current + .documents() + .documents + .iter() + // The native restore materializes missing preferences and built-in styles. + .filter(|doc| { + !matches!( + doc.kind, + DocumentKind::Preferences | DocumentKind::UiPreferences + ) && !(doc.kind == DocumentKind::StylePacks + && doc.value["pack"]["kind"] == "builtin") + }) + .filter(|doc| { + !incoming + .documents() + .documents + .iter() + .any(|other| other.kind == doc.kind && other.id == doc.id) + }) + .map(|doc| crate::cloud_sync_e2ee_documents::DocumentKey { + kind: doc.kind, + id: doc.id.clone(), + }) + .collect() + } else { + Vec::new() + }; + let preview = local_bundle_diff(¤t, &incoming, mode)?; Ok(BundleMergePreview { inspection, observed_generation: generation, + observed_android_revision, + removed_documents, conflicts: preview.conflicts().to_vec(), }) } @@ -730,17 +908,14 @@ impl CoreSyncStore { mode: RestoreMode, choices: &[ConflictChoice], expected_generation: Option, - portable_android_preferences: Option, + expected_android_revision: Option, ) -> Result { let scope = self.local_bundle_scope(); - let captured = self - .export_scope(scope.clone()) - .await - .map_err(BundleError::from)?; - let current = Self::overlay_portable_android_preferences( - &captured.documents, - portable_android_preferences.clone(), - )?; + let (captured, android_revision) = self.capture_local_bundle().await?; + if android_revision != expected_android_revision { + return Err(BundleError::StalePreview); + } + let current = captured.documents.clone(); let generation = captured.generation; if expected_generation.is_some_and(|expected| expected != generation) { return Err(BundleError::StalePreview); @@ -748,18 +923,11 @@ impl CoreSyncStore { let decoded = decode_bundle(bytes, password.as_ref(), generation)?; let inspection = decoded.inspection.clone(); let bundle_documents = self.prepare_bundle_documents(¤t, decoded)?; - let desired = match mode { - RestoreMode::Replace => bundle_documents, - RestoreMode::Merge => { - let preview = crate::cloud_sync_e2ee_documents::diff_sync_documents( - None, - ¤t, - &bundle_documents, - ) - .map_err(BundleError::from)?; - preview.resolve(choices).map_err(BundleError::from)? - } - }; + let mut desired = local_bundle_diff(¤t, &bundle_documents, mode)?.resolve(choices)?; + if mode == RestoreMode::Merge { + merge_android_learning(¤t, &mut desired)?; + } + let portable_android_preferences = android_settings(¤t).cloned(); let backup_options = if inspection.credentials_present { BundleExportOptions { mode: crate::local_migration_bundle::BundleMode::PersonalMigration, @@ -784,7 +952,7 @@ impl CoreSyncStore { local_generation: generation, target_device: self.inner.device.clone(), }; - self.restore_scope(desired, context) + self.restore_scope_with_android_revision(desired, context, android_revision) .await .map_err(BundleError::from)?; Ok(inspection) @@ -793,10 +961,45 @@ impl CoreSyncStore { fn prepare_bundle_documents( &self, current: &ValidatedSyncDocuments, - decoded: crate::local_migration_bundle::DecodedBundle, + mut decoded: crate::local_migration_bundle::DecodedBundle, ) -> Result { use crate::cloud_sync_e2ee_documents::DocumentKind; + decoded + .documents + .set + .documents + .retain(|doc| !matches!(doc.kind, DocumentKind::History | DocumentKind::Activity)); + decoded + .documents + .set + .tombstones + .retain(|doc| !matches!(doc.kind, DocumentKind::History | DocumentKind::Activity)); + decoded.documents.set.documents.extend( + current + .documents() + .documents + .iter() + .filter(|doc| matches!(doc.kind, DocumentKind::History | DocumentKind::Activity)) + .cloned(), + ); + decoded.documents.set.tombstones.extend( + current + .documents() + .tombstones + .iter() + .filter(|doc| matches!(doc.kind, DocumentKind::History | DocumentKind::Activity)) + .cloned(), + ); + if android_settings(&decoded.documents).is_none() { + if let Some(value) = android_settings(current) { + decoded + .documents + .set + .documents + .push(android_document(value.clone())); + } + } if !matches!( decoded.inspection.mode, crate::local_migration_bundle::BundleMode::ShareRedacted @@ -971,7 +1174,7 @@ impl CoreSyncStore { value: json!({ "channelId": channel_id, "namespace": namespace, - "accounts": {}, + "accounts": channel_value.get("configuration").cloned().unwrap_or_else(|| json!({})), }), }); } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index cc7020bce..5e6c4acb7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -352,6 +352,7 @@ impl Fixture { effects, } = self; let device = store.device(); + let android = store.inner.android_migration.get().cloned(); drop(store); drop(repo); // Stand-in for reopening the persistent OS vault, not retaining the old process gate. @@ -370,6 +371,9 @@ impl Fixture { .unwrap(); store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); *effects.gate.lock().unwrap() = Some(Arc::downgrade(&store.inner.gate)); + if let Some(android) = android { + store.bind_android_migration_state(android).unwrap(); + } store .bind_runtime_effects(Arc::new(effects.clone())) .unwrap(); @@ -1739,3 +1743,150 @@ fn setup_prompt_store_probe_requires_idle_runtime_and_no_active_writer() { .store(false, Ordering::Release); assert!(fixture.store.setup_prompt_state().unwrap().is_some()); } + +#[tokio::test] +async fn review_redacted_import_new_channel_with_public_settings() { + let source = Fixture::new(); + *source.credentials.value.lock().unwrap() = SyncCredentials { + channels: vec![serde_json::from_value(json!({"id":"custom","namespace":"llm","providerType":"requesty","name":"Custom","enabled":true,"order":0,"active":true})).unwrap()], + credentials: vec![serde_json::from_value(json!({"channelId":"custom","namespace":"llm","accounts":{"ark.api_key":"sender-secret","ark.endpoint":"https://example.test/v1","ark.model_id":"model"}})).unwrap()], + }; + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let target = Fixture::new(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, RestoreMode::Replace) + .await + .unwrap(); + let choices: Vec<_> = preview + .conflicts + .iter() + .map(|conflict| ConflictChoice { + conflict_id: conflict.conflict_id.clone(), + side: ConflictSide::Remote, + }) + .collect(); + let result = target + .store + .import_local_bundle( + &artifact.bytes, + None, + RestoreMode::Replace, + &choices, + Some(preview.observed_generation), + None, + ) + .await; + assert!( + result.is_ok(), + "redacted new channel restore failed: {:?}", + result + ); +} + +#[tokio::test] +async fn review_replace_honors_keep_local_credentials() { + let source = Fixture::new(); + let target = Fixture::new(); + for (fixture, secret) in [(&source, "sender-secret"), (&target, "receiver-secret")] { + *fixture.credentials.value.lock().unwrap() = SyncCredentials { + channels: vec![serde_json::from_value(json!({"id":"custom","namespace":"llm","providerType":"requesty","name":"Custom","enabled":true,"order":0,"active":true})).unwrap()], + credentials: vec![serde_json::from_value(json!({"channelId":"custom","namespace":"llm","accounts":{"ark.api_key":secret}})).unwrap()], + }; + } + let artifact = source + .store + .export_local_bundle(BundleExportOptions { + mode: crate::local_migration_bundle::BundleMode::PersonalMigration, + credential_policy: crate::local_migration_bundle::CredentialPolicy::PasswordEncrypted, + password: Some(BundlePassword::new("password").unwrap()), + ..BundleExportOptions::default() + }) + .await + .unwrap(); + let preview = target + .store + .preview_local_bundle( + &artifact.bytes, + Some(BundlePassword::new("password").unwrap()), + RestoreMode::Replace, + ) + .await + .unwrap(); + let choices: Vec<_> = preview + .conflicts + .iter() + .map(|conflict| ConflictChoice { + conflict_id: conflict.conflict_id.clone(), + side: crate::cloud_sync_e2ee_documents::ConflictSide::Local, + }) + .collect(); + assert!(!choices.is_empty()); + target + .store + .import_local_bundle( + &artifact.bytes, + Some(BundlePassword::new("password").unwrap()), + RestoreMode::Replace, + &choices, + Some(preview.observed_generation), + None, + ) + .await + .unwrap(); + assert_eq!( + target.credentials.value.lock().unwrap().credentials[0].accounts["ark.api_key"], + "receiver-secret", + "keep-local choice was ignored" + ); +} + +#[tokio::test] +async fn review_rejects_invalid_android_preferences_before_core_commit() { + let source = Fixture::new(); + let captured = source + .store + .export_scope(source.scope.clone()) + .await + .unwrap(); + let mut set = captured.documents.documents().clone(); + set_doc( + &mut set, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + set_doc( + &mut set, + DocumentKind::UiPreferences, + "androidPortableSettings", + json!({ + "schemaVersion":1,"learningPrivacyVersion":1,"preferences":{"themeMode":"INVALID_THEME"},"strokeFrequency":{},"pinyinLearnedPhrases":{} + }), + ); + let artifact = export_validated_bundle(&validate(set), BundleExportOptions::default()).unwrap(); + let target = Fixture::new(); + let result = target + .store + .import_local_bundle( + &artifact.bytes, + None, + RestoreMode::Replace, + &[], + Some(target.store.generation().unwrap()), + None, + ) + .await; + let committed_theme = target.repo.preferences.get().theme_mode; + assert!( + result.is_err(), + "invalid Android preference accepted and core theme committed as {:?}", + committed_theme + ); +} + +mod local_migration; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs new file mode 100644 index 000000000..353fb4de1 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs @@ -0,0 +1,818 @@ +use super::*; +use crate::local_migration_bundle::{ + AndroidMigrationLease, AndroidMigrationSnapshot, AndroidMigrationState, +}; + +fn portable(theme: &str) -> Value { + json!({"schemaVersion":1,"learningPrivacyVersion":1,"preferences":{"themeMode":theme,"hapticEnabled":true,"hapticAmplitude":55,"hapticDurationMs":12,"englishSuggestionsEnabled":true,"debugFeaturesEnabled":false},"strokeFrequency":{"word":"1,2"},"pinyinLearnedPhrases":{"xx":"word,2"}}) +} + +#[derive(Clone)] +struct AndroidState(Arc>); +struct AndroidData { + value: Value, + revision: String, + leased: bool, + recovering: bool, + failures: VecDeque, + finish_failures: usize, +} +impl AndroidState { + fn new(value: Value) -> Self { + Self(Arc::new(Mutex::new(AndroidData { + value, + revision: uuid::Uuid::new_v4().to_string(), + leased: false, + recovering: false, + failures: VecDeque::new(), + finish_failures: 0, + }))) + } + fn value(&self) -> Value { + self.0.lock().unwrap().value.clone() + } +} +impl AndroidMigrationState for AndroidState { + fn capture(&self) -> DocumentResult { + let data = self.0.lock().unwrap(); + if data.leased || data.recovering { + return Err(DocumentError::RuntimeBusy); + } + Ok(AndroidMigrationSnapshot { + revision: data.revision.clone(), + value: SecretJson::new(data.value.clone()), + }) + } + fn acquire(&self, expected: Option<&str>) -> DocumentResult> { + let mut data = self.0.lock().unwrap(); + if data.leased { + return Err(DocumentError::RuntimeBusy); + } + if expected.is_some_and(|expected| expected != data.revision || data.recovering) { + return Err(DocumentError::StalePreview); + } + data.leased = true; + Ok(Box::new(self.clone())) + } +} +impl AndroidMigrationLease for AndroidState { + fn capture(&self) -> DocumentResult { + Ok(SecretJson::new(self.value())) + } + fn replace(&mut self, value: &SecretJson) -> DocumentResult<()> { + let mut data = self.0.lock().unwrap(); + let fail = data.failures.pop_front(); + for (index, key) in ["preferences", "strokeFrequency", "pinyinLearnedPhrases"] + .iter() + .enumerate() + { + data.value[key] = value.expose()[key].clone(); + data.revision = uuid::Uuid::new_v4().to_string(); + if fail == Some(index) { + return Err(DocumentError::CaptureFailed); + } + } + Ok(()) + } + fn finish(&mut self, recovery_required: bool) -> DocumentResult<()> { + let mut data = self.0.lock().unwrap(); + data.leased = false; + if data.finish_failures > 0 { + data.finish_failures -= 1; + data.recovering = true; + return Err(DocumentError::CaptureFailed); + } + data.recovering = recovery_required; + Ok(()) + } +} + +fn fixture(theme: &str) -> (Fixture, AndroidState) { + let fixture = Fixture::new(); + let state = AndroidState::new(portable(theme)); + fixture + .store + .bind_android_migration_state(Arc::new(state.clone())) + .unwrap(); + (fixture, state) +} + +#[tokio::test] +async fn data_only_export_keeps_explicit_portable_state_without_a_host_adapter() { + let source = Fixture::new(); + let artifact = source + .store + .export_local_bundle(BundleExportOptions { + portable_android_preferences: Some(portable("dark")), + ..BundleExportOptions::default() + }) + .await + .unwrap(); + let decoded = decode_bundle(&artifact.bytes, None, Revision::new(0)).unwrap(); + assert_eq!( + android_settings(&decoded.documents), + Some(&portable("dark")) + ); +} + +fn choices(preview: &BundleMergePreview, side: ConflictSide) -> Vec { + preview + .conflicts + .iter() + .map(|conflict| ConflictChoice { + conflict_id: conflict.conflict_id.clone(), + side, + }) + .collect() +} + +#[tokio::test] +async fn android_and_core_roll_back_at_each_android_write_and_recover_failed_rollback() { + let (source, _) = fixture("dark"); + source + .repo + .preferences + .update(|prefs| prefs.theme_mode = crate::contract::ThemeMode::Dark) + .unwrap(); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + for stage in 0..3 { + for fail_rollback in [false, true] { + let (target, state) = fixture("light"); + let before = state.value(); + let core_before = target.repo.preferences.get(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, RestoreMode::Replace) + .await + .unwrap(); + state.0.lock().unwrap().failures.push_back(stage); + if fail_rollback { + state.0.lock().unwrap().failures.push_back(stage); + } + let result = target + .store + .import_local_bundle( + &artifact.bytes, + None, + RestoreMode::Replace, + &choices(&preview, ConflictSide::Remote), + Some(preview.observed_generation), + preview.observed_android_revision, + ) + .await; + assert_eq!( + result.unwrap_err(), + if fail_rollback { + BundleError::RecoveryRequired + } else { + BundleError::RestoreRolledBack + } + ); + let target = if fail_rollback { + assert!(target.store.is_recovering()); + assert!(state.0.lock().unwrap().recovering); + let reopened = target.reopened(); + reopened.store.recover_registered().await.unwrap(); + reopened + } else { + target + }; + assert_eq!(state.value(), before); + assert_eq!( + serde_json::to_value(target.repo.preferences.get()).unwrap(), + serde_json::to_value(core_before).unwrap() + ); + assert!(!target.store.is_recovering()); + assert!(!state.0.lock().unwrap().recovering); + } + } +} + +#[tokio::test] +async fn stale_android_preview_is_rejected_before_any_core_write() { + let (source, _) = fixture("dark"); + let (target, state) = fixture("light"); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, RestoreMode::Merge) + .await + .unwrap(); + state.0.lock().unwrap().revision = "changed".into(); + let before = target.repo.preferences.get(); + assert_eq!( + target + .store + .import_local_bundle( + &artifact.bytes, + None, + RestoreMode::Merge, + &choices(&preview, ConflictSide::Remote), + Some(preview.observed_generation), + preview.observed_android_revision + ) + .await + .unwrap_err(), + BundleError::StalePreview + ); + assert_eq!( + serde_json::to_value(target.repo.preferences.get()).unwrap(), + serde_json::to_value(before).unwrap() + ); + assert_eq!(state.value(), portable("light")); +} + +#[tokio::test] +async fn android_choices_and_learning_follow_the_selected_mode() { + for mode in [RestoreMode::Merge, RestoreMode::Replace] { + for side in [ConflictSide::Local, ConflictSide::Remote] { + let (source, _) = fixture("dark"); + let (target, state) = fixture("light"); + state.0.lock().unwrap().value["strokeFrequency"]["local-only"] = json!("3,4"); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, mode) + .await + .unwrap(); + target + .store + .import_local_bundle( + &artifact.bytes, + None, + mode, + &choices(&preview, side), + Some(preview.observed_generation), + preview.observed_android_revision, + ) + .await + .unwrap(); + assert_eq!( + state.value()["preferences"]["themeMode"], + if side == ConflictSide::Local { + "light" + } else { + "dark" + } + ); + assert_eq!( + state.value()["strokeFrequency"].get("local-only").is_some(), + mode == RestoreMode::Merge || side == ConflictSide::Local + ); + } + } +} + +#[tokio::test] +async fn recovery_replays_android_before_or_after_from_the_durable_decision() { + for (committed, has_android_state) in + [(false, true), (true, true), (false, false), (true, false)] + { + let (target, state) = fixture("light"); + let captured = if has_android_state { + target.store.capture_local_bundle().await.unwrap().0 + } else { + target + .store + .export_scope(target.store.local_bundle_scope()) + .await + .unwrap() + }; + let before = captured.documents.documents().clone(); + let mut after = before.clone(); + set_doc( + &mut after, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + if has_android_state { + set_doc( + &mut after, + DocumentKind::UiPreferences, + "androidPortableSettings", + portable("dark"), + ); + } + let scope = target.store.local_bundle_scope(); + let operation = uuid::Uuid::new_v4().to_string(); + let permit = target.store.inner.gate.try_exclusive().unwrap(); + let local_before = target.store.capture_local_rollback_state(&permit).unwrap(); + let journal = json!({"schemaVersion":1,"scope":scope,"operationId":operation,"observedRevision":captured.documents.observed_revision(),"phase":if committed {"committed"} else {"prepared"},"before":before,"after":after,"localBefore":local_before}); + permit + .mark_restore_pending(&operation, &state::scope_id(&scope).unwrap(), &scope) + .unwrap(); + let ciphertext = target + .extensions + .protector + .seal(&scope, &operation, &serde_json::to_vec(&journal).unwrap()) + .unwrap(); + target + .store + .journal() + .save( + scope.clone(), + SealedJournal { + operation_id: operation.clone(), + ciphertext, + }, + ) + .await + .unwrap(); + permit + .mark_journal_ready(&operation, &state::scope_id(&scope).unwrap()) + .unwrap(); + drop(permit); + state.0.lock().unwrap().value = portable("system"); + state.0.lock().unwrap().recovering = has_android_state; + let target = target.reopened(); + target.store.recover_registered().await.unwrap(); + assert_eq!( + state.value(), + portable(if !has_android_state { + "system" + } else if committed { + "dark" + } else { + "light" + }) + ); + assert!(!target.store.is_recovering()); + } +} + +fn usage(set: &mut DocumentSet, id: &str, text: &str, count: u32) { + set_doc( + set, + DocumentKind::History, + id, + json!({"id":id,"createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":text,"finalText":text,"mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":0}), + ); + set_doc( + set, + DocumentKind::Activity, + "fixture-device:2026-09-26", + json!({"sourceDeviceId":"fixture-device","date":"2026-09-26","count":count,"chars":10,"durationMs":20}), + ); +} + +fn legacy_usage_bundle(bytes: &[u8]) -> Vec { + use sha2::{Digest, Sha256}; + use std::io::{Cursor, Read, Write}; + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut manifest = String::new(); + archive + .by_name("manifest.json") + .unwrap() + .read_to_string(&mut manifest) + .unwrap(); + let mut manifest: Value = serde_json::from_str(&manifest).unwrap(); + let mut documents = String::new(); + archive + .by_name("documents.json") + .unwrap() + .read_to_string(&mut documents) + .unwrap(); + let mut set: DocumentSet = serde_json::from_str(&documents).unwrap(); + usage(&mut set, "sender-history", "SENDER_PRIVATE_TEXT", 99); + set.tombstones + .push(crate::cloud_sync_e2ee_protocol::types::Tombstone { + id: "receiver-history".into(), + kind: DocumentKind::History, + deleted_at: "2026-09-26T00:00:00Z".into(), + base_revision: Revision::new(0), + }); + let documents = serde_json::to_vec(&set).unwrap(); + manifest["documents"]["bytes"] = json!(documents.len()); + manifest["documents"]["sha256"] = json!(format!("{:x}", Sha256::digest(&documents))); + let mut output = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, bytes) in [ + ("manifest.json", serde_json::to_vec(&manifest).unwrap()), + ("documents.json", documents), + ] { + output + .start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + output.write_all(&bytes).unwrap(); + } + output.finish().unwrap().into_inner() +} + +#[tokio::test] +async fn old_usage_records_and_deletions_are_skipped_in_both_restore_modes() { + let source = Fixture::new(); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let legacy = legacy_usage_bundle(&artifact.bytes); + for mode in [RestoreMode::Merge, RestoreMode::Replace] { + let target = Fixture::new(); + let before = target + .store + .export_scope(target.scope.clone()) + .await + .unwrap(); + let mut set = before.documents.documents().clone(); + usage(&mut set, "receiver-history", "RECEIVER_PRIVATE_TEXT", 41); + target + .store + .restore_scope(validate(set), target.context(&before)) + .await + .unwrap(); + let history = target.repo.history.list().unwrap(); + let activity = target.repo.activity.snapshot().unwrap(); + let preview = target + .store + .preview_local_bundle(&legacy, None, mode) + .await + .unwrap(); + assert_eq!(preview.inspection.skipped_history, 1); + assert_eq!(preview.inspection.skipped_activity, 1); + assert_eq!(preview.inspection.skipped_tombstones, 1); + target + .store + .import_local_bundle( + &legacy, + None, + mode, + &choices(&preview, ConflictSide::Remote), + Some(preview.observed_generation), + None, + ) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(target.repo.history.list().unwrap()).unwrap(), + serde_json::to_value(history).unwrap() + ); + assert_eq!( + serde_json::to_value(target.repo.activity.snapshot().unwrap()).unwrap(), + serde_json::to_value(activity).unwrap() + ); + } +} + +fn credentials(secret: &str) -> SyncCredentials { + let mut result = SyncCredentials { + channels: Vec::new(), + credentials: Vec::new(), + }; + for (namespace, provider, endpoint, model, key) in [ + ( + "asr", + "custom-openai-whisper", + "asr.endpoint", + "asr.model", + "asr.api_key", + ), + ( + "llm", + "openai", + "ark.endpoint", + "ark.model_id", + "ark.api_key", + ), + ( + "omni", + "qwen-omni", + "omni.endpoint", + "omni.model", + "omni.api_key", + ), + ] { + result.channels.push(serde_json::from_value(json!({"id":provider,"namespace":namespace,"providerType":provider,"name":provider,"enabled":true,"order":0,"active":true})).unwrap()); + result.credentials.push(serde_json::from_value(json!({"channelId":provider,"namespace":namespace,"accounts":{(endpoint):"https://example.test/v1",(model):"model",(key):secret}})).unwrap()); + } + result +} + +#[tokio::test] +async fn all_namespaces_restore_public_settings_idempotently_without_keys() { + let source = Fixture::new(); + *source.credentials.value.lock().unwrap() = credentials("sender-secret"); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + for mode in [RestoreMode::Merge, RestoreMode::Replace] { + let target = Fixture::new(); + for _ in 0..2 { + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, mode) + .await + .unwrap(); + target + .store + .import_local_bundle( + &artifact.bytes, + None, + mode, + &choices(&preview, ConflictSide::Remote), + Some(preview.observed_generation), + None, + ) + .await + .unwrap(); + let restored = target.credentials.value.lock().unwrap(); + assert_eq!(restored.channels.len(), 3); + for record in &restored.credentials { + assert_eq!(record.accounts.len(), 2); + assert!(record + .accounts + .values() + .any(|value| value == "https://example.test/v1")); + assert!(record.accounts.values().any(|value| value == "model")); + } + } + } +} + +#[tokio::test] +async fn redacted_shares_keep_receiver_secrets_and_their_endpoint_binding() { + let source = Fixture::new(); + let target = Fixture::new(); + *source.credentials.value.lock().unwrap() = credentials("sender-secret"); + *target.credentials.value.lock().unwrap() = credentials("receiver-secret"); + for record in &mut source.credentials.value.lock().unwrap().credentials { + for (account, value) in &mut record.accounts { + if account.ends_with(".endpoint") { + *value = "https://untrusted.test/v1".into(); + } + } + } + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, RestoreMode::Replace) + .await + .unwrap(); + target + .store + .import_local_bundle( + &artifact.bytes, + None, + RestoreMode::Replace, + &choices(&preview, ConflictSide::Remote), + Some(preview.observed_generation), + None, + ) + .await + .unwrap(); + for record in &target.credentials.value.lock().unwrap().credentials { + assert!(record + .accounts + .values() + .any(|value| value == "receiver-secret")); + assert!(record + .accounts + .values() + .any(|value| value == "https://example.test/v1")); + assert!(!record + .accounts + .values() + .any(|value| value == "https://untrusted.test/v1")); + } +} + +#[tokio::test] +async fn cloud_restore_does_not_require_or_write_the_android_adapter() { + let (target, state) = fixture("light"); + let before = target + .store + .export_scope(target.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + target + .store + .restore_scope(validate(desired), target.context(&before)) + .await + .unwrap(); + assert_eq!(state.value(), portable("light")); +} + +#[tokio::test] +async fn both_modes_require_exact_conflict_choices_before_writes() { + for mode in [RestoreMode::Merge, RestoreMode::Replace] { + let (source, _) = fixture("dark"); + let (target, state) = fixture("light"); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, mode) + .await + .unwrap(); + let valid = choices(&preview, ConflictSide::Local); + assert!(!valid.is_empty()); + let mut missing = valid.clone(); + missing.pop(); + let mut duplicate = valid.clone(); + duplicate.push(valid[0].clone()); + let mut unknown = valid.clone(); + unknown.push(ConflictChoice { + conflict_id: "unknown".into(), + side: ConflictSide::Remote, + }); + for invalid in [missing, duplicate, unknown] { + assert_eq!( + target + .store + .import_local_bundle( + &artifact.bytes, + None, + mode, + &invalid, + Some(preview.observed_generation), + preview.observed_android_revision.clone() + ) + .await + .unwrap_err(), + BundleError::Document(DocumentError::ConflictChoiceRequired) + ); + assert_eq!( + target.store.generation().unwrap(), + preview.observed_generation + ); + assert_eq!(state.value(), portable("light")); + } + } +} + +#[tokio::test] +async fn replace_removes_receiver_only_configuration_but_not_explicit_local_conflicts() { + for mode in [RestoreMode::Merge, RestoreMode::Replace] { + let (source, _) = fixture("dark"); + let (target, state) = fixture("light"); + let before = target + .store + .export_scope(target.store.local_bundle_scope()) + .await + .unwrap(); + let mut local = before.documents.documents().clone(); + set_doc( + &mut local, + DocumentKind::Preferences, + "futureSetting", + json!("retained"), + ); + set_doc( + &mut local, + DocumentKind::UiPreferences, + "futureUi", + json!("retained"), + ); + set_doc( + &mut local, + DocumentKind::Dictionary, + "receiver-only", + json!({"id":"receiver-only","phrase":"Keep only in merge","enabled":true,"hits":0,"createdAt":"2026-09-26T00:00:00Z","sortIndex":0}), + ); + target + .store + .restore_scope( + validate(local), + RestoreContext { + scope: target.store.local_bundle_scope(), + ..target.context(&before) + }, + ) + .await + .unwrap(); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, mode) + .await + .unwrap(); + assert_eq!( + preview + .removed_documents + .iter() + .any(|doc| doc.id == "receiver-only"), + mode == RestoreMode::Replace + ); + assert!(!preview + .removed_documents + .iter() + .any(|doc| doc.id == "futureSetting" || doc.id == "futureUi")); + target + .store + .import_local_bundle( + &artifact.bytes, + None, + mode, + &choices(&preview, ConflictSide::Local), + Some(preview.observed_generation), + preview.observed_android_revision, + ) + .await + .unwrap(); + assert_eq!( + target + .repo + .vocabulary + .list() + .unwrap() + .iter() + .any(|entry| entry.id == "receiver-only"), + mode == RestoreMode::Merge + ); + assert_eq!(state.value()["preferences"]["themeMode"], "light"); + } +} + +#[tokio::test] +async fn failed_android_fence_release_keeps_journal_and_recovery_does_not_replay_committed_data() { + let (source, _) = fixture("dark"); + let (target, state) = fixture("light"); + let artifact = source + .store + .export_local_bundle(BundleExportOptions::default()) + .await + .unwrap(); + let preview = target + .store + .preview_local_bundle(&artifact.bytes, None, RestoreMode::Replace) + .await + .unwrap(); + state.0.lock().unwrap().finish_failures = 2; + assert_eq!( + target + .store + .import_local_bundle( + &artifact.bytes, + None, + RestoreMode::Replace, + &choices(&preview, ConflictSide::Remote), + Some(preview.observed_generation), + preview.observed_android_revision + ) + .await + .unwrap_err(), + BundleError::RecoveryRequired + ); + assert_eq!( + target + .store + .inner + .gate + .registered_recovery_scopes() + .unwrap() + .len(), + 1 + ); + assert!(state.0.lock().unwrap().recovering); + target + .repo + .preferences + .update(|prefs| prefs.theme_mode = crate::contract::ThemeMode::Light) + .unwrap(); + target.store.recover_registered().await.unwrap(); + assert_eq!( + target.repo.preferences.get().theme_mode, + crate::contract::ThemeMode::Light + ); + assert_eq!(state.value(), portable("dark")); + assert!(!state.0.lock().unwrap().recovering); + assert!(target + .store + .inner + .gate + .registered_recovery_scopes() + .unwrap() + .is_empty()); +} diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/android_state.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/android_state.rs new file mode 100644 index 000000000..76e610775 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/android_state.rs @@ -0,0 +1,24 @@ +use crate::cloud_sync_e2ee_documents::{DocumentResult, SecretJson}; + +/// Live Android state is owned by the Host, not by the Activity or a cached sync extension. +pub struct AndroidMigrationSnapshot { + pub revision: String, + pub value: SecretJson, +} + +pub trait AndroidMigrationState: Send + Sync { + fn capture(&self) -> DocumentResult; + fn acquire( + &self, + expected_revision: Option<&str>, + ) -> DocumentResult>; +} + +pub trait AndroidMigrationLease: Send { + fn capture(&self) -> DocumentResult; + fn replace(&mut self, value: &SecretJson) -> DocumentResult<()>; + /// A failed recovery keeps ordinary Android writers fenced until startup recovery succeeds. + fn finish(&mut self, recovery_required: bool) -> DocumentResult<()>; +} + +pub(crate) const ANDROID_SETTINGS_ID: &str = "androidPortableSettings"; diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs index 183029710..f4fb2b7bf 100644 --- a/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs @@ -122,11 +122,7 @@ pub fn inspect_bundle(bytes: &[u8]) -> BundleResult { let entries = read_entries(bytes)?; let manifest = decode_manifest(&entries)?; let documents = validate_document_entry(&manifest, &entries)?; - let counts = section_counts(&documents)?; - Ok(inspection(&manifest, &documents, false)).map(|mut value| { - value.section_counts = counts; - value - }) + Ok(inspection(&manifest, &documents, false)) } pub fn decode_bundle( @@ -167,13 +163,43 @@ pub fn decode_bundle( credentials_unlocked = true; } let bytes = serde_json::to_vec(&root).map_err(|_| BundleError::InvalidSchema)?; - let documents = if manifest.credentials.is_none() { + let mut documents = if manifest.credentials.is_none() { parse_redacted_sync_documents(&bytes, observed_revision) } else { parse_sync_documents(&bytes, observed_revision) } .map_err(BundleError::from)?; let inspection = inspection(&manifest, &root, credentials_unlocked); + for document in &mut documents.set.documents { + if document.kind == DocumentKind::UiPreferences && document.id == super::ANDROID_SETTINGS_ID + { + let preferences = document.value["preferences"] + .as_object_mut() + .ok_or(BundleError::InvalidSchema)?; + for (key, value) in [ + ("hapticEnabled", json!(true)), + ("hapticAmplitude", json!(55)), + ("hapticDurationMs", json!(12)), + ("englishSuggestionsEnabled", json!(true)), + ("debugFeaturesEnabled", json!(false)), + ] { + preferences.entry(key).or_insert(value); + } + } + } + // Validate old bundles before dropping usage data; local migration never restores it. + documents.set.documents.retain(|document| { + !matches!( + document.kind, + DocumentKind::History | DocumentKind::Activity + ) + }); + documents.set.tombstones.retain(|document| { + !matches!( + document.kind, + DocumentKind::History | DocumentKind::Activity + ) + }); Ok(DecodedBundle { inspection, documents, @@ -201,7 +227,7 @@ fn assemble_bundle( let mut credential_documents = Vec::new(); source_documents.retain(|document| { let kind = document.get("kind").and_then(Value::as_str); - if kind == Some("device_profile") { + if matches!(kind, Some("device_profile" | "history" | "activity")) { return false; } if kind == Some("provider_credentials") { @@ -256,7 +282,10 @@ fn assemble_bundle( .and_then(Value::as_array_mut) { tombstones.retain(|tombstone| { - !(matches!(options.mode, BundleMode::ShareRedacted) + !matches!( + tombstone.get("kind").and_then(Value::as_str), + Some("history" | "activity" | "device_profile") + ) && !(matches!(options.mode, BundleMode::ShareRedacted) && matches!( tombstone.get("kind").and_then(Value::as_str), Some("provider_credentials" | "device_profile") @@ -317,7 +346,7 @@ fn assemble_bundle( kdf: "argon2id".into(), aead: "xchacha20poly1305".into(), }), - sections: section_counts(&root)?, + sections: section_counts(&root, false)?, permissions_migrated: false, device_profile_migrated: false, }; @@ -335,6 +364,28 @@ fn assemble_bundle( } fn inspection(manifest: &Manifest, root: &Value, credentials_unlocked: bool) -> BundleInspection { + let skipped_history = root["documents"].as_array().map_or(0, |documents| { + documents + .iter() + .filter(|doc| doc["kind"] == "history") + .count() + }); + let skipped_activity = root["documents"].as_array().map_or(0, |documents| { + documents + .iter() + .filter(|doc| doc["kind"] == "activity") + .count() + }); + let skipped_tombstones = root["tombstones"].as_array().map_or(0, |documents| { + documents + .iter() + .filter(|doc| matches!(doc["kind"].as_str(), Some("history" | "activity"))) + .count() + }); + let mut section_counts = + section_counts(root, credentials_unlocked).expect("validated document kinds"); + section_counts.history = 0; + section_counts.activity = 0; let (document_count, tombstone_count) = root .as_object() .map(|object| { @@ -358,9 +409,12 @@ fn inspection(manifest: &Manifest, root: &Value, credentials_unlocked: bool) -> created_at: manifest.created_at.clone(), platform: manifest.platform.clone(), mode: manifest.mode, - document_count, - tombstone_count, - section_counts: manifest.sections.clone(), + document_count: document_count - skipped_history - skipped_activity, + tombstone_count: tombstone_count - skipped_tombstones, + section_counts, + skipped_history, + skipped_activity, + skipped_tombstones, credentials_present: manifest.credentials.is_some(), credentials_encrypted: manifest.credentials.is_some(), credentials_unlocked, @@ -427,6 +481,15 @@ fn validate_document_entry( { return Err(BundleError::InvalidSchema); } + section_counts(&root, false)?; + for document in root["documents"] + .as_array() + .ok_or(BundleError::InvalidSchema)? + { + if document["kind"] == "ui_preferences" && document["id"] == "androidPortableSettings" { + validate_portable_android_preferences(&document["value"])?; + } + } if let Some(credentials) = &manifest.credentials { let bytes = entries .get(&credentials.entry) @@ -440,7 +503,7 @@ fn validate_document_entry( Ok(root) } -fn section_counts(root: &Value) -> BundleResult { +fn section_counts(root: &Value, credentials_unlocked: bool) -> BundleResult { let mut counts = SectionCounts::default(); for document in root .get("documents") @@ -457,6 +520,7 @@ fn section_counts(root: &Value) -> BundleResult { Some("style_packs") => counts.style_packs += 1, Some("history") => counts.history += 1, Some("activity") => counts.activity += 1, + Some("provider_credentials") if credentials_unlocked => {} Some("provider_credentials") | Some("device_profile") => { return Err(BundleError::InvalidSchema) } diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs index 36367ce50..bdf654832 100644 --- a/openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/mod.rs @@ -1,7 +1,13 @@ +mod android_state; mod container; mod crypto; mod types; +pub(crate) use android_state::ANDROID_SETTINGS_ID; +pub use android_state::{AndroidMigrationLease, AndroidMigrationSnapshot, AndroidMigrationState}; + +pub(crate) use types::validate_portable_android_preferences; + pub use container::{decode_bundle, export_bundle, export_validated_bundle, inspect_bundle}; pub use types::{ BundleArtifact, BundleError, BundleExportOptions, BundleImportOptions, BundleInspection, diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs index eb4df7a3e..9cdda1215 100644 --- a/openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/types.rs @@ -261,6 +261,9 @@ pub struct BundleInspection { pub document_count: usize, pub tombstone_count: usize, pub section_counts: SectionCounts, + pub skipped_history: usize, + pub skipped_activity: usize, + pub skipped_tombstones: usize, pub credentials_present: bool, pub credentials_encrypted: bool, pub credentials_unlocked: bool, @@ -287,6 +290,8 @@ pub struct SectionCounts { pub struct BundleMergePreview { pub inspection: BundleInspection, pub observed_generation: crate::cloud_sync_e2ee_documents::Revision, + pub observed_android_revision: Option, + pub removed_documents: Vec, pub conflicts: Vec, } diff --git a/openless-all/app/crates/openless-core/tests/local_bundle_contract.rs b/openless-all/app/crates/openless-core/tests/local_bundle_contract.rs index 6c8bb104a..af1c566ea 100644 --- a/openless-all/app/crates/openless-core/tests/local_bundle_contract.rs +++ b/openless-all/app/crates/openless-core/tests/local_bundle_contract.rs @@ -154,3 +154,102 @@ fn container_rejects_unknown_entries() { Err(BundleError::MalformedContainer) )); } + +#[test] +fn review_default_share_does_not_contain_history() { + let mut set = fixture().documents().clone(); + set.documents.push(serde_json::from_value(json!({"id":"private-history","kind":"history","schemaVersion":1,"value":{"id":"private-history","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"PRIVATE_DICTATION_MARKER","finalText":"PRIVATE_DICTATION_MARKER","mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":0}})).unwrap()); + let docs = parse_sync_documents(&serde_json::to_vec(&set).unwrap(), Revision::new(0)).unwrap(); + let artifact = export_validated_bundle(&docs, BundleExportOptions::default()).unwrap(); + let mut archive = zip::ZipArchive::new(Cursor::new(&artifact.bytes[..])).unwrap(); + let mut raw = String::new(); + std::io::Read::read_to_string(&mut archive.by_name("documents.json").unwrap(), &mut raw) + .unwrap(); + assert!( + !raw.contains("PRIVATE_DICTATION_MARKER"), + "default redacted share contains plaintext dictation history" + ); +} + +#[test] +fn both_modes_exclude_usage_records_and_tombstones() { + let mut set = fixture().documents().clone(); + set.documents.push(serde_json::from_value(json!({"id":"history-private","kind":"history","schemaVersion":1,"value":{"id":"history-private","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"PRIVATE_HISTORY","finalText":"PRIVATE_HISTORY","mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":0}})).unwrap()); + set.documents.push(serde_json::from_value(json!({"id":"private-device:2026-09-26","kind":"activity","schemaVersion":1,"value":{"sourceDeviceId":"private-device","date":"2026-09-26","count":3,"chars":10,"durationMs":20}})).unwrap()); + for (kind, id) in [ + ("history", "deleted-history"), + ("activity", "other-device:2026-09-26"), + ] { + set.tombstones.push( + serde_json::from_value( + json!({"id":id,"kind":kind,"deletedAt":"2026-09-26T00:00:00Z","baseRevision":"0"}), + ) + .unwrap(), + ); + } + let docs = parse_sync_documents(&serde_json::to_vec(&set).unwrap(), Revision::new(0)).unwrap(); + for personal in [false, true] { + let password = personal.then(|| BundlePassword::new("test-password").unwrap()); + let artifact = export_validated_bundle( + &docs, + BundleExportOptions { + mode: if personal { + BundleMode::PersonalMigration + } else { + BundleMode::ShareRedacted + }, + credential_policy: if personal { + CredentialPolicy::PasswordEncrypted + } else { + CredentialPolicy::Omit + }, + password: password.clone(), + ..BundleExportOptions::default() + }, + ) + .unwrap(); + let decoded = decode_bundle(&artifact.bytes, password.as_ref(), Revision::new(0)).unwrap(); + assert_eq!(decoded.inspection.section_counts.history, 0); + assert_eq!(decoded.inspection.section_counts.activity, 0); + assert!(decoded.documents.documents().tombstones.is_empty()); + let mut archive = zip::ZipArchive::new(Cursor::new(&artifact.bytes[..])).unwrap(); + let mut raw = String::new(); + std::io::Read::read_to_string(&mut archive.by_name("documents.json").unwrap(), &mut raw) + .unwrap(); + assert!(!raw.contains("PRIVATE_HISTORY")); + assert!(!raw.contains("private-device")); + } +} + +#[test] +fn malformed_android_state_is_rejected_during_inspection_and_decode() { + let valid = json!({"schemaVersion":1,"learningPrivacyVersion":1,"preferences":{},"strokeFrequency":{},"pinyinLearnedPhrases":{}}); + let mut invalid = vec![json!({}), valid.clone()]; + invalid[1]["learningPrivacyVersion"] = json!(0); + for (key, value) in [ + ("hapticEnabled", json!("true")), + ("hapticAmplitude", json!(0)), + ("hapticAmplitude", json!(1.5)), + ("locale", json!(1)), + ("themeMode", json!("invalid")), + ] { + let mut value_root = valid.clone(); + value_root["preferences"][key] = value; + invalid.push(value_root); + } + for value in invalid { + let mut set = fixture().documents().clone(); + set.documents.push(serde_json::from_value(json!({"id":"androidPortableSettings","kind":"ui_preferences","schemaVersion":1,"value":value})).unwrap()); + let docs = + parse_sync_documents(&serde_json::to_vec(&set).unwrap(), Revision::new(0)).unwrap(); + let artifact = export_validated_bundle(&docs, BundleExportOptions::default()).unwrap(); + assert!(matches!( + inspect_bundle(&artifact.bytes), + Err(BundleError::InvalidSchema) + )); + assert!(matches!( + decode_bundle(&artifact.bytes, None, Revision::new(0)), + Err(BundleError::InvalidSchema) + )); + } +} diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index eeab15ffe..3b8abb873 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -60,6 +60,7 @@ const KOTLIN_FILES = [ 'OpenLessContentReader.kt', 'OpenLessContentWriter.kt', 'OpenLessMigrationBundlePolicy.kt', + 'OpenLessMigrationState.kt', 'OpenLessImeService.kt', 'PendingEditReplacement.kt', 'ImeLearningPolicy.kt', diff --git a/openless-all/app/scripts/local-bundle-contract.test.mjs b/openless-all/app/scripts/local-bundle-contract.test.mjs index 6398ba119..ed5e01571 100644 --- a/openless-all/app/scripts/local-bundle-contract.test.mjs +++ b/openless-all/app/scripts/local-bundle-contract.test.mjs @@ -34,8 +34,17 @@ assert.match(migrationActivity, /RadioButton/); assert.match(migrationActivity, /checkedRadioButtonId/); assert.doesNotMatch(migrationActivity, /setSingleChoiceItems/); assert.match(migrationActivity, /migration_bundle_stale_preview/); -assert.match(migrationActivity, /showMigrationRestoreMode\(bundleBase64, password, allowStaleRetry = false\)/); +assert.match( + migrationActivity, + /previewMigration\(bundleBase64, password, mode, allowStaleRetry = false\)/, +); assert.match(migrationActivity, /observedGeneration.*isNotBlank/); +assert.match(migrationActivity, /observedAndroidRevision.*isNotBlank/); +assert.doesNotMatch( + migrationActivity, + /nativeExtractLocalMigrationAndroidPreferences|importPortable/, +); +assert.match(scaffolding, /OpenLessMigrationState\.kt/); assert.match(migrationActivity, /documents\.json/); assert.doesNotMatch(migrationActivity, /pendingExportJson/); assert.doesNotMatch( diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 8ab3b3eed..4cc7d14b2 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -125,7 +125,7 @@ pub mod android { Ok(JClass::from(class_obj)) } - fn call_static_void_with_context_class<'local>( + pub(crate) fn call_static_void_with_context_class<'local>( env: &mut JNIEnv<'local>, context: &JObject<'local>, class_name: &str, @@ -1189,7 +1189,7 @@ pub mod android { ) } - fn call_static_string_with_context_class<'local>( + pub(crate) fn call_static_string_with_context_class<'local>( env: &mut JNIEnv<'local>, context: &JObject<'local>, class_name: &str, diff --git a/openless-all/app/src-tauri/src/android/local_migration.rs b/openless-all/app/src-tauri/src/android/local_migration.rs new file mode 100644 index 000000000..09115fca0 --- /dev/null +++ b/openless-all/app/src-tauri/src/android/local_migration.rs @@ -0,0 +1,195 @@ +use std::sync::Arc; + +use jni::objects::{GlobalRef, JObject, JString, JValue}; +use jni::{JNIEnv, JavaVM}; +use openless_core::cloud_sync_e2ee_documents::{DocumentError, DocumentResult, SecretJson}; +use openless_core::local_migration_bundle::{ + AndroidMigrationLease, AndroidMigrationSnapshot, AndroidMigrationState, +}; + +use super::jni::android::{ + call_static_string_with_context_class, call_static_void_with_context_class, jobject_str, + with_android_env, +}; + +const CLASS: &str = "com.openless.app.OpenLessMigrationState"; + +#[derive(Clone)] +pub(crate) struct AndroidMigrationHost { + vm: Arc, + context: GlobalRef, +} + +impl AndroidMigrationHost { + pub(crate) fn new(recovering: bool) -> Result { + let host = with_android_env(|env, context| { + let app = env + .call_method( + context, + "getApplicationContext", + "()Landroid/content/Context;", + &[], + ) + .and_then(|value| value.l()) + .map_err(|_| "migration_application_context_unavailable")?; + Ok(Self { + vm: Arc::new(env.get_java_vm().map_err(|_| "migration_jvm_unavailable")?), + context: env + .new_global_ref(app) + .map_err(|_| "migration_application_context_unavailable")?, + }) + })?; + host.call(|env, context| { + call_static_void_with_context_class( + env, + context, + CLASS, + "initialize", + "(Landroid/content/Context;Z)V", + &[JValue::Object(context), JValue::Bool(recovering.into())], + ) + }) + .map_err(|error| error.to_string())?; + Ok(host) + } + + fn call( + &self, + work: impl for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> Result, + ) -> DocumentResult { + let mut env = self + .vm + .attach_current_thread() + .map_err(|_| DocumentError::CaptureFailed)?; + let result = work(&mut env, self.context.as_obj()); + if env.exception_check().unwrap_or(true) { + let exception = env.exception_occurred().ok(); + let _ = env.exception_clear(); + let message = exception + .and_then(|exception| { + env.call_method(exception, "getMessage", "()Ljava/lang/String;", &[]) + .ok() + }) + .and_then(|value| value.l().ok()) + .filter(|value| !value.is_null()) + .and_then(|value| { + env.get_string(&JString::from(value)) + .ok() + .map(|message| message.to_string_lossy().into_owned()) + }); + let _ = env.exception_clear(); + return Err(match message.as_deref() { + Some("migration_bundle_stale_preview") => DocumentError::StalePreview, + Some("migration_android_busy") => DocumentError::RuntimeBusy, + _ => DocumentError::CaptureFailed, + }); + } + result.map_err(|_| DocumentError::CaptureFailed) + } +} + +impl AndroidMigrationState for AndroidMigrationHost { + fn capture(&self) -> DocumentResult { + let json = self.call(|env, context| { + call_static_string_with_context_class( + env, + context, + CLASS, + "capture", + "(Landroid/content/Context;)Ljava/lang/String;", + &[JValue::Object(context)], + ) + })?; + let value: serde_json::Value = + serde_json::from_str(&json).map_err(|_| DocumentError::InvalidDocument)?; + Ok(AndroidMigrationSnapshot { + revision: value["revision"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or(DocumentError::InvalidDocument)? + .into(), + value: SecretJson::new(value["value"].clone()), + }) + } + + fn acquire( + &self, + expected_revision: Option<&str>, + ) -> DocumentResult> { + let token = self.call(|env, context| { + let expected = jobject_str(env, expected_revision.unwrap_or_default())?; + call_static_string_with_context_class( + env, + context, + CLASS, + "acquire", + "(Ljava/lang/String;)Ljava/lang/String;", + &[JValue::Object(&expected)], + ) + })?; + Ok(Box::new(AndroidLease { + host: self.clone(), + token, + })) + } +} + +struct AndroidLease { + host: AndroidMigrationHost, + token: String, +} + +impl AndroidMigrationLease for AndroidLease { + fn capture(&self) -> DocumentResult { + let json = self.host.call(|env, context| { + let token = jobject_str(env, &self.token)?; + call_static_string_with_context_class( + env, + context, + CLASS, + "captureLocked", + "(Landroid/content/Context;Ljava/lang/String;)Ljava/lang/String;", + &[JValue::Object(context), JValue::Object(&token)], + ) + })?; + serde_json::from_str(&json) + .map(SecretJson::new) + .map_err(|_| DocumentError::InvalidDocument) + } + + fn replace(&mut self, value: &SecretJson) -> DocumentResult<()> { + self.host.call(|env, context| { + let token = jobject_str(env, &self.token)?; + let json = jobject_str(env, &value.expose().to_string())?; + call_static_void_with_context_class( + env, + context, + CLASS, + "replace", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Object(context), + JValue::Object(&token), + JValue::Object(&json), + ], + ) + }) + } + + fn finish(&mut self, recovery_required: bool) -> DocumentResult<()> { + self.host.call(|env, context| { + let token = jobject_str(env, &self.token)?; + call_static_void_with_context_class( + env, + context, + CLASS, + "finish", + "(Ljava/lang/String;Z)V", + &[ + JValue::Object(&token), + JValue::Bool(recovery_required.into()), + ], + ) + }) + } +} diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index 48c512e95..eedb949bf 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -7,6 +7,8 @@ pub mod edit_observation; pub mod insert; pub mod insert_tiers; pub mod jni; +#[cfg(target_os = "android")] +pub(crate) mod local_migration; pub mod native_bridge; pub mod overlay; pub mod shizuku; diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 55fe84e55..014a4444d 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -196,7 +196,6 @@ fn export_local_migration_bundle_json( mode: &str, include_credentials: bool, password: &str, - android_preferences_json: &str, ) -> String { let result = (|| -> Result { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; @@ -225,14 +224,7 @@ fn export_local_migration_bundle_json( credential_policy, password, app_version: env!("CARGO_PKG_VERSION").into(), - portable_android_preferences: if android_preferences_json.is_empty() { - None - } else { - Some( - serde_json::from_str(android_preferences_json) - .map_err(|_| "invalid Android portable preferences")?, - ) - }, + portable_android_preferences: None, }; let artifact = tauri::async_runtime::block_on(backend.export_local_migration_bundle(options)) @@ -265,53 +257,7 @@ fn inspect_local_migration_bundle_json(bundle_base64: &str) -> String { } } -fn extract_local_migration_android_preferences_json( - bundle_base64: &str, - password: &str, -) -> String { - let result = (|| -> Result { - let bytes = base64::engine::general_purpose::STANDARD - .decode(bundle_base64) - .map_err(|_| "invalid bundle encoding")?; - let password = if password.is_empty() { - None - } else { - Some( - openless_core::local_migration_bundle::BundlePassword::new(password) - .map_err(|error| error.to_string())?, - ) - }; - let decoded = openless_core::local_migration_bundle::decode_bundle( - &bytes, - password.as_ref(), - openless_core::cloud_sync_e2ee_documents::Revision::new(0), - ) - .map_err(|error| error.to_string())?; - let preferences = decoded - .documents - .documents() - .documents - .iter() - .find(|document| { - document.kind - == openless_core::cloud_sync_e2ee_documents::DocumentKind::UiPreferences - && document.id == "androidPortableSettings" - }) - .map(|document| document.value.clone()) - .unwrap_or(serde_json::Value::Null); - Ok(serde_json::json!({"preferences": preferences})) - })(); - match result { - Ok(payload) => serde_json::json!({"ok": true, "payload": payload}).to_string(), - Err(error) => local_bundle_error(error), - } -} - -fn preview_local_migration_bundle_json( - bundle_base64: &str, - password: &str, - android_preferences_json: &str, -) -> String { +fn preview_local_migration_bundle_json(bundle_base64: &str, password: &str, mode: &str) -> String { let result = (|| -> Result { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; let bytes = base64::engine::general_purpose::STANDARD @@ -325,16 +271,13 @@ fn preview_local_migration_bundle_json( .map_err(|error| error.to_string())?, ) }; - let android_preferences = if android_preferences_json.is_empty() { - None - } else { - Some( - serde_json::from_str(android_preferences_json) - .map_err(|_| "invalid Android portable preferences")?, - ) + let mode = match mode { + "merge" => openless_core::local_migration_bundle::RestoreMode::Merge, + "replace" => openless_core::local_migration_bundle::RestoreMode::Replace, + _ => return Err("invalid migration restore mode".into()), }; let preview = tauri::async_runtime::block_on( - backend.preview_local_migration_bundle(&bytes, password, android_preferences), + backend.preview_local_migration_bundle(&bytes, password, mode), ) .map_err(|error| error.message)?; serde_json::to_value(preview).map_err(|_| "invalid bundle preview".into()) @@ -351,7 +294,7 @@ fn import_local_migration_bundle_json( mode: &str, choices_json: &str, expected_generation: &str, - android_preferences_json: &str, + android_revision: &str, ) -> String { let result = (|| -> Result { let backend = CORE_BACKEND.get().ok_or("backend unavailable")?; @@ -386,21 +329,15 @@ fn import_local_migration_bundle_json( value, )) }; - let android_preferences = if android_preferences_json.is_empty() { - None - } else { - Some( - serde_json::from_str(android_preferences_json) - .map_err(|_| "invalid Android portable preferences")?, - ) - }; + let expected_android_revision = + (!android_revision.is_empty()).then(|| android_revision.to_owned()); let inspection = tauri::async_runtime::block_on(backend.import_local_migration_bundle( &bytes, password, restore_mode, &choices, expected_generation, - android_preferences, + expected_android_revision, )) .map_err(|error| error.message)?; backend.publish_local_migration_state_changed(); @@ -1650,7 +1587,6 @@ mod jni_exports { mode: jstring, include_credentials: jboolean, password: jstring, - android_preferences: jstring, ) -> jstring { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); @@ -1661,16 +1597,10 @@ mod jni_exports { let password = env .get_string(&JString::from_raw(password)) .map(|value| value.to_string_lossy().into_owned()); - let android_preferences = env - .get_string(&JString::from_raw(android_preferences)) - .map(|value| value.to_string_lossy().into_owned()); - let response = match (mode, password, android_preferences) { - (Ok(mode), Ok(password), Ok(android_preferences)) => export_local_migration_bundle_json( - &mode, - include_credentials != 0, - &password, - &android_preferences, - ), + let response = match (mode, password) { + (Ok(mode), Ok(password)) => { + export_local_migration_bundle_json(&mode, include_credentials != 0, &password) + } _ => local_bundle_error("invalid JNI input"), }; crate::android::jni::android::export_jstring(&mut env, &response) @@ -1682,7 +1612,7 @@ mod jni_exports { _class: JClass, bundle_base64: jstring, password: jstring, - android_preferences: jstring, + mode: jstring, ) -> jstring { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); @@ -1693,15 +1623,13 @@ mod jni_exports { let password = env .get_string(&JString::from_raw(password)) .map(|value| value.to_string_lossy().into_owned()); - let android_preferences = env - .get_string(&JString::from_raw(android_preferences)) + let mode = env + .get_string(&JString::from_raw(mode)) .map(|value| value.to_string_lossy().into_owned()); - let response = match (bundle_base64, password, android_preferences) { - (Ok(bundle), Ok(password), Ok(android_preferences)) => preview_local_migration_bundle_json( - &bundle, - &password, - &android_preferences, - ), + let response = match (bundle_base64, password, mode) { + (Ok(bundle), Ok(password), Ok(mode)) => { + preview_local_migration_bundle_json(&bundle, &password, &mode) + } _ => local_bundle_error("invalid JNI input"), }; crate::android::jni::android::export_jstring(&mut env, &response) @@ -1726,32 +1654,6 @@ mod jni_exports { crate::android::jni::android::export_jstring(&mut env, &response) } - #[no_mangle] - pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeExtractLocalMigrationAndroidPreferences( - env: *mut JNIEnv, - _class: JClass, - bundle_base64: jstring, - password: jstring, - ) -> jstring { - let Ok(mut env) = JniEnv::from_raw(env) else { - return std::ptr::null_mut(); - }; - let bundle_base64 = env - .get_string(&JString::from_raw(bundle_base64)) - .map(|value| value.to_string_lossy().into_owned()); - let password = env - .get_string(&JString::from_raw(password)) - .map(|value| value.to_string_lossy().into_owned()); - let response = match (bundle_base64, password) { - (Ok(bundle), Ok(password)) => extract_local_migration_android_preferences_json( - &bundle, - &password, - ), - _ => local_bundle_error("invalid JNI input"), - }; - crate::android::jni::android::export_jstring(&mut env, &response) - } - #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeImportLocalMigrationBundle( env: *mut JNIEnv, @@ -1761,7 +1663,7 @@ mod jni_exports { mode: jstring, choices_json: jstring, expected_generation: jstring, - android_preferences: jstring, + android_revision: jstring, ) -> jstring { let Ok(mut env) = JniEnv::from_raw(env) else { return std::ptr::null_mut(); @@ -1781,8 +1683,8 @@ mod jni_exports { let expected_generation = env .get_string(&JString::from_raw(expected_generation)) .map(|value| value.to_string_lossy().into_owned()); - let android_preferences = env - .get_string(&JString::from_raw(android_preferences)) + let android_revision = env + .get_string(&JString::from_raw(android_revision)) .map(|value| value.to_string_lossy().into_owned()); let response = match ( bundle_base64, @@ -1790,18 +1692,23 @@ mod jni_exports { mode, choices_json, expected_generation, - android_preferences, + android_revision, ) { - (Ok(bundle), Ok(password), Ok(mode), Ok(choices), Ok(expected_generation), Ok(android_preferences)) => { - import_local_migration_bundle_json( - &bundle, - &password, - &mode, - &choices, - &expected_generation, - &android_preferences, - ) - } + ( + Ok(bundle), + Ok(password), + Ok(mode), + Ok(choices), + Ok(expected_generation), + Ok(android_revision), + ) => import_local_migration_bundle_json( + &bundle, + &password, + &mode, + &choices, + &expected_generation, + &android_revision, + ), _ => local_bundle_error("invalid JNI input"), }; crate::android::jni::android::export_jstring(&mut env, &response) diff --git a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs index 04767889e..76adf256f 100644 --- a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs +++ b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs @@ -134,6 +134,16 @@ impl Coordinator { if !self.inner.host.is_bound() { return Err(failure("restore_host_not_bound")); } + #[cfg(target_os = "android")] + { + let host = crate::android::local_migration::AndroidMigrationHost::new( + self.inner.backend.ensure_runtime_ready().is_err(), + ) + .map_err(|_| failure("restore_android_state_unavailable"))?; + self.inner + .backend + .bind_android_migration_state(Arc::new(host))?; + } self.inner .backend .bind_restore_runtime_effects(Arc::new(RestoreHost {