# Conflicts: # app/src/main/assets/best_embedding_model_mobile.pt # app/src/main/assets/data/class_info.json # app/src/main/assets/data/embeddings.json # app/src/main/assets/data/labels.json
333 lines
12 KiB
Kotlin
333 lines
12 KiB
Kotlin
/*
|
|
* Copyright 2024 ObjectBox Ltd. All rights reserved.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.sw.inbound.objbox
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.Context
|
|
import android.util.Log
|
|
import com.sw.inbound.MyApp
|
|
import com.sw.inbound.utils.LogSaveUtil
|
|
import io.objectbox.Box
|
|
import io.objectbox.BoxStore
|
|
import io.objectbox.BoxStoreBuilder
|
|
import io.objectbox.exception.DbException
|
|
import io.objectbox.exception.FileCorruptException
|
|
import io.objectbox.kotlin.boxFor
|
|
import io.objectbox.query.IdWithScore
|
|
import io.objectbox.query.Query
|
|
import io.objectbox.sync.Sync
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
import kotlinx.coroutines.withContext
|
|
import java.io.File
|
|
import java.io.FileInputStream
|
|
import java.io.FileOutputStream
|
|
import java.io.IOException
|
|
import java.util.zip.GZIPOutputStream
|
|
|
|
/**
|
|
* Singleton to keep BoxStore reference and provide current list of Notes Objects.
|
|
* Inserts demo data if no Objects are stored.
|
|
*/
|
|
@SuppressLint("LogNotTimber")
|
|
object ObjectBox {
|
|
|
|
// 确保所有设备/版本使用相同的objectbox-models/default.json
|
|
// 避免直接修改自动生成的MyObjectBox类
|
|
// 跨版本升级时使用boxStore.runInTx执行数据迁移
|
|
|
|
private const val TAG = "ObjectBox"
|
|
|
|
// 添加 @Volatile 保证多线程下的可见性
|
|
@Volatile
|
|
var boxStore: BoxStore? = null
|
|
|
|
/**
|
|
* If building the [boxStore] failed, contains the thrown error message.
|
|
*/
|
|
var dbExceptionMessage: String? = null
|
|
private set
|
|
|
|
fun init(context: Context) {
|
|
if (boxStore != null && boxStore!!.isClosed.not()) return
|
|
// 优化:调大事务回收超时时间
|
|
System.setProperty("objectbox.finalizerTimeout", "30000");
|
|
// On Android make sure to pass a Context when building the Store.
|
|
boxStore = try {
|
|
MyObjectBox.builder()
|
|
.androidContext(context.applicationContext)
|
|
//.disableMultiProcess() // 开启多进程支持(按需关闭,单进程可删)
|
|
//.disableFileCompression() // 禁用文件压缩,避免存储异常
|
|
.build()
|
|
} catch (e: DbException) {
|
|
if (e.javaClass == DbException::class.java || e is FileCorruptException) {
|
|
// Failed to build BoxStore due to database file issue, store message;
|
|
// checked in NoteListActivity to notify user.
|
|
dbExceptionMessage = e.toString()
|
|
LogSaveUtil.saveLogFile("ObjectBox.init异常1:$dbExceptionMessage")
|
|
return
|
|
} else {
|
|
LogSaveUtil.saveLogFile("ObjectBox.init异常2:${e.message}")
|
|
// Failed to build BoxStore due to developer error.
|
|
throw e
|
|
}
|
|
}
|
|
|
|
if (com.sw.inbound.BuildConfig.DEBUG) {
|
|
var syncAvailable = if (Sync.isAvailable()) "available" else "unavailable"
|
|
Log.d(
|
|
TAG,
|
|
"Using ObjectBox ${BoxStore.getVersion()} (${BoxStore.getVersionNative()}, sync $syncAvailable)"
|
|
)
|
|
// Enable ObjectBox Admin on debug builds.
|
|
// https://docs.objectbox.io/data-browser
|
|
io.objectbox.android.Admin(boxStore).start(context.applicationContext)
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* If the database file is not in use, compresses (GZIP) and copies it to the given [target].
|
|
* 增加 IOException 捕获,避免文件异常导致崩溃。
|
|
*/
|
|
fun copyAndGzipDatabaseFileTo(target: File, context: Context): Boolean {
|
|
if (BoxStore.isDatabaseOpen(context, null)) {
|
|
// Do not copy if database file is still in use.
|
|
// If it would be open, the copy will likely get corrupted
|
|
// as BoxStore may currently write data to the file.
|
|
Log.e(TAG, "Database file is still in use, can not copy.")
|
|
return false
|
|
}
|
|
|
|
// If a name was given when building BoxStore use that instead of the default below.
|
|
val dbName = BoxStoreBuilder.DEFAULT_NAME
|
|
return try {
|
|
File(context.filesDir, "objectbox/$dbName/data.mdb").inputStream().use { input ->
|
|
target.parentFile?.mkdirs()
|
|
GZIPOutputStream(target.outputStream()).use { output ->
|
|
input.copyTo(output, DEFAULT_BUFFER_SIZE)
|
|
}
|
|
}
|
|
true
|
|
} catch (e: IOException) {
|
|
Log.e(TAG, "备份数据库文件失败", e)
|
|
false
|
|
}
|
|
}
|
|
|
|
|
|
// 获取指定实体的Box,自动关联BoxStore
|
|
inline fun <reified T> getBox(): Box<T>? = boxStore?.boxFor()
|
|
|
|
// 协程安全执行数据库操作(推荐所有操作使用此方法)
|
|
suspend fun <T> safeDbOp(operation: suspend () -> T): T? {
|
|
return withContext(Dispatchers.IO) {
|
|
try {
|
|
dbMutex.withLock { operation() }
|
|
} catch (e: FileCorruptException) {
|
|
// 操作中触发损坏,尝试重建数据库
|
|
e.printStackTrace()
|
|
LogSaveUtil.saveLogFile("safeDbOp出现FileCorruptException异常:${e.message},${e.errorCode}")
|
|
// 使用安全调用替代 !! 断言,避免 NPE
|
|
val app = MyApp.instance ?: return@withContext null
|
|
init(app)
|
|
null
|
|
} catch (e: Exception) {
|
|
e.printStackTrace()
|
|
LogSaveUtil.saveLogFile("safeDbOp出现其它异常:${e.message}")
|
|
null
|
|
} finally {
|
|
// 线程资源清理
|
|
boxStore?.closeThreadResources()
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun query(floatArray: FloatArray, queryCount: Int) = safeDbOp {
|
|
val query: Query<Food>? = getBox<Food>()?.query(
|
|
Food_.foodVector
|
|
.nearestNeighbors(floatArray, queryCount)
|
|
)?.build()
|
|
try {
|
|
query?.findIdsWithScores()
|
|
} finally {
|
|
// 先关闭Query,释放Cursor
|
|
query?.close()
|
|
}
|
|
} ?: emptyList()
|
|
|
|
suspend fun get(id: Long) = safeDbOp {
|
|
getBox<Food>()?.get(id)
|
|
}
|
|
|
|
// 批量按ID加载,一次DB事务替代循环单条查询
|
|
suspend fun getByIds(ids: List<Long>): List<Food> = safeDbOp {
|
|
if (ids.isEmpty()) return@safeDbOp emptyList<Food>()
|
|
getBox<Food>()?.get(ids.toLongArray()) ?: emptyList<Food>()
|
|
} ?: emptyList()
|
|
|
|
suspend fun put(entity: Food) = safeDbOp {
|
|
getBox<Food>()?.put(entity)
|
|
}
|
|
|
|
suspend fun putAll(entities: List<Food>) = safeDbOp {
|
|
val box = getBox<Food>()
|
|
box?.put(entities)
|
|
}
|
|
|
|
// 使用 QueryBuilder 在数据库层计数,避免全量加载到内存
|
|
suspend fun countNameField(name: String) = safeDbOp {
|
|
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.count()
|
|
} ?: 0L
|
|
|
|
suspend fun countIndexField(idx: Int) = safeDbOp {
|
|
getBox<Food>()?.query(Food_.foodIdx.equal(idx))?.build()?.count()
|
|
} ?: 0L
|
|
|
|
// 使用 QueryBuilder 在数据库层过滤,避免全量加载到内存
|
|
suspend fun filter(name: String?) = safeDbOp {
|
|
if (!name.isNullOrBlank()) {
|
|
getBox<Food>()?.query(Food_.name.contains(name))?.build()?.find()
|
|
?.distinctBy { it.name }
|
|
} else {
|
|
getBox<Food>()?.all?.distinctBy { it.name }
|
|
} ?: emptyList()
|
|
}
|
|
|
|
/**
|
|
* 按名称分组统计采集数量
|
|
* 使用属性查询只加载 name 字段,不加载 foodVector,节省大量内存
|
|
* @param nameFilter 名称过滤关键词,为空时查询全部
|
|
* @return 按名称分组后的 (名称, 数量) 列表,按名称排序
|
|
*/
|
|
suspend fun queryFoodNameCounts(nameFilter: String? = null) = safeDbOp {
|
|
val box = getBox<Food>() ?: return@safeDbOp emptyList<Pair<String, Int>>()
|
|
val query = if (!nameFilter.isNullOrBlank()) {
|
|
box.query(Food_.name.contains(nameFilter)).build()
|
|
} else {
|
|
box.query().build()
|
|
}
|
|
try {
|
|
// 只查 name 字段,完全不加载 foodVector
|
|
query.property(Food_.name).findStrings()
|
|
.groupingBy { it }
|
|
.eachCount()
|
|
.map { (name, count) -> Pair(name, count) }
|
|
.sortedBy { it.first }
|
|
} finally {
|
|
query.close()
|
|
}
|
|
} ?: emptyList()
|
|
|
|
// 使用 QueryBuilder 在数据库层删除,避免全量加载到内存
|
|
suspend fun remove(name: String) = safeDbOp {
|
|
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.remove()
|
|
}
|
|
|
|
suspend fun removeAll() = safeDbOp {
|
|
getBox<Food>()?.removeAll()
|
|
}
|
|
|
|
private val dbMutex = Mutex() // 协程并发锁,保证写入操作原子性
|
|
private const val DB_DIR_NAME = "objectbox" // ObjectBox 默认数据库目录
|
|
private const val BACKUP_DIR_NAME = "objectbox_backup" // 备份目录
|
|
|
|
// 检查内部存储是否可读写(操作数据库前调用)
|
|
// 修复:原逻辑错误地检查外部存储状态,数据库存于内部存储,与外部存储无关
|
|
fun isStorageAvailable(context: Context): Boolean {
|
|
return try {
|
|
context.filesDir.canRead() && context.filesDir.canWrite()
|
|
} catch (e: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
// 备份数据库到应用私有目录(无权限要求,推荐)
|
|
suspend fun backupDb(context: Context): Boolean = withContext(Dispatchers.IO) {
|
|
if (!isStorageAvailable(context)) return@withContext false
|
|
val dbDir = File(context.filesDir, DB_DIR_NAME)
|
|
val backupDir = File(context.filesDir, BACKUP_DIR_NAME)
|
|
return@withContext copyDir(dbDir, backupDir)
|
|
}
|
|
|
|
// 从备份恢复数据库(恢复后会重建BoxStore)
|
|
suspend fun restoreDb(context: Context): Boolean = withContext(Dispatchers.IO) {
|
|
if (!isStorageAvailable(context)) return@withContext false
|
|
val dbDir = File(context.filesDir, DB_DIR_NAME)
|
|
val backupDir = File(context.filesDir, BACKUP_DIR_NAME)
|
|
if (!backupDir.exists()) return@withContext false
|
|
// 先关闭旧的BoxStore,删除损坏文件,再恢复备份
|
|
boxStore?.close()
|
|
deleteDbFiles(context)
|
|
val isSuccess = copyDir(backupDir, dbDir)
|
|
// 重新初始化
|
|
init(context)
|
|
return@withContext isSuccess
|
|
}
|
|
|
|
// 递归删除数据库文件
|
|
private fun deleteDbFiles(context: Context) {
|
|
val dbDir = File(context.filesDir, DB_DIR_NAME)
|
|
if (dbDir.exists()) deleteDirRecursively(dbDir)
|
|
}
|
|
|
|
// 递归删除目录
|
|
private fun deleteDirRecursively(file: File) {
|
|
if (file.isDirectory) {
|
|
file.listFiles()?.forEach { deleteDirRecursively(it) }
|
|
}
|
|
file.delete()
|
|
}
|
|
|
|
// 递归复制目录(核心备份/恢复逻辑)
|
|
private fun copyDir(srcDir: File, destDir: File): Boolean {
|
|
return try {
|
|
if (!srcDir.exists()) return false
|
|
if (!destDir.exists()) destDir.mkdirs()
|
|
srcDir.listFiles()?.forEach { srcFile ->
|
|
val destFile = File(destDir, srcFile.name)
|
|
if (srcFile.isDirectory) {
|
|
copyDir(srcFile, destFile)
|
|
} else {
|
|
copyFile(srcFile, destFile)
|
|
}
|
|
}
|
|
true
|
|
} catch (e: IOException) {
|
|
e.printStackTrace()
|
|
false
|
|
}
|
|
}
|
|
|
|
// 复制单个文件(使用NIO,高效稳定)
|
|
private fun copyFile(srcFile: File, destFile: File) {
|
|
FileInputStream(srcFile).channel.use { srcChannel ->
|
|
FileOutputStream(destFile).channel.use { destChannel ->
|
|
destChannel.transferFrom(srcChannel, 0, srcChannel.size())
|
|
}
|
|
}
|
|
}
|
|
|
|
// 关闭BoxStore(应用退出时调用,可选)
|
|
fun close() {
|
|
boxStore?.close()
|
|
boxStore = null
|
|
}
|
|
|
|
}
|