@adep/cli 0.4.1 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -11
- package/dist/index.js +34568 -27795
- package/dist/vite.js +279 -19
- package/dist/worker-entry.js +201 -2
- package/package.json +6 -5
- package/templates/app-desktop/README.md +67 -2
- package/templates/app-desktop/identity-store.mjs +309 -0
- package/templates/app-desktop/identity.mjs +517 -0
- package/templates/app-desktop/main.cjs +17 -0
- package/templates/app-desktop/main.mjs +218 -2
- package/templates/app-desktop/offline-runtime.mjs +72 -3
- package/templates/app-desktop/package.json +10 -6
- package/templates/app-desktop/recovery-ui.mjs +300 -0
- package/templates/app-desktop/sync-loop.mjs +137 -0
- package/templates/app-mobile/README.md +47 -9
- package/templates/app-mobile/package.json +2 -1
- package/templates/app-mobile/plugins/adep-bridge/Package.swift +52 -0
- package/templates/app-mobile/plugins/adep-bridge/android/build.gradle +34 -3
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/AndroidManifest.xml +12 -5
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt +198 -12
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepIdentity.kt +342 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepKeystore.kt +56 -2
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqlite.kt +506 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqliteLogic.kt +235 -0
- package/templates/app-mobile/plugins/adep-bridge/dist/plugin.js +30 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/AdepBridge.podspec +7 -1
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepBridge.swift +221 -9
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepIdentity.swift +500 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepKeychain.swift +69 -17
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepPermissionGate.swift +25 -4
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepShellConfig.swift +54 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepSqlite.swift +614 -0
- package/templates/app-mobile/plugins/adep-bridge/package.json +10 -0
- package/templates/app-mobile/template.json +12 -3
- package/templates/app-mobile/web/adep-bridge.js +103 -9
- package/templates/app-mobile/web/offline-runtime.js +230 -0
- package/templates/app-mobile-xtool/AdepShell-Info.plist +24 -0
- package/templates/app-mobile-xtool/Package.swift +59 -0
- package/templates/app-mobile-xtool/README.md +81 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/App.swift +36 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/ShellViewController.swift +223 -0
- package/templates/app-mobile-xtool/adep.permissions.json +4 -0
- package/templates/app-mobile-xtool/app/site/.gitkeep +0 -0
- package/templates/app-mobile-xtool/template.json +15 -0
- package/templates/app-mobile-xtool/xtool.yml +35 -0
- package/templates/app-mobile/plugins/adep-bridge/capacitor.plugin.json +0 -10
package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqlite.kt
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
package com.adep.bridge
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.database.Cursor
|
|
5
|
+
import android.util.Base64
|
|
6
|
+
import java.io.File
|
|
7
|
+
import java.io.RandomAccessFile
|
|
8
|
+
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
|
9
|
+
import org.json.JSONArray
|
|
10
|
+
import org.json.JSONObject
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 设备本地 SQLite 桥的**执行层**(Android,APP-023;APP-026 换 SQLCipher)。
|
|
14
|
+
*
|
|
15
|
+
* 消费 {@link AdepSqliteLogic} 的所有决策(错误码 / 参数分流 / 多语句拒绝 / 占位符计数 /
|
|
16
|
+
* 类型映射 / 成功信封);本文件只负责把决策落到 SQLCipher 真执行。
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么是 SQLCipher(APP-026,回补 APP-024 forbidden 的显式偏离)
|
|
19
|
+
*
|
|
20
|
+
* APP-024 首版用 framework `android.database.sqlite`(明文先通链路,与 iOS APP-022
|
|
21
|
+
* 同口径);APP-025 把 iOS 侧接上 SQLCipher 后,「落盘即密文」在 Android 侧仍是明文
|
|
22
|
+
* ——同设备语义两平台分叉。本单换 **net.zetetic:sqlcipher-android 4.6.1**
|
|
23
|
+
* (`net.zetetic.database.sqlcipher.*`,API 与 framework 同构,替换成本在 import 与
|
|
24
|
+
* open 一处),run/all/get 契约零改动。
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **`System.loadLibrary("sqlcipher")` 必须显式调用且先于任何 open**:AAR 的
|
|
27
|
+
* classes.jar 里没有任何类负责加载 so(javap + 常量池全量扫描 0 命中),上游 README
|
|
28
|
+
* 明文「the developer must load this library explicitly」。漏写 = 首次 open 报
|
|
29
|
+
* `UnsatisfiedLinkError`(只在 Android runtime 上爆,本机 JVM 门测不到——由静态契约
|
|
30
|
+
* 门钉「loadLibrary 在任何 open 之前」的顺序)。`companion object` 的懒加载守护保证
|
|
31
|
+
* 进程内只 load 一次(重复 load 幂等,但守护让语义显式)。
|
|
32
|
+
*
|
|
33
|
+
* ## keying 语义(与 iOS APP-025 逐条对齐)
|
|
34
|
+
*
|
|
35
|
+
* - **key 由 `open(context, keyProvider)` 注入**:生产装配传
|
|
36
|
+
* `{ AdepKeystore.loadOwnerKey(context) }`(ownerKey 即库 key);返回 null 时用
|
|
37
|
+
* 设备固定缺省(首启未签发也要加密落盘,不能明文裸奔)。
|
|
38
|
+
* - **ownerKey 迟到 ⇒ key 升级(APP-027)**:签发是首启异步完成的,库可能先用
|
|
39
|
+
* FALLBACK_KEY 建好(离线首启就是这形态)。ownerKey 落 Keystore 后由 {@link adoptKey}
|
|
40
|
+
* 原地 `PRAGMA rekey`;重启时若换代发生在退出前,{@link ensureOpenHelper} 会探测到
|
|
41
|
+
* 「当前 key 打不开但缺省 key 打得开」并自动升级。不处理就是「换 key 后老库打不开」。
|
|
42
|
+
* - **passphrase 走 `openOrCreateDatabase(path, passphrase, factory, errorHandler)`**:
|
|
43
|
+
* SQLCipher 连接层在 open 时做 keying(等价 iOS PRAGMA key;javap 实证该重载存在)。
|
|
44
|
+
* - **明文库迁移**:APP-024 时代建的库是明文(头 = `SQLite format 3\0`)。open 时
|
|
45
|
+
* 检测到明文头,先以**无 key 形态**打开旧库,`ATTACH` 带真实 key 的临时文件 +
|
|
46
|
+
* `sqlcipher_export` 转密 + 表数校验 + 原子替换(迁移方向语义与 iOS 探针实证一致:
|
|
47
|
+
* export 产出密文形态由 **目标库的 key** 决定——ATTACH KEY '' 产出明文假迁移)。
|
|
48
|
+
* - **错 key fail loud 钉在 open 阶段**:passphrase 错不报错(SQLCipher 惰性验证),
|
|
49
|
+
* 错 key 要到首次读表才爆(`file is not a database`)⇒ keying 后立刻跑探活语句,
|
|
50
|
+
* 报错点钉在打开时(错误码 `SQLITE_OPEN_FAILED`),不漂移到首次业务查询。
|
|
51
|
+
*
|
|
52
|
+
* ## 与 iOS `AdepSqlite.swift` 的语义对齐(2026-10-22 真机七轮排障沉淀,逐条移植)
|
|
53
|
+
*
|
|
54
|
+
* - **长持单连接**(不现开现关):`runOwnedWrite` 事务协议(SAVEPOINT → 业务 → RELEASE/ROLLBACK)
|
|
55
|
+
* 是**三次独立桥调用**,SAVEPOINT 是连接级状态——现开现关会让第三条语句连接已换 ⇒
|
|
56
|
+
* 「no such savepoint」。SQLCipher 版 {@link SQLiteOpenHelper} 同样长持
|
|
57
|
+
* (getWritableDatabase 返回同一实例)。
|
|
58
|
+
* - **串行化**:SQLite 句柄非线程安全,桥调用来自不同线程 ⇒ 单线程
|
|
59
|
+
* {@link java.util.concurrent.Executors#newSingleThreadExecutor} 串行执行
|
|
60
|
+
* (同队列也让「写后读」时序天然成立)。
|
|
61
|
+
* - **多语句拒绝**:iOS 因 `prepare_v2` 只编第一条会**静默丢尾随**;Android 的
|
|
62
|
+
* `execSQL` 可能**多执行**——两边行为必须对齐为「拒绝多语句」(`countSqlStatements != 1`
|
|
63
|
+
* fail loud)。
|
|
64
|
+
* - **占位符计数**:绑定值数必须**恰好**等于占位符数(多了静默忽略 / 少了按 NULL——两侧 fail)。
|
|
65
|
+
*/
|
|
66
|
+
class AdepSqlite private constructor(
|
|
67
|
+
private val context: Context,
|
|
68
|
+
private val keyProvider: (() -> String?)?,
|
|
69
|
+
) {
|
|
70
|
+
/** 串行执行器:句柄非线程安全,同队列也让「写后读」时序天然成立(等价 iOS DispatchQueue)。 */
|
|
71
|
+
private val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
|
72
|
+
private var open = true
|
|
73
|
+
|
|
74
|
+
companion object {
|
|
75
|
+
/** 库文件名(与 iOS `app.sqlite` 同语义;Android 落 `databases/app.sqlite`)。 */
|
|
76
|
+
const val DATABASE_NAME = "app.sqlite"
|
|
77
|
+
const val DATABASE_VERSION = 1
|
|
78
|
+
|
|
79
|
+
/** keyProvider 返回 nil(首启未签发)时的设备固定缺省(与 iOS fallbackKey 同值)。 */
|
|
80
|
+
private const val FALLBACK_KEY = "adep-device-default-key-v1"
|
|
81
|
+
|
|
82
|
+
/** SQLCipher 明文库 magic 头(16 字节,含结尾 \0)。文件头与之相等 = 明文库。 */
|
|
83
|
+
private val PLAIN_HEADER_MAGIC = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII)
|
|
84
|
+
|
|
85
|
+
/** SQLCipher native 库加载守护:进程内恰好一次(explicit,上游 README 纪律)。 */
|
|
86
|
+
@Volatile
|
|
87
|
+
private var nativeLoaded = false
|
|
88
|
+
|
|
89
|
+
private fun ensureNativeLoaded() {
|
|
90
|
+
if (!nativeLoaded) {
|
|
91
|
+
synchronized(this) {
|
|
92
|
+
if (!nativeLoaded) {
|
|
93
|
+
System.loadLibrary("sqlcipher")
|
|
94
|
+
nativeLoaded = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 工厂:`context.getDatabasePath(DATABASE_NAME)`(Android 标准 databases 目录)。
|
|
102
|
+
* `keyProvider` 在每次打开时被调用(串行队列 ⇒ 无并发读)——返回 null 用缺省 key。
|
|
103
|
+
* 生产装配:`AdepSqlite.open(context) { AdepKeystore.loadOwnerKey(context) }`。
|
|
104
|
+
*/
|
|
105
|
+
fun open(context: Context, keyProvider: (() -> String?)? = null): AdepSqlite {
|
|
106
|
+
ensureNativeLoaded()
|
|
107
|
+
return AdepSqlite(context.applicationContext, keyProvider)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 生效 key:provider 优先,null 时走缺省。 */
|
|
111
|
+
private fun effectiveKey(keyProvider: (() -> String?)?): String =
|
|
112
|
+
keyProvider?.invoke() ?: FALLBACK_KEY
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 执行写语句(DDL / DML),返回受影响行数。串行 + 多语句拒绝 + 占位符校验。 */
|
|
116
|
+
fun run(sql: String, params: List<Any>): Int {
|
|
117
|
+
return execute { db ->
|
|
118
|
+
rejectIfMultiple(sql)
|
|
119
|
+
if (params.isEmpty()) {
|
|
120
|
+
db.execSQL(sql)
|
|
121
|
+
} else {
|
|
122
|
+
rejectIfPlaceholderMismatch(sql, params)
|
|
123
|
+
db.execSQL(sql, bindArray(params))
|
|
124
|
+
}
|
|
125
|
+
// 受影响行数:sqlcipher-android 的 SQLiteDatabase 没有 framework 的
|
|
126
|
+
// changedRowCount;语义等价物是 compileStatement.executeUpdateDelete()
|
|
127
|
+
// (session.executeForChangedRowCount 的公开面)——重编一次代价可接受
|
|
128
|
+
// (写路径非热点),换来与 iOS sqlite3_changes 同语义的返回值。
|
|
129
|
+
val count = db.compileStatement("SELECT changes()").simpleQueryForLong()
|
|
130
|
+
count.toInt()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 执行查询,返回对象行数组([{列名:值}])。 */
|
|
135
|
+
fun all(sql: String, params: List<Any>): JSONArray {
|
|
136
|
+
return execute { db ->
|
|
137
|
+
rejectIfMultiple(sql)
|
|
138
|
+
if (params.isEmpty()) {
|
|
139
|
+
queryRows(db.rawQuery(sql, null))
|
|
140
|
+
} else {
|
|
141
|
+
rejectIfPlaceholderMismatch(sql, params)
|
|
142
|
+
queryRows(db.rawQuery(sql, bindArray(params)))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 执行单行查询,无行返回 `JSONObject.NULL`(JS 侧 null)。 */
|
|
148
|
+
fun get(sql: String, params: List<Any>): Any {
|
|
149
|
+
val rows = all(sql, params)
|
|
150
|
+
if (rows.length() == 0) return JSONObject.NULL
|
|
151
|
+
return rows.getJSONObject(0)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 关闭连接(幂等;插件销毁时调用,等价 iOS `close()`)。 */
|
|
155
|
+
fun close() {
|
|
156
|
+
synchronized(this) {
|
|
157
|
+
if (!open) return
|
|
158
|
+
open = false
|
|
159
|
+
}
|
|
160
|
+
executor.execute {
|
|
161
|
+
try {
|
|
162
|
+
openHelper?.close()
|
|
163
|
+
} catch (_: Exception) {
|
|
164
|
+
// 关闭已断连接不抛(幂等收口)。
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// MARK: - 打开 / keying / 明文迁移(APP-026)
|
|
170
|
+
|
|
171
|
+
/** 长持 helper(懒建;getWritableDatabase 返回同一实例 ⇒ SAVEPOINT 跨调用存活)。 */
|
|
172
|
+
private var openHelper: net.zetetic.database.sqlcipher.SQLiteOpenHelper? = null
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 本次打开**实际使用**的 passphrase(仅在成功打开后非 null)。
|
|
176
|
+
*
|
|
177
|
+
* 为什么要记住:ownerKey 是首启签发、**异步到达**的(APP-027)——连接可能先用
|
|
178
|
+
* [FALLBACK_KEY] 建好,ownerKey 随后才落 Keystore。这时必须知道「手上这把连接用的是
|
|
179
|
+
* 哪把 key」才能决定要不要 rekey([adoptKey])。
|
|
180
|
+
*/
|
|
181
|
+
private var currentKey: String? = null
|
|
182
|
+
|
|
183
|
+
private fun databaseFile(): File = context.getDatabasePath(DATABASE_NAME)
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 打开 helper(懒建一次、跨调用复用)。APP-026 keying 顺序(与 iOS 逐条对齐,顺序不能动):
|
|
187
|
+
* 1. **明文库检测与迁移**(keying 之前——旧明文库必须以无 key 形态打开才能读);
|
|
188
|
+
* 2. 建 SQLCipher 版 {@link SQLiteOpenHelper}(passphrase 由 keyProvider 决定);
|
|
189
|
+
* 3. **探活语句**:getWritableDatabase 后立刻 `SELECT count(*) FROM sqlite_master`,
|
|
190
|
+
* 把错 key 的报错钉在打开时(惰性验证,见类注释)。
|
|
191
|
+
*
|
|
192
|
+
* APP-027 增量:探活失败且「当前 key 不是缺省 key」⇒ 说明这是「此前用 [FALLBACK_KEY]
|
|
193
|
+
* 建的老库,而 ownerKey 刚签发」⇒ 走 [upgradeFallbackKeyedDatabase] 原地 rekey。
|
|
194
|
+
* 不处理的话症状是「换到正式 key 后老库打不开」——用户看到的是数据全丢。
|
|
195
|
+
*
|
|
196
|
+
* ⚠️ 探活**失败**(而非抛错)只可能是「库不是用这把 key 建的」:helper 新建的空库对任何
|
|
197
|
+
* passphrase 都能探活通过,所以「文件是本次才建的」与「走到了升级分支」互斥——初版在这里
|
|
198
|
+
* 还判过 `file.exists() && length > 0`,变异自证时发现它恒为 true(不可达死码)已删;
|
|
199
|
+
* 保留会让读者误以为存在「新库被误判成老库」的路径。
|
|
200
|
+
*/
|
|
201
|
+
private fun ensureOpenHelper(): net.zetetic.database.sqlcipher.SQLiteOpenHelper {
|
|
202
|
+
openHelper?.let { return it }
|
|
203
|
+
val file = databaseFile()
|
|
204
|
+
file.parentFile?.mkdirs()
|
|
205
|
+
migratePlainDatabaseIfNeeded(file)
|
|
206
|
+
val desired = effectiveKey(keyProvider)
|
|
207
|
+
val helper = try {
|
|
208
|
+
buildHelper(file, desired)
|
|
209
|
+
} catch (firstError: Exception) {
|
|
210
|
+
// `desired == FALLBACK_KEY` 短路:同一把 key 再试一次毫无意义,此时唯一成因就是
|
|
211
|
+
// 库已损坏,直接报「库坏了」比报「升级失败」好排障。
|
|
212
|
+
if (desired == FALLBACK_KEY) throw firstError
|
|
213
|
+
upgradeFallbackKeyedDatabase(file, desired, firstError)
|
|
214
|
+
}
|
|
215
|
+
openHelper = helper
|
|
216
|
+
currentKey = desired
|
|
217
|
+
return helper
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 建一个带指定 passphrase 的 helper 并探活;探活失败抛(调用方决定升级还是失败)。 */
|
|
221
|
+
private fun buildHelper(file: File, passphrase: String): net.zetetic.database.sqlcipher.SQLiteOpenHelper {
|
|
222
|
+
val helper = object : net.zetetic.database.sqlcipher.SQLiteOpenHelper(
|
|
223
|
+
context,
|
|
224
|
+
file.absolutePath,
|
|
225
|
+
passphrase,
|
|
226
|
+
null,
|
|
227
|
+
DATABASE_VERSION,
|
|
228
|
+
DATABASE_VERSION,
|
|
229
|
+
null,
|
|
230
|
+
null,
|
|
231
|
+
true,
|
|
232
|
+
) {
|
|
233
|
+
override fun onCreate(db: SQLiteDatabase) {
|
|
234
|
+
// 库文件由 helper 幂等创建;schema/baseline 由页面装配器在启动时经
|
|
235
|
+
// sqliteRun 应用(与 iOS 同语义——壳不预建业务表)。
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
|
239
|
+
// 版本迭代策略:业务表归页面 schema 管理;此处无迁移(首版 v1)。
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
// 探活:错 key 到这里才报(passphrase 惰性验证)——主动读一把,
|
|
244
|
+
// 让 SQLITE_OPEN_FAILED 出现在打开阶段而不是首次业务查询。
|
|
245
|
+
probe(helper)
|
|
246
|
+
} catch (error: Exception) {
|
|
247
|
+
try {
|
|
248
|
+
helper.close()
|
|
249
|
+
} catch (_: Exception) {
|
|
250
|
+
// 打不开的 helper 关不掉不致命(下次 ensureOpenHelper 重建)。
|
|
251
|
+
}
|
|
252
|
+
throw error
|
|
253
|
+
}
|
|
254
|
+
return helper
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 探活语句(错 key / 库损坏在这里暴露)。 */
|
|
258
|
+
private fun probe(helper: net.zetetic.database.sqlcipher.SQLiteOpenHelper) {
|
|
259
|
+
helper.writableDatabase.rawQuery("SELECT count(*) FROM sqlite_master", null).use { cursor ->
|
|
260
|
+
if (!cursor.moveToFirst()) {
|
|
261
|
+
throw SqliteBridgeError(
|
|
262
|
+
SqliteCode.OPEN_FAILED,
|
|
263
|
+
"SQLITE_OPEN_FAILED: key 校验失败(探活语句无行返回)——密钥不匹配或库已损坏",
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 老库 key 升级:`FALLBACK_KEY` → 正式 key(ownerKey)原地 rekey(APP-027)。
|
|
271
|
+
*
|
|
272
|
+
* 只能在「旧 key 已知」的方向做(SQLCipher 4.19 拒绝 `key='' + rekey`,iOS APP-025 探针实证),
|
|
273
|
+
* 而设备固定缺省 key 是常量、永远可知 ⇒ 这条路对存量设备是通的。
|
|
274
|
+
*
|
|
275
|
+
* 顺序不能省:用**旧 key** 打开 → `PRAGMA rekey` → 探活 → 关 → 用**新 key** 重建。
|
|
276
|
+
* 任何一步失败都关连接抛出(保持原库不动,下次启动重试;绝不「打不开就当新库建」)。
|
|
277
|
+
*/
|
|
278
|
+
private fun upgradeFallbackKeyedDatabase(
|
|
279
|
+
file: File,
|
|
280
|
+
desired: String,
|
|
281
|
+
firstError: Exception,
|
|
282
|
+
): net.zetetic.database.sqlcipher.SQLiteOpenHelper {
|
|
283
|
+
val legacy = try {
|
|
284
|
+
buildHelper(file, FALLBACK_KEY)
|
|
285
|
+
} catch (error: Exception) {
|
|
286
|
+
throw SqliteBridgeError(
|
|
287
|
+
SqliteCode.OPEN_FAILED,
|
|
288
|
+
"SQLITE_OPEN_FAILED: 库既不能用当前 key 打开、也不能用设备缺省 key 打开" +
|
|
289
|
+
"(${firstError.message})——密钥不匹配或库已损坏;不会按新库重建(那等于静默丢数据)",
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
legacy.writableDatabase.execSQL("PRAGMA rekey = '${escapeKey(desired)}';")
|
|
294
|
+
probe(legacy)
|
|
295
|
+
} catch (error: Exception) {
|
|
296
|
+
try {
|
|
297
|
+
legacy.close()
|
|
298
|
+
} catch (_: Exception) {
|
|
299
|
+
// 同上:关不掉不致命。
|
|
300
|
+
}
|
|
301
|
+
throw SqliteBridgeError(
|
|
302
|
+
SqliteCode.OPEN_FAILED,
|
|
303
|
+
"SQLITE_OPEN_FAILED: 库 key 升级(fallback → ownerKey)失败:${error.message}(原库仍以缺省 key 可读)",
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
legacy.close()
|
|
308
|
+
} catch (_: Exception) {
|
|
309
|
+
// 收口失败不致命:文件已 rekey,新 helper 打开时会重新取句柄。
|
|
310
|
+
}
|
|
311
|
+
return buildHelper(file, desired)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 库 key 换成正式 key(APP-027 自举拿到 ownerKey 后由插件层调用)。
|
|
316
|
+
*
|
|
317
|
+
* 三种情形都要正确:
|
|
318
|
+
* 1. helper **还没建**(最常见)⇒ 什么都不做——之后 [ensureOpenHelper] 的
|
|
319
|
+
* `effectiveKey(keyProvider)` 已能读到 ownerKey;
|
|
320
|
+
* 2. 已建且就是这把 key ⇒ 什么都不做(幂等);
|
|
321
|
+
* 3. 已建、用的是 [FALLBACK_KEY] ⇒ `PRAGMA rekey` 原地换 key(数据行原样保留)。
|
|
322
|
+
*
|
|
323
|
+
* ⚠️ 失败**不改变可用性**:库仍以旧 key 可读(只是还没升级),下次启动
|
|
324
|
+
* [ensureOpenHelper] 会走升级路径重试,所以这里只记日志不抛(调用方是后台自举线程)。
|
|
325
|
+
*/
|
|
326
|
+
fun adoptKey(key: String) {
|
|
327
|
+
if (key.isEmpty()) return
|
|
328
|
+
val job = executor.submit {
|
|
329
|
+
val helper = openHelper ?: return@submit
|
|
330
|
+
if (currentKey == key) return@submit
|
|
331
|
+
try {
|
|
332
|
+
helper.writableDatabase.execSQL("PRAGMA rekey = '${escapeKey(key)}';")
|
|
333
|
+
probe(helper)
|
|
334
|
+
currentKey = key
|
|
335
|
+
} catch (error: Exception) {
|
|
336
|
+
android.util.Log.e("adep-sqlite", "库 key 升级(adoptKey)失败,库仍以旧 key 可读", error)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
job.get()
|
|
341
|
+
} catch (error: Exception) {
|
|
342
|
+
android.util.Log.e("adep-sqlite", "adoptKey 调度失败", error)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** SQL 字面量里的单引号转义(key 来自 Keystore / 配置,可能是任意字符串)。 */
|
|
347
|
+
private fun escapeKey(value: String): String = value.replace("'", "''")
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 明文库 → 密文库原地迁移(APP-026)。仅在库文件存在且头是明文 magic 时执行。
|
|
351
|
+
*
|
|
352
|
+
* 路径(与 iOS APP-025 探针实证的语义一致):以**无 key 形态**打开旧明文库 →
|
|
353
|
+
* `ATTACH` 临时文件**带真实 key** → `sqlcipher_export(migrateTarget ← main)` 产出
|
|
354
|
+
* 密文临时库(密文形态由 ATTACH 的 KEY 决定——KEY '' 产出明文假迁移)→ 校验
|
|
355
|
+
* 表数一致 → 关连接 → 原子替换。旧明文库不留盘(替换即消失);任何一步失败都
|
|
356
|
+
* 保持原明文库不动(可重试)。
|
|
357
|
+
*/
|
|
358
|
+
private fun migratePlainDatabaseIfNeeded(file: File) {
|
|
359
|
+
if (!file.exists() || !file.length().let { it > 0 }) return
|
|
360
|
+
val header = readFileHeader(file, PLAIN_HEADER_MAGIC.size) ?: return
|
|
361
|
+
if (!header.contentEquals(PLAIN_HEADER_MAGIC)) return
|
|
362
|
+
val key = effectiveKey(keyProvider)
|
|
363
|
+
val tempPath = file.absolutePath + ".migrating"
|
|
364
|
+
val tempFile = File(tempPath)
|
|
365
|
+
// 幂等清理:上次迁移中断留下的半成品直接删(export 从原明文库重跑)。
|
|
366
|
+
tempFile.delete()
|
|
367
|
+
var plainDb: SQLiteDatabase? = null
|
|
368
|
+
try {
|
|
369
|
+
// 无 key 形态打开明文库(String 重载与 ByteArray 重载在 null factory 下有
|
|
370
|
+
// 歧义 ⇒ 显式转型 factory 参数消歧)。
|
|
371
|
+
plainDb = SQLiteDatabase.openDatabase(
|
|
372
|
+
file.absolutePath,
|
|
373
|
+
null as SQLiteDatabase.CursorFactory?,
|
|
374
|
+
SQLiteDatabase.OPEN_READWRITE,
|
|
375
|
+
)
|
|
376
|
+
val opened = plainDb!!
|
|
377
|
+
val before = scalar(opened, "SELECT count(*) FROM sqlite_master") ?: -1
|
|
378
|
+
val escapedTemp = tempPath.replace("'", "''")
|
|
379
|
+
val escapedKey = key.replace("'", "''")
|
|
380
|
+
opened.execSQL("ATTACH DATABASE '$escapedTemp' AS migrateTarget KEY '$escapedKey';")
|
|
381
|
+
try {
|
|
382
|
+
opened.execSQL("SELECT sqlcipher_export('migrateTarget', 'main');")
|
|
383
|
+
val after = opened
|
|
384
|
+
.rawQuery("SELECT count(*) FROM migrateTarget.sqlite_master", null)
|
|
385
|
+
.use { c -> if (c.moveToFirst()) c.getLong(0) else -2L }
|
|
386
|
+
if (after != before || before < 0) {
|
|
387
|
+
throw SqliteBridgeError(
|
|
388
|
+
SqliteCode.OPEN_FAILED,
|
|
389
|
+
"SQLITE_OPEN_FAILED: 明文库迁移校验失败:源 sqlite_master $before 条 vs 目标 $after 条",
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
opened.execSQL("DETACH DATABASE migrateTarget;")
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
plainDb?.close()
|
|
397
|
+
}
|
|
398
|
+
// 关掉对明文库的连接后替换文件(长持连接必须重开指向新文件;helper 在上层重建)。
|
|
399
|
+
val backupPath = file.absolutePath + ".plain-backup"
|
|
400
|
+
val backupFile = File(backupPath)
|
|
401
|
+
backupFile.delete()
|
|
402
|
+
if (!file.renameTo(backupFile)) {
|
|
403
|
+
throw SqliteBridgeError(
|
|
404
|
+
SqliteCode.OPEN_FAILED,
|
|
405
|
+
"SQLITE_OPEN_FAILED: 明文库迁移替换失败(rename 到备份位)",
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
if (!tempFile.renameTo(file)) {
|
|
409
|
+
// 替换失败:回滚备份,保持明文原状(可重试)。
|
|
410
|
+
backupFile.renameTo(file)
|
|
411
|
+
throw SqliteBridgeError(
|
|
412
|
+
SqliteCode.OPEN_FAILED,
|
|
413
|
+
"SQLITE_OPEN_FAILED: 明文库迁移替换失败(临时文件落位)",
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
// 替换成功:删明文备份(不留盘是验收标准;失败不致命——下次迁移会重建)。
|
|
417
|
+
backupFile.delete()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 读文件头 n 字节(不足 n 字节 = 不是 SQLite 库,返回 null 走「非明文」分支)。 */
|
|
421
|
+
private fun readFileHeader(file: File, n: Int): ByteArray? {
|
|
422
|
+
return try {
|
|
423
|
+
RandomAccessFile(file, "r").use { handle ->
|
|
424
|
+
val buffer = ByteArray(n)
|
|
425
|
+
val read = handle.read(buffer)
|
|
426
|
+
if (read == n) buffer else null
|
|
427
|
+
}
|
|
428
|
+
} catch (_: Exception) {
|
|
429
|
+
null
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 裸单值查询(返回首行首列整数值;失败/无行返回 null——由调用方决定语义)。 */
|
|
434
|
+
private fun scalar(db: SQLiteDatabase, sql: String): Long? = try {
|
|
435
|
+
db.rawQuery(sql, null).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
|
|
436
|
+
} catch (_: Exception) {
|
|
437
|
+
null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// MARK: - 内部执行(APP-024 原样:串行 + 解包 + 判定委托纯逻辑层)
|
|
441
|
+
|
|
442
|
+
private fun <T> execute(block: (SQLiteDatabase) -> T): T {
|
|
443
|
+
val job = executor.submit<T> {
|
|
444
|
+
val db = ensureOpenHelper().writableDatabase // 长持:同实例复用(SAVEPOINT 跨调用存活)
|
|
445
|
+
block(db)
|
|
446
|
+
}
|
|
447
|
+
return try {
|
|
448
|
+
job.get()
|
|
449
|
+
} catch (error: java.util.concurrent.ExecutionException) {
|
|
450
|
+
throw error.cause ?: error // 解开 Promise 式包装,抛出原始 SqliteBridgeError
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private fun rejectIfMultiple(sql: String) {
|
|
455
|
+
val count = countSqlStatements(sql)
|
|
456
|
+
check(count == 1) {
|
|
457
|
+
"SQLITE_MULTIPLE_STATEMENTS: SQL 含 $count 条语句(Android 的 execSQL 会多执行、iOS 会静默丢尾随——两侧都拒绝以对齐语义);sql: ${sql.take(120)}"
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private fun rejectIfPlaceholderMismatch(sql: String, params: List<Any>) {
|
|
462
|
+
val placeholderCount = countPlaceholders(sql)
|
|
463
|
+
check(placeholderCount == params.size) {
|
|
464
|
+
"SQLITE_BIND_FAILED: 参数个数 ${params.size} 与占位符数 $placeholderCount 不一致(多了会被静默忽略,少了按 NULL 执行——两侧都不允许);sql: ${sql.take(120)}"
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private fun bindArray(params: List<Any>): Array<Any?> =
|
|
469
|
+
params.map { if (it === JSONObject.NULL) null else it }.toTypedArray()
|
|
470
|
+
|
|
471
|
+
private fun queryRows(cursor: Cursor): JSONArray {
|
|
472
|
+
val rows = JSONArray()
|
|
473
|
+
try {
|
|
474
|
+
val columnCount = cursor.columnCount
|
|
475
|
+
while (cursor.moveToNext()) {
|
|
476
|
+
val row = JSONObject()
|
|
477
|
+
for (i in 0 until columnCount) {
|
|
478
|
+
val name = cursor.getColumnName(i)
|
|
479
|
+
row.put(name, cursorValue(cursor, i))
|
|
480
|
+
}
|
|
481
|
+
rows.put(row)
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
cursor.close()
|
|
485
|
+
}
|
|
486
|
+
return rows
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** 按 `Cursor.getType` 把当前行列读成 JSON 值(类型映射经纯逻辑层 {@link mapCursorFieldType})。 */
|
|
490
|
+
private fun cursorValue(cursor: Cursor, column: Int): Any? {
|
|
491
|
+
return when (mapCursorFieldType(cursor.getType(column))) {
|
|
492
|
+
"integer" -> cursor.getLong(column) // 超 2^53 由调用方保证 id 字符串
|
|
493
|
+
"float" -> cursor.getDouble(column)
|
|
494
|
+
"string" -> cursor.getString(column)
|
|
495
|
+
"blob" -> blobToB64(cursor.getBlob(column)) // BLOB → base64(DeviceFile 同形态)
|
|
496
|
+
"null" -> JSONObject.NULL
|
|
497
|
+
else -> throw SqliteBridgeError(
|
|
498
|
+
SqliteCode.COLUMN_FAILED,
|
|
499
|
+
"未知列类型(column=$column)——SQLite Cursor.getType 泄漏了新类型?",
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private fun blobToB64(bytes: ByteArray): String =
|
|
505
|
+
Base64.encodeToString(bytes, Base64.NO_WRAP)
|
|
506
|
+
}
|