|
|
@@ -6,19 +6,29 @@ import androidx.room.Database
|
|
|
import androidx.room.Room
|
|
|
import androidx.room.RoomDatabase
|
|
|
import androidx.room.TypeConverters
|
|
|
+import androidx.sqlite.db.SupportSQLiteDatabase
|
|
|
import com.grkj.data.converters.Converters
|
|
|
import com.grkj.data.dao.*
|
|
|
import com.grkj.data.model.dos.*
|
|
|
+import com.grkj.shared.config.AESConfig
|
|
|
import com.grkj.shared.config.Constants
|
|
|
import com.sik.sikcore.SIKCore
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
+import net.zetetic.database.sqlcipher.SQLiteConnection
|
|
|
+import net.zetetic.database.sqlcipher.SQLiteDatabaseHook
|
|
|
+import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
|
|
import org.slf4j.Logger
|
|
|
import org.slf4j.LoggerFactory
|
|
|
import java.io.File
|
|
|
import java.io.FileOutputStream
|
|
|
+import java.util.concurrent.Executors
|
|
|
+
|
|
|
+// 显式区分两个 SQLite,避免 IDE 导错:
|
|
|
+import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
|
|
|
+import android.database.sqlite.SQLiteDatabase as SysDB
|
|
|
|
|
|
/**
|
|
|
- * 本地数据库
|
|
|
+ * 本地数据库(SQLCipher)
|
|
|
*/
|
|
|
@Database(
|
|
|
entities = [
|
|
|
@@ -52,6 +62,8 @@ abstract class ISCSDatabase : RoomDatabase() {
|
|
|
companion object {
|
|
|
const val DB_NAME = "iscs_database.db"
|
|
|
private val logger: Logger = LoggerFactory.getLogger(ISCSDatabase::class.java)
|
|
|
+ // 放在 companion 里
|
|
|
+ private val MIGRATION_LOCK = Any()
|
|
|
|
|
|
// 外部存储目录路径
|
|
|
val EXTERNAL_DB_FILE: File by lazy {
|
|
|
@@ -62,26 +74,21 @@ abstract class ISCSDatabase : RoomDatabase() {
|
|
|
val instance: ISCSDatabase by lazy {
|
|
|
val context = SIKCore.getApplication()
|
|
|
|
|
|
- // 检查并创建外部目录
|
|
|
+ // 目录
|
|
|
val parentDir = EXTERNAL_DB_FILE.parentFile
|
|
|
- if (parentDir != null) {
|
|
|
- if (!parentDir.exists()) {
|
|
|
- val ok = parentDir.mkdirs()
|
|
|
- if (!ok) {
|
|
|
- Log.e("ISCSDatabase", "无法创建目录: ${parentDir.absolutePath}")
|
|
|
- } else {
|
|
|
- logger.info("创建目录: ${parentDir.absolutePath}")
|
|
|
- }
|
|
|
+ if (parentDir != null && !parentDir.exists()) {
|
|
|
+ if (!parentDir.mkdirs()) {
|
|
|
+ Log.e("ISCSDatabase", "无法创建目录: ${parentDir.absolutePath}")
|
|
|
+ } else {
|
|
|
+ logger.info("创建目录: ${parentDir.absolutePath}")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 若文件不存在,从 assets 拷贝
|
|
|
+ // 首次落地模板库(可能是明文,也可能本来就是加密库)
|
|
|
if (!EXTERNAL_DB_FILE.exists()) {
|
|
|
try {
|
|
|
context.assets.open("data.db").use { input ->
|
|
|
- FileOutputStream(EXTERNAL_DB_FILE).use { output ->
|
|
|
- input.copyTo(output)
|
|
|
- }
|
|
|
+ FileOutputStream(EXTERNAL_DB_FILE).use { output -> input.copyTo(output) }
|
|
|
}
|
|
|
logger.info("已从 assets 复制数据库到: ${EXTERNAL_DB_FILE.absolutePath}")
|
|
|
} catch (e: Exception) {
|
|
|
@@ -89,20 +96,191 @@ abstract class ISCSDatabase : RoomDatabase() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 构建 RoomDatabase,使用外部存储路径
|
|
|
- val builder = Room.databaseBuilder(
|
|
|
+ // 确保成为加密库(首启自动迁移)
|
|
|
+ ensureCiphered(EXTERNAL_DB_FILE)
|
|
|
+
|
|
|
+ // Room + SQLCipher
|
|
|
+ val passphrase: ByteArray = AESConfig.defaultConfig.key()
|
|
|
+ val hook = object : SQLiteDatabaseHook {
|
|
|
+ override fun preKey(connection: SQLiteConnection) { /* no-op */ }
|
|
|
+ override fun postKey(connection: SQLiteConnection) {
|
|
|
+ // 这些 PRAGMA 都要把三个参数补全(bindArgs, cancellationSignal)
|
|
|
+ connection.execute("PRAGMA foreign_keys=ON", null, null)
|
|
|
+ // journal_mode 建议用 executeForString,返回当前模式字符串
|
|
|
+ connection.executeForString("PRAGMA journal_mode=WAL", null, null)
|
|
|
+ connection.execute("PRAGMA synchronous=NORMAL", null, null)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ val factory = SupportOpenHelperFactory(passphrase)
|
|
|
+
|
|
|
+ val db = Room.databaseBuilder(
|
|
|
context,
|
|
|
ISCSDatabase::class.java,
|
|
|
EXTERNAL_DB_FILE.absolutePath
|
|
|
- ).addMigrations(*ISCSMigrations.migrationData)
|
|
|
- if (Constants.DEBUG) {
|
|
|
- builder.setQueryCallback(Dispatchers.IO, { sql, args ->
|
|
|
- logger.debug("SQL: $sql, args: $args")
|
|
|
+ )
|
|
|
+ .openHelperFactory(factory)
|
|
|
+ .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) // ✅ 显式 WAL
|
|
|
+ .addMigrations(*ISCSMigrations.migrationData)
|
|
|
+ .addCallback(object : RoomDatabase.Callback() {
|
|
|
+ override fun onOpen(db: SupportSQLiteDatabase) {
|
|
|
+ // 数据库已经可读写,放行初始化
|
|
|
+ DbReadyGate.open()
|
|
|
+ }
|
|
|
})
|
|
|
- // 不要使用 fallbackToDestructiveMigration()
|
|
|
+ .build()
|
|
|
+
|
|
|
+ // 强制触发实际打开(避免“懒打开”延后 onOpen 回调)
|
|
|
+ db.openHelper.writableDatabase
|
|
|
+
|
|
|
+ db // ← 作为 instance 返回
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保证目标库为加密库:
|
|
|
+ * - 能用口令打开 => 已加密,返回
|
|
|
+ * - 系统 SQLite 能打开 => 明文;做 WAL checkpoint + 备份 + sqlcipher_export
|
|
|
+ * - 两边都不行 => 可能是加密但口令不匹配/文件损坏,保守返回(不覆盖原文件)
|
|
|
+ */
|
|
|
+ private fun ensureCiphered(targetDbFile: File) {
|
|
|
+ if (!targetDbFile.exists()) return
|
|
|
+ val passphrase: ByteArray = AESConfig.defaultConfig.key()
|
|
|
+
|
|
|
+ synchronized(MIGRATION_LOCK) {
|
|
|
+ if (tryOpenCipherDb(targetDbFile, passphrase)) {
|
|
|
+ logger.info("数据库已为加密库:${targetDbFile.absolutePath}")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (tryOpenPlainDb(targetDbFile)) {
|
|
|
+ safePlainWalCheckpoint(targetDbFile)
|
|
|
+
|
|
|
+ val app = SIKCore.getApplication()
|
|
|
+ val backup = File(targetDbFile.parentFile, "${targetDbFile.name}.bak")
|
|
|
+ val tmpCipher = File(app.cacheDir, "${targetDbFile.name}.tmp.cipher") // ✅ 内部缓存
|
|
|
+ if (tmpCipher.exists()) tmpCipher.delete() // ✅ 先清理
|
|
|
+
|
|
|
+ try {
|
|
|
+ targetDbFile.copyTo(backup, overwrite = true)
|
|
|
+
|
|
|
+ migratePlainToCipher(
|
|
|
+ plainPath = targetDbFile.absolutePath,
|
|
|
+ cipherPath = tmpCipher.absolutePath,
|
|
|
+ passphrase = passphrase
|
|
|
+ )
|
|
|
+
|
|
|
+ // 二次校验:确保 tmp 真是可开的加密库
|
|
|
+ check(tryOpenCipherDb(tmpCipher, passphrase)) { "迁移后加密库校验失败" }
|
|
|
+
|
|
|
+ // ✅ 安全替换(见下一个改动)
|
|
|
+ safeReplaceFile(tmpCipher, targetDbFile)
|
|
|
+
|
|
|
+ logger.info("已将明文库迁移为加密库:${targetDbFile.absolutePath}")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ logger.error("明文→加密迁移失败,已保留原库与备份", e)
|
|
|
+ if (tmpCipher.exists()) tmpCipher.delete()
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.error("数据库无法识别:可能是加密口令不匹配或文件损坏,停止处理以保护数据 -> ${targetDbFile.absolutePath}")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 用 SQLCipher 测试是否“已加密并且口令正确”
|
|
|
+ private fun tryOpenCipherDb(dbFile: File, passphrase: ByteArray): Boolean = try {
|
|
|
+ val db = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(
|
|
|
+ dbFile, passphrase, null, null, null
|
|
|
+ )
|
|
|
+ db.close()
|
|
|
+ true
|
|
|
+ } catch (_: Throwable) { false }
|
|
|
+
|
|
|
+ // 用系统 SQLite 判定是否“明文库”
|
|
|
+ private fun tryOpenPlainDb(dbFile: File): Boolean = try {
|
|
|
+ val db = android.database.sqlite.SQLiteDatabase.openDatabase(
|
|
|
+ dbFile.absolutePath, null,
|
|
|
+ android.database.sqlite.SQLiteDatabase.OPEN_READONLY
|
|
|
+ or android.database.sqlite.SQLiteDatabase.NO_LOCALIZED_COLLATORS
|
|
|
+ )
|
|
|
+ db.close()
|
|
|
+ true
|
|
|
+ } catch (_: Throwable) { false }
|
|
|
+
|
|
|
+ // 明文 → 加密(sqlcipher_export)
|
|
|
+ private fun migratePlainToCipher(plainPath: String, cipherPath: String, passphrase: ByteArray) {
|
|
|
+ val cipherDb = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(
|
|
|
+ File(cipherPath), passphrase, null, null, null
|
|
|
+ )
|
|
|
+ try {
|
|
|
+ // 保险起见,确认 main 为空(可选)
|
|
|
+ val cur = cipherDb.rawQuery("SELECT COUNT(*) FROM sqlite_master", null)
|
|
|
+ if (cur.moveToFirst() && cur.getLong(0) != 0L) {
|
|
|
+ cur.close()
|
|
|
+ throw IllegalStateException("目标加密库非空,已中止导出:$cipherPath")
|
|
|
+ }
|
|
|
+ cur.close()
|
|
|
+
|
|
|
+ val escapedPlain = plainPath.replace("'", "''")
|
|
|
+ cipherDb.rawExecSQL("ATTACH DATABASE '$escapedPlain' AS plaintext KEY ''")
|
|
|
+ cipherDb.rawExecSQL("SELECT sqlcipher_export('main','plaintext')")
|
|
|
+ cipherDb.rawExecSQL("DETACH DATABASE plaintext")
|
|
|
+ cipherDb.rawExecSQL("VACUUM")
|
|
|
+ } finally {
|
|
|
+ cipherDb.close()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ // 明文库做一次 WAL checkpoint,避免数据停在 -wal
|
|
|
+ private fun safePlainWalCheckpoint(dbFile: File) {
|
|
|
+ try {
|
|
|
+ val db = android.database.sqlite.SQLiteDatabase.openDatabase(
|
|
|
+ dbFile.absolutePath, null,
|
|
|
+ android.database.sqlite.SQLiteDatabase.OPEN_READWRITE
|
|
|
+ )
|
|
|
+ db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).close()
|
|
|
+ db.execSQL("PRAGMA journal_mode=DELETE")
|
|
|
+ db.close()
|
|
|
+ File(dbFile.parent, dbFile.name + "-wal").delete()
|
|
|
+ File(dbFile.parent, dbFile.name + "-shm").delete()
|
|
|
+ } catch (_: Throwable) {
|
|
|
+ // 忽略:老库未启用 WAL 也无妨
|
|
|
}
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun safeReplaceFile(src: File, dst: File) {
|
|
|
+ if (!src.exists()) throw IllegalStateException("临时加密库不存在:${src.absolutePath}")
|
|
|
+ dst.parentFile?.mkdirs()
|
|
|
|
|
|
- builder.build()
|
|
|
+ if (dst.exists()) {
|
|
|
+ val old = File(dst.parentFile, dst.name + ".old")
|
|
|
+ if (old.exists()) old.delete()
|
|
|
+ // 尽量挪走旧文件,失败也不紧张
|
|
|
+ dst.renameTo(old)
|
|
|
+ copyOverwrite(src, dst)
|
|
|
+ old.delete()
|
|
|
+ src.delete()
|
|
|
+ } else {
|
|
|
+ if (!src.renameTo(dst)) {
|
|
|
+ copyOverwrite(src, dst)
|
|
|
+ src.delete()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ dst.setReadable(true, false)
|
|
|
+ dst.setWritable(true, false)
|
|
|
}
|
|
|
+
|
|
|
+ private fun copyOverwrite(src: File, dst: File) {
|
|
|
+ java.io.FileInputStream(src).channel.use { inCh ->
|
|
|
+ java.io.FileOutputStream(dst).channel.use { outCh ->
|
|
|
+ var pos = 0L
|
|
|
+ val size = inCh.size()
|
|
|
+ while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
|
|
|
+ outCh.force(true) // fsync,确保落盘
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
}
|
|
|
}
|