diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea2679f..666bb40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -137,8 +137,8 @@ dependencies { implementation(libs.androidx.camera.extensions) //pytorch - implementation (libs.pytorch.android) - implementation (libs.pytorch.android.torchvision) +// implementation (libs.pytorch.android) +// implementation (libs.pytorch.android.torchvision) // objectbox if (isDebug) { diff --git a/app/release/菜品配比终端v1.0-release.apk b/app/release/菜品配比终端v1.0-release.apk deleted file mode 100644 index ea09e3c..0000000 Binary files a/app/release/菜品配比终端v1.0-release.apk and /dev/null differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 23ff004..13947e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,8 +21,6 @@ - - + android:exported="true"> - - - - - - \ No newline at end of file diff --git a/app/src/main/assets/best_embedding_model_mobile.pt b/app/src/main/assets/best_embedding_model_mobile.pt deleted file mode 100644 index f1464da..0000000 Binary files a/app/src/main/assets/best_embedding_model_mobile.pt and /dev/null differ diff --git a/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt b/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt index e403b33..fc602e8 100644 --- a/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt +++ b/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt @@ -8,8 +8,6 @@ import android.content.SharedPreferences import android.util.Log import com.shuwei.dish.match.R import com.shuwei.dish.match.db.DatabaseProvider -import com.shuwei.dish.match.objbox.FoodModule -import com.shuwei.dish.match.objbox.ObjectBox import com.shuwei.dish.match.scale.ScaleServiceManager import com.shuwei.dish.match.ui.InitActivity.Companion.TAG import com.shuwei.dish.match.utils.AppUtil @@ -42,7 +40,7 @@ class BaseApp : Application() { // GlobalData.deviceId = "39a7abdd06b3c7ab" val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED) registerReceiver(BootReceiver(), filter) - ObjectBox.init(this) +// ObjectBox.init(this) } override fun onTerminate() { diff --git a/app/src/main/java/com/shuwei/dish/match/objbox/FoodModule.kt b/app/src/main/java/com/shuwei/dish/match/objbox/FoodModule.kt index 9e4fb11..9a18459 100644 --- a/app/src/main/java/com/shuwei/dish/match/objbox/FoodModule.kt +++ b/app/src/main/java/com/shuwei/dish/match/objbox/FoodModule.kt @@ -1,308 +1,308 @@ -package com.shuwei.dish.match.objbox - -import android.content.Context -import android.graphics.Bitmap -import android.util.Log -import androidx.core.graphics.scale -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.shuwei.dish.match.utils.AssetsTool -import com.shuwei.dish.match.utils.LogSaveUtil -import com.shuwei.dish.match.utils.ext.toJsonString -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.pytorch.IValue -import org.pytorch.Module -import org.pytorch.torchvision.TensorImageUtils -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.InputStream - - -object FoodModule { - - private lateinit var module_mobile: Module - - private val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f) - private val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f) - - // 1. 定义你的模型固定输入尺寸 (根据你的tflite模型修改,比如224x224) - private const val MODEL_INPUT_WIDTH = 300 - private const val MODEL_INPUT_HEIGHT = 300 - const val DEFAULT_FOOD_INDEX = -1 - const val DEFAULT_QUERY_COUNT = 50 - - const val BAG_RATE = 0.05 - - suspend fun init(context: Context) { - withContext(Dispatchers.IO) { - module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt")) - initDefFoodData(context) - } - } - -// fun uri2FloatArray(uri: Uri): FloatArray? { -// return MyApp.instance?.let { context -> -// ImageUtil.uriToBitmap(context, uri)?.let { -// bitmap2FloatArray(it) -// } -// } -// } - - fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? { - var rgb565Bitmap: Bitmap? = null - try { - val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT) - if (isRecycle) { - originBitmap.recycle() - } - rgb565Bitmap = scaledBitmap.copy(Bitmap.Config.RGB_565, false) - scaledBitmap.recycle() - val inputTensor = TensorImageUtils.bitmapToFloat32Tensor( - rgb565Bitmap, - NO_MEAN_RGB, // [0.485, 0.456, 0.406] TORCHVISION_NORM_MEAN_RGB - NO_STD_RGB // [0.229, 0.224, 0.225] TORCHVISION_NORM_STD_RGB - ) - val outputTensor = module_mobile.forward(IValue.from(inputTensor)).toTensor() - return outputTensor.dataAsFloatArray - } catch (e: OutOfMemoryError) { - e.printStackTrace() - logInfo("bitmap2FloatArray异常:${e.message}") - } finally { - if (rgb565Bitmap != null && rgb565Bitmap.isRecycled.not()) { - rgb565Bitmap.recycle() - } - //System.gc() - //System.runFinalization() - } - return null - } - -// fun queryFood(uri: Uri, queryCount: Int = DEFAULT_QUERY_COUNT): List? { -// return uri2FloatArray(uri)?.let { -// queryFood(it, queryCount) -// } -// } - -// /** -// * 返回识别物品名称列表 -// */ -// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List { -// val floatArray = bitmap2FloatArray(bitmap) -// return queryFood(floatArray, queryCount) -// } - -// /** -// * 返回识别物品IdNameScore对象列表 -// */ -// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List { -// val floatArray = bitmap2FloatArray(bitmap) -// return queryFoodNameScore(floatArray, queryCount) -// } - - suspend fun queryFoodNameScore( - floatArray: FloatArray?, - queryCount: Int = DEFAULT_QUERY_COUNT - ): List { - if (floatArray == null) return emptyList() -// val query: Query = box.query(Food_.foodVector.nearestNeighbors(floatArray, queryCount)).build() -// //查询比较分数 -//// val tempList = query.findWithScores().sortedBy { it.score }.map { "${it.get().name}|${it.get().foodIdx}|${it.score}" } -// var idScoreList: List -// try { -// idScoreList = query.findIdsWithScores(); -// } finally { -// // 先关闭Query,释放Cursor -// query.close() -// } - - logInfo("queryFood向量:${floatArray.slice(0 until 50).toJsonString()}") - val idScoreList = ObjectBox.query(floatArray, queryCount) - // 批量加载所有实体,一次DB操作替代循环单条查询 - val foodMap = ObjectBox.getByIds(idScoreList.map { it.id }).associateBy { it.id } - val nameScoreList = mutableListOf() - idScoreList.forEach { - val name = foodMap[it.id]?.name ?: "" - nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score)) - } - logInfo("queryFood数据:${nameScoreList.toJsonString()}") - return nameScoreList - } - - suspend fun getFoodScoreList(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List { - val floatArray = bitmap2FloatArray(bitmap, false) ?: return emptyList() - logInfo("getFoodScoreList向量:${floatArray.toJsonString()}") - val nameScoreList = queryFoodNameScore(floatArray, queryCount) - if (nameScoreList.isEmpty()) { - return emptyList() - } - //暂时不过滤 - val maxScoreList = nameScoreList - //.filter { it.score < 0.15 } - .groupBy { it.name } - .map { (_, value) -> value.minByOrNull { it.score }!! } - .toMutableList() -// val map = mutableMapOf() -// nameScoreList.forEach { -// val key = it.name -// val count = map[key] ?: 0 -// map[key] = count + 1 -// } -// val orderList = map.entries.sortedByDescending { it.value }.map { it.key }.toMutableList() -// val firstFood = nameScoreList[0].name -// orderList.remove(firstFood) -// orderList.add(0, firstFood) +//package com.shuwei.dish.match.objbox // -// val sortedScoreList = maxScoreList.sortedWith(compareBy { -// orderList.indexOf(it.name) -// }) - val sortedScoreList = maxScoreList.sortedBy { it.score } - logInfo("getFoodScoreList数据:${sortedScoreList.toJsonString()}") - return sortedScoreList - } - -// suspend fun queryFood(floatArray: FloatArray, queryCount: Int = DEFAULT_QUERY_COUNT): List { -// val map = mutableMapOf() -// val nameScoreList = queryFoodNameScore(floatArray, queryCount) -// nameScoreList.filter { it.score < 0.05 }.forEach { -// val count = map[it.name] ?: 0 -// map[it.name] = count + 1 -// } -// val list = map.entries.sortedByDescending { it.value }.map { it.key } -// return list -// } - - data class IdNameScore( - val id: Long, - var name: String, - val score: Double - ) - - suspend fun initDefFoodData(context: Context, action: () -> Unit = {}) { - try { - val count = ObjectBox.countIndexField(DEFAULT_FOOD_INDEX) - if (count > 0) { - return - } - val embeddingsJson = AssetsTool.readAssetsFile(context, "data/embeddings.json") - val labelsJson = AssetsTool.readAssetsFile(context, "data/labels.json") - val classInfoJson = AssetsTool.readAssetsFile(context, "data/class_info.json") - - val embeddingsList: List> = - Gson().fromJson(embeddingsJson, object : TypeToken>>() {}.type) - val labelsList: IntArray = Gson().fromJson(labelsJson, IntArray::class.java) - val classInfo: FoodClassInfo = - Gson().fromJson(classInfoJson, FoodClassInfo::class.java) - - val foodMap = classInfo.idx_to_class - val list = mutableListOf() - val size = embeddingsList.size - val size2 = labelsList.size - val size3 = foodMap.size - logInfo("initDefFoodData: $size,$size2,$size3") - embeddingsList.forEachIndexed { index, floatList -> - val classIdx = labelsList[index] - val foodName = foodMap["$classIdx"] - val array = floatList.toFloatArray() - // ObjectBox.boxStore.runInTx { - // box.put(Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX)) - // } - val food = Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX) - list.add(food) - } -// val tempList = list.chunked(10) - ObjectBox.putAll(list) - action() - } catch (e: Exception) { - e.printStackTrace() - logInfo("initDefFoodData: ---${e.message}--") - } - } - - - /** - * ,此方法的主要目的是:从assets 拷贝到 app的cache目录 - * @param context - * @param fileName - * @return 例如是这样:/data/user/0/com.frizzle.pluginhookandroid9/cache/plugin-debug.apk - * - * 不可能反正SD - */ -// fun copyAssetToCache(context: Context, fileName: String): String? { -// // 此app的缓存目录 --> 会默认在 cache目录...,可以自己去看看哦 -// val cacheDir = context.getCacheDir() -// if (!cacheDir.exists()) { -// cacheDir.mkdirs() // TODO 如果没有缓存目录,就创建 -// } -// val outPath = File(cacheDir, fileName) // TODO 创建输出的文件位置 -// if (outPath.exists()) { -// outPath.delete() // TODO 如果该文件已经存在,就删掉 -// } -// var `is`: InputStream? = null // 读取 -// var fos: FileOutputStream? = null // 写入 -// try { -// // 创建文件,如果创建成功,就返回true -// val res = outPath.createNewFile() -// if (res) { -// `is` = context.getAssets().open(fileName) // 拿到main/assets目录的输入流,用于读取字节 -// fos = FileOutputStream(outPath) // 读取出来的字节最终写到outPath -// val buf = ByteArray(`is`.available()) // 缓存区 -// var byteCount: Int +//import android.content.Context +//import android.graphics.Bitmap +//import android.util.Log +//import androidx.core.graphics.scale +//import com.google.gson.Gson +//import com.google.gson.reflect.TypeToken +//import com.shuwei.dish.match.utils.AssetsTool +//import com.shuwei.dish.match.utils.LogSaveUtil +//import com.shuwei.dish.match.utils.ext.toJsonString +//import kotlinx.coroutines.Dispatchers +//import kotlinx.coroutines.withContext +//import org.pytorch.IValue +//import org.pytorch.Module +//import org.pytorch.torchvision.TensorImageUtils +//import java.io.File +//import java.io.FileOutputStream +//import java.io.IOException +//import java.io.InputStream // -// // 开始循环读取 -// while ((`is`.read(buf).also { byteCount = it }) != -1) { -// fos.write(buf, 0, byteCount) -// } -// return outPath.getAbsolutePath() +// +//object FoodModule { +// +// private lateinit var module_mobile: Module +// +// private val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f) +// private val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f) +// +// // 1. 定义你的模型固定输入尺寸 (根据你的tflite模型修改,比如224x224) +// private const val MODEL_INPUT_WIDTH = 300 +// private const val MODEL_INPUT_HEIGHT = 300 +// const val DEFAULT_FOOD_INDEX = -1 +// const val DEFAULT_QUERY_COUNT = 50 +// +// const val BAG_RATE = 0.05 +// +// suspend fun init(context: Context) { +// withContext(Dispatchers.IO) { +// module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt")) +// initDefFoodData(context) +// } +// } +// +//// fun uri2FloatArray(uri: Uri): FloatArray? { +//// return MyApp.instance?.let { context -> +//// ImageUtil.uriToBitmap(context, uri)?.let { +//// bitmap2FloatArray(it) +//// } +//// } +//// } +// +// fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? { +// var rgb565Bitmap: Bitmap? = null +// try { +// val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT) +// if (isRecycle) { +// originBitmap.recycle() // } -// } catch (e: IOException) { +// rgb565Bitmap = scaledBitmap.copy(Bitmap.Config.RGB_565, false) +// scaledBitmap.recycle() +// val inputTensor = TensorImageUtils.bitmapToFloat32Tensor( +// rgb565Bitmap, +// NO_MEAN_RGB, // [0.485, 0.456, 0.406] TORCHVISION_NORM_MEAN_RGB +// NO_STD_RGB // [0.229, 0.224, 0.225] TORCHVISION_NORM_STD_RGB +// ) +// val outputTensor = module_mobile.forward(IValue.from(inputTensor)).toTensor() +// return outputTensor.dataAsFloatArray +// } catch (e: OutOfMemoryError) { // e.printStackTrace() +// logInfo("bitmap2FloatArray异常:${e.message}") // } finally { -// try { -// // TODO 一定要记得关闭资源,为了不去性能的磨损 -// fos?.flush() -// `is`?.close() -// fos?.close() -// } catch (e: IOException) { -// e.printStackTrace() +// if (rgb565Bitmap != null && rgb565Bitmap.isRecycled.not()) { +// rgb565Bitmap.recycle() // } +// //System.gc() +// //System.runFinalization() // } // return null // } - - fun copyAssetToCache(context: Context, fileName: String): String? { - val cacheFile = File(context.cacheDir, fileName) - val buffer = ByteArray(8 * 1024) - var inputStream: InputStream? = null - var outputStream: FileOutputStream? = null - - try { - inputStream = context.assets.open(fileName) - outputStream = FileOutputStream(cacheFile) - var byteCount: Int - while (inputStream.read(buffer).also { byteCount = it } != -1) { - outputStream.write(buffer, 0, byteCount) - } - outputStream.channel.force(true) // 强制物理落盘,比flush更彻底 - return cacheFile.absolutePath - } catch (e: IOException) { - e.printStackTrace() - logInfo("文件拷贝失败 fileName=$fileName, error=${e.message}") - // 拷贝失败时删除残缺文件,避免下次读取到损坏文件 - if (cacheFile.exists()) { - cacheFile.delete() - } - } finally { - outputStream?.close() - inputStream?.close() - } - return null - } - - private fun logInfo(msg:String) { - Log.d("FoodModule", msg) - LogSaveUtil.saveLogFile(msg) - } - -} \ No newline at end of file +// +//// fun queryFood(uri: Uri, queryCount: Int = DEFAULT_QUERY_COUNT): List? { +//// return uri2FloatArray(uri)?.let { +//// queryFood(it, queryCount) +//// } +//// } +// +//// /** +//// * 返回识别物品名称列表 +//// */ +//// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List { +//// val floatArray = bitmap2FloatArray(bitmap) +//// return queryFood(floatArray, queryCount) +//// } +// +//// /** +//// * 返回识别物品IdNameScore对象列表 +//// */ +//// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List { +//// val floatArray = bitmap2FloatArray(bitmap) +//// return queryFoodNameScore(floatArray, queryCount) +//// } +// +// suspend fun queryFoodNameScore( +// floatArray: FloatArray?, +// queryCount: Int = DEFAULT_QUERY_COUNT +// ): List { +// if (floatArray == null) return emptyList() +//// val query: Query = box.query(Food_.foodVector.nearestNeighbors(floatArray, queryCount)).build() +//// //查询比较分数 +////// val tempList = query.findWithScores().sortedBy { it.score }.map { "${it.get().name}|${it.get().foodIdx}|${it.score}" } +//// var idScoreList: List +//// try { +//// idScoreList = query.findIdsWithScores(); +//// } finally { +//// // 先关闭Query,释放Cursor +//// query.close() +//// } +// +// logInfo("queryFood向量:${floatArray.slice(0 until 50).toJsonString()}") +// val idScoreList = ObjectBox.query(floatArray, queryCount) +// // 批量加载所有实体,一次DB操作替代循环单条查询 +// val foodMap = ObjectBox.getByIds(idScoreList.map { it.id }).associateBy { it.id } +// val nameScoreList = mutableListOf() +// idScoreList.forEach { +// val name = foodMap[it.id]?.name ?: "" +// nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score)) +// } +// logInfo("queryFood数据:${nameScoreList.toJsonString()}") +// return nameScoreList +// } +// +// suspend fun getFoodScoreList(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List { +// val floatArray = bitmap2FloatArray(bitmap, false) ?: return emptyList() +// logInfo("getFoodScoreList向量:${floatArray.toJsonString()}") +// val nameScoreList = queryFoodNameScore(floatArray, queryCount) +// if (nameScoreList.isEmpty()) { +// return emptyList() +// } +// //暂时不过滤 +// val maxScoreList = nameScoreList +// //.filter { it.score < 0.15 } +// .groupBy { it.name } +// .map { (_, value) -> value.minByOrNull { it.score }!! } +// .toMutableList() +//// val map = mutableMapOf() +//// nameScoreList.forEach { +//// val key = it.name +//// val count = map[key] ?: 0 +//// map[key] = count + 1 +//// } +//// val orderList = map.entries.sortedByDescending { it.value }.map { it.key }.toMutableList() +//// val firstFood = nameScoreList[0].name +//// orderList.remove(firstFood) +//// orderList.add(0, firstFood) +//// +//// val sortedScoreList = maxScoreList.sortedWith(compareBy { +//// orderList.indexOf(it.name) +//// }) +// val sortedScoreList = maxScoreList.sortedBy { it.score } +// logInfo("getFoodScoreList数据:${sortedScoreList.toJsonString()}") +// return sortedScoreList +// } +// +//// suspend fun queryFood(floatArray: FloatArray, queryCount: Int = DEFAULT_QUERY_COUNT): List { +//// val map = mutableMapOf() +//// val nameScoreList = queryFoodNameScore(floatArray, queryCount) +//// nameScoreList.filter { it.score < 0.05 }.forEach { +//// val count = map[it.name] ?: 0 +//// map[it.name] = count + 1 +//// } +//// val list = map.entries.sortedByDescending { it.value }.map { it.key } +//// return list +//// } +// +// data class IdNameScore( +// val id: Long, +// var name: String, +// val score: Double +// ) +// +// suspend fun initDefFoodData(context: Context, action: () -> Unit = {}) { +// try { +// val count = ObjectBox.countIndexField(DEFAULT_FOOD_INDEX) +// if (count > 0) { +// return +// } +// val embeddingsJson = AssetsTool.readAssetsFile(context, "data/embeddings.json") +// val labelsJson = AssetsTool.readAssetsFile(context, "data/labels.json") +// val classInfoJson = AssetsTool.readAssetsFile(context, "data/class_info.json") +// +// val embeddingsList: List> = +// Gson().fromJson(embeddingsJson, object : TypeToken>>() {}.type) +// val labelsList: IntArray = Gson().fromJson(labelsJson, IntArray::class.java) +// val classInfo: FoodClassInfo = +// Gson().fromJson(classInfoJson, FoodClassInfo::class.java) +// +// val foodMap = classInfo.idx_to_class +// val list = mutableListOf() +// val size = embeddingsList.size +// val size2 = labelsList.size +// val size3 = foodMap.size +// logInfo("initDefFoodData: $size,$size2,$size3") +// embeddingsList.forEachIndexed { index, floatList -> +// val classIdx = labelsList[index] +// val foodName = foodMap["$classIdx"] +// val array = floatList.toFloatArray() +// // ObjectBox.boxStore.runInTx { +// // box.put(Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX)) +// // } +// val food = Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX) +// list.add(food) +// } +//// val tempList = list.chunked(10) +// ObjectBox.putAll(list) +// action() +// } catch (e: Exception) { +// e.printStackTrace() +// logInfo("initDefFoodData: ---${e.message}--") +// } +// } +// +// +// /** +// * ,此方法的主要目的是:从assets 拷贝到 app的cache目录 +// * @param context +// * @param fileName +// * @return 例如是这样:/data/user/0/com.frizzle.pluginhookandroid9/cache/plugin-debug.apk +// * +// * 不可能反正SD +// */ +//// fun copyAssetToCache(context: Context, fileName: String): String? { +//// // 此app的缓存目录 --> 会默认在 cache目录...,可以自己去看看哦 +//// val cacheDir = context.getCacheDir() +//// if (!cacheDir.exists()) { +//// cacheDir.mkdirs() // TODO 如果没有缓存目录,就创建 +//// } +//// val outPath = File(cacheDir, fileName) // TODO 创建输出的文件位置 +//// if (outPath.exists()) { +//// outPath.delete() // TODO 如果该文件已经存在,就删掉 +//// } +//// var `is`: InputStream? = null // 读取 +//// var fos: FileOutputStream? = null // 写入 +//// try { +//// // 创建文件,如果创建成功,就返回true +//// val res = outPath.createNewFile() +//// if (res) { +//// `is` = context.getAssets().open(fileName) // 拿到main/assets目录的输入流,用于读取字节 +//// fos = FileOutputStream(outPath) // 读取出来的字节最终写到outPath +//// val buf = ByteArray(`is`.available()) // 缓存区 +//// var byteCount: Int +//// +//// // 开始循环读取 +//// while ((`is`.read(buf).also { byteCount = it }) != -1) { +//// fos.write(buf, 0, byteCount) +//// } +//// return outPath.getAbsolutePath() +//// } +//// } catch (e: IOException) { +//// e.printStackTrace() +//// } finally { +//// try { +//// // TODO 一定要记得关闭资源,为了不去性能的磨损 +//// fos?.flush() +//// `is`?.close() +//// fos?.close() +//// } catch (e: IOException) { +//// e.printStackTrace() +//// } +//// } +//// return null +//// } +// +// fun copyAssetToCache(context: Context, fileName: String): String? { +// val cacheFile = File(context.cacheDir, fileName) +// val buffer = ByteArray(8 * 1024) +// var inputStream: InputStream? = null +// var outputStream: FileOutputStream? = null +// +// try { +// inputStream = context.assets.open(fileName) +// outputStream = FileOutputStream(cacheFile) +// var byteCount: Int +// while (inputStream.read(buffer).also { byteCount = it } != -1) { +// outputStream.write(buffer, 0, byteCount) +// } +// outputStream.channel.force(true) // 强制物理落盘,比flush更彻底 +// return cacheFile.absolutePath +// } catch (e: IOException) { +// e.printStackTrace() +// logInfo("文件拷贝失败 fileName=$fileName, error=${e.message}") +// // 拷贝失败时删除残缺文件,避免下次读取到损坏文件 +// if (cacheFile.exists()) { +// cacheFile.delete() +// } +// } finally { +// outputStream?.close() +// inputStream?.close() +// } +// return null +// } +// +// private fun logInfo(msg:String) { +// Log.d("FoodModule", msg) +// LogSaveUtil.saveLogFile(msg) +// } +// +//} \ No newline at end of file diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt index 4f097a5..4e25194 100644 --- a/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt @@ -31,9 +31,11 @@ object ScaleServiceManager { private var mdnsRegister: MdnsRegisterManager? = null private var wsServer: ScaleWebSocketServer? = null + private var udpSender: UdpBroadcastSender? = null // 以下仅主设备使用 private var mdnsDiscovery: MdnsDiscoveryManager? = null + private var udpReceiver: UdpBroadcastReceiver? = null private var wsClient: ScaleWebSocketClient? = null private var aggregator: ScaleDataAggregator? = null @@ -66,6 +68,10 @@ object ScaleServiceManager { if (role == DeviceRole.MASTER) { startMasterServices(context, deviceId) + } else { + // 子设备:启动 UDP 广播,作为 mDNS 的兜底发现机制 + val localIp = getLocalIp(context) + udpSender = UdpBroadcastSender(deviceId, localIp).also { it.start() } } } @@ -93,24 +99,35 @@ object ScaleServiceManager { } wsClient = client + // 统一的设备发现处理:mDNS 和 UDP 共用同一逻辑 + fun onDeviceFound(remoteId: String, host: String, port: Int) { + if (remoteId == deviceId) return + Log.d(TAG, "发现子设备 $remoteId ($host:$port), 建立连接") + aggregator?.setDeviceIp(remoteId, host) + client.connect(remoteId, host, port) + } + // mDNS 发现:发现子设备 → 建立 WebSocket 连接;设备离线 → 断开连接并清除数据 mdnsDiscovery = MdnsDiscoveryManager(context).also { discovery -> discovery.onDeviceFound = { remoteId, host, port -> - // 不连接自身 - if (remoteId != deviceId) { - Log.d(TAG, "发现子设备 $remoteId, 建立连接") - // 记录子设备 IP,供 UI 展示使用 - aggregator?.setDeviceIp(remoteId, host) - client.connect(remoteId, host, port) - } + onDeviceFound(remoteId, host, port) } discovery.onDeviceLost = { remoteId -> Log.d(TAG, "子设备离线 $remoteId, 断开连接并清除数据") client.disconnect(remoteId) aggregator?.removeDevice(remoteId) + udpReceiver?.removeDevice(remoteId) } discovery.startDiscovery() } + + // UDP 广播接收:作为 mDNS 的兜底,发现 mDNS 未能触发的子设备 + udpReceiver = UdpBroadcastReceiver().also { receiver -> + receiver.onDeviceFound = { remoteId, host, port -> + onDeviceFound(remoteId, host, port) + } + receiver.start() + } } /** @@ -118,12 +135,16 @@ object ScaleServiceManager { */ fun stop() { mdnsDiscovery?.stopDiscovery() + udpReceiver?.stop() + udpSender?.stop() wsClient?.shutdown() aggregator?.stop() wsServer?.stop() mdnsRegister?.unregister() mdnsDiscovery = null + udpReceiver = null + udpSender = null wsClient = null aggregator = null wsServer = null diff --git a/app/src/main/java/com/shuwei/dish/match/scale/UdpBroadcastReceiver.kt b/app/src/main/java/com/shuwei/dish/match/scale/UdpBroadcastReceiver.kt new file mode 100644 index 0000000..379e6a3 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/scale/UdpBroadcastReceiver.kt @@ -0,0 +1,95 @@ +package com.shuwei.dish.match.scale + +import android.util.Log +import com.google.gson.Gson +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * UDP 广播接收器(主设备运行) + * 监听子设备发来的 UDP 广播包,解析后通过回调通知连接管理器 + * 与 mDNS 互为备份,任意一条路发现子设备均可建立 WebSocket 连接 + */ +class UdpBroadcastReceiver { + + companion object { + private const val TAG = "UdpBroadcastReceiver" + private const val BUFFER_SIZE = 1024 + } + + private val gson = Gson() + private val executor = Executors.newSingleThreadExecutor() + + /** 已发现的设备集合,避免重复触发回调,key=deviceId, value="host:port" */ + private val knownDevices = ConcurrentHashMap() + + @Volatile + private var running = false + private var socket: DatagramSocket? = null + + /** + * 发现新设备时的回调:(deviceId, host, port) + * 与 MdnsDiscoveryManager.onDeviceFound 签名一致,可共用同一处理逻辑 + */ + var onDeviceFound: ((deviceId: String, host: String, port: Int) -> Unit)? = null + + /** + * 开始监听 UDP 广播 + */ + fun start() { + if (running) return + running = true + executor.execute { listenLoop() } + Log.d(TAG, "UDP 广播接收已启动, 监听端口=${UdpBroadcastSender.BROADCAST_PORT}") + } + + private fun listenLoop() { + try { + socket = DatagramSocket(UdpBroadcastSender.BROADCAST_PORT) + val buf = ByteArray(BUFFER_SIZE) + while (running) { + val packet = DatagramPacket(buf, buf.size) + socket?.receive(packet) ?: break + val json = String(packet.data, 0, packet.length, Charsets.UTF_8) + handlePacket(json) + } + } catch (e: Exception) { + if (running) Log.w(TAG, "UDP 接收异常: ${e.message}") + } finally { + socket?.close() + socket = null + } + } + + private fun handlePacket(json: String) { + try { + val payload = gson.fromJson(json, UdpBroadcastSender.Payload::class.java) + val key = "${payload.ip}:${payload.port}" + // 同一设备已通知过则跳过,避免每5秒重复触发连接 + if (knownDevices.put(payload.deviceId, key) == key) return + Log.d(TAG, "UDP 发现设备: ${payload.deviceId} -> ${payload.ip}:${payload.port}") + onDeviceFound?.invoke(payload.deviceId, payload.ip, payload.port) + } catch (e: Exception) { + Log.w(TAG, "解析 UDP 广播包失败: ${e.message}") + } + } + + /** + * 移除已知设备记录,设备离线时调用,确保设备重新上线后能再次触发回调 + */ + fun removeDevice(deviceId: String) { + knownDevices.remove(deviceId) + } + + /** + * 停止监听,释放资源 + */ + fun stop() { + running = false + socket?.close() + executor.shutdownNow() + Log.d(TAG, "UDP 广播接收已停止") + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/scale/UdpBroadcastSender.kt b/app/src/main/java/com/shuwei/dish/match/scale/UdpBroadcastSender.kt new file mode 100644 index 0000000..0eab2d6 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/scale/UdpBroadcastSender.kt @@ -0,0 +1,76 @@ +package com.shuwei.dish.match.scale + +import android.util.Log +import com.google.gson.Gson +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * UDP 广播发送器(子设备运行) + * 每隔固定间隔向局域网广播本机的 deviceId、IP、WebSocket 端口 + * 作为 mDNS 的兜底发现机制,确保主设备能可靠发现子设备 + */ +class UdpBroadcastSender( + private val deviceId: String, + private val localIp: String, + private val wsPort: Int = MdnsRegisterManager.WS_PORT +) { + + companion object { + private const val TAG = "UdpBroadcastSender" + /** UDP 广播目标端口,与 UdpBroadcastReceiver 保持一致 */ + const val BROADCAST_PORT = 8766 + /** 广播间隔(秒) */ + private const val INTERVAL_SEC = 5L + } + + private val gson = Gson() + private val scheduler = Executors.newSingleThreadScheduledExecutor() + private var task: ScheduledFuture<*>? = null + + /** 广播数据包结构 */ + data class Payload(val deviceId: String, val ip: String, val port: Int) + + /** + * 开始定时广播 + */ + fun start() { + if (task != null) return + task = scheduler.scheduleWithFixedDelay({ + sendBroadcast() + }, 0, INTERVAL_SEC, TimeUnit.SECONDS) + Log.d(TAG, "UDP 广播已启动, deviceId=$deviceId, ip=$localIp, port=$wsPort") + } + + private fun sendBroadcast() { + try { + val payload = gson.toJson(Payload(deviceId, localIp, wsPort)) + val data = payload.toByteArray(Charsets.UTF_8) + DatagramSocket().use { socket -> + socket.broadcast = true + val packet = DatagramPacket( + data, data.size, + InetAddress.getByName("255.255.255.255"), + BROADCAST_PORT + ) + socket.send(packet) + } + } catch (e: Exception) { + Log.w(TAG, "UDP 广播发送失败: ${e.message}") + } + } + + /** + * 停止广播,释放资源 + */ + fun stop() { + task?.cancel(false) + task = null + scheduler.shutdownNow() + Log.d(TAG, "UDP 广播已停止") + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt index cc5bfe7..91ae3d1 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt @@ -12,7 +12,6 @@ import com.shuwei.dish.match.base.GlobalData import com.shuwei.dish.match.databinding.ActivityInitBinding import com.shuwei.dish.match.db.AppRepository import com.shuwei.dish.match.http.UrlConfig -import com.shuwei.dish.match.objbox.FoodModule import com.shuwei.dish.match.utils.AppUtil import com.shuwei.dish.match.utils.QRCodeUtil import com.shuwei.dish.match.utils.SpTool @@ -42,9 +41,9 @@ class InitActivity : BaseActivity() { // useBackground(false) binding = ActivityInitBinding.inflate(layoutInflater) setContentView(binding.root) - lifecycleScope.launch { - FoodModule.init(this@InitActivity) - } +// lifecycleScope.launch { +// FoodModule.init(this@InitActivity) +// } // setHeaderBackground(isHomePage = true) setHeaderBgVisible(false) BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString() diff --git a/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt b/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt index 25177cc..ffcf1c4 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt @@ -17,7 +17,6 @@ import com.shuwei.dish.match.base.BaseFragment import com.shuwei.dish.match.databinding.FragmentCollectBinding import com.shuwei.dish.match.dialog.Loading import com.shuwei.dish.match.objbox.FoodCollectionBean -import com.shuwei.dish.match.objbox.FoodModule import com.shuwei.dish.match.ui.SettingActivity import com.shuwei.dish.match.utils.BitmapSaver import com.shuwei.dish.match.utils.Debouncer @@ -120,38 +119,38 @@ class CollectFragment : BaseFragment() { } private fun getImageVector(index: Int, bitmap: Bitmap) { -// val bitmap = BitmapCropper.cropCenter( -// original = srcBmp, -// targetWidth = 900, targetHeight = 900, -//// offsetX = 30, offsetY = 100 +//// val bitmap = BitmapCropper.cropCenter( +//// original = srcBmp, +//// targetWidth = 900, targetHeight = 900, +////// offsetX = 30, offsetY = 100 +//// ) +// val imageVector = try { +// FoodModule.bitmap2FloatArray(bitmap, false) +// } catch (e: Exception) { +// e.printStackTrace() +// toast("操作失败") +// log("操作失败:${e.message}") +// hideWaitingDialog() +// return +// } +// val file = BitmapSaver.saveToAppFilesDir( +// bitmap, requireActivity(), "IMG_CROP_${System.currentTimeMillis()}.jpg" // ) - val imageVector = try { - FoodModule.bitmap2FloatArray(bitmap, false) - } catch (e: Exception) { - e.printStackTrace() - toast("操作失败") - log("操作失败:${e.message}") - hideWaitingDialog() - return - } - val file = BitmapSaver.saveToAppFilesDir( - bitmap, requireActivity(), "IMG_CROP_${System.currentTimeMillis()}.jpg" - ) - log("${this.javaClass.simpleName}-cameraCallback-裁剪bitmap保存文件路径:${file?.absolutePath}") - - activity?.runOnUiThread { - foodCollectionList[index].let { - it.imageVector = imageVector - it.bitmap = null - it.isShowCamera = false - it.imageFile = file - } - collectionAdapter.notifyItemChanged(index) - } - if (bitmap.isRecycled.not()) { - bitmap.recycle() - } - hideWaitingDialog() +// log("${this.javaClass.simpleName}-cameraCallback-裁剪bitmap保存文件路径:${file?.absolutePath}") +// +// activity?.runOnUiThread { +// foodCollectionList[index].let { +// it.imageVector = imageVector +// it.bitmap = null +// it.isShowCamera = false +// it.imageFile = file +// } +// collectionAdapter.notifyItemChanged(index) +// } +// if (bitmap.isRecycled.not()) { +// bitmap.recycle() +// } +// hideWaitingDialog() } @SuppressLint("NotifyDataSetChanged") diff --git a/app/src/main/java/com/shuwei/dish/match/utils/BootReceiver.kt b/app/src/main/java/com/shuwei/dish/match/utils/BootReceiver.kt index a2a5ee3..35f6830 100644 --- a/app/src/main/java/com/shuwei/dish/match/utils/BootReceiver.kt +++ b/app/src/main/java/com/shuwei/dish/match/utils/BootReceiver.kt @@ -3,25 +3,15 @@ package com.shuwei.dish.match.utils import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import com.shuwei.dish.match.ui.InitActivity class BootReceiver : BroadcastReceiver() { - - companion object { - /** 兼容多种设备的开机广播 action 集合 */ - private val BOOT_ACTIONS = setOf( - Intent.ACTION_BOOT_COMPLETED, - "android.intent.action.QUICKBOOT_POWERON", - "android.intent.action.LOCKED_BOOT_COMPLETED" - ) - } - override fun onReceive(context: Context, intent: Intent) { - val action = intent.action ?: return - if (action in BOOT_ACTIONS) { - // Android 10+ 禁止在后台直接启动 Activity - // 改为启动前台服务,由 BootService 负责拉起 InitActivity - val serviceIntent = Intent(context, BootService::class.java) - context.startForegroundService(serviceIntent) + if (Intent.ACTION_BOOT_COMPLETED == intent.action) { + // 启动服务或Activity + val launchIntent = Intent(context, InitActivity::class.java) + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(launchIntent) } } } diff --git a/app/src/main/java/com/shuwei/dish/match/utils/BootService.kt b/app/src/main/java/com/shuwei/dish/match/utils/BootService.kt deleted file mode 100644 index 7275c18..0000000 --- a/app/src/main/java/com/shuwei/dish/match/utils/BootService.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.shuwei.dish.match.utils - -import android.app.ActivityManager -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import com.shuwei.dish.match.R -import com.shuwei.dish.match.ui.InitActivity - -/** - * 开机自启中转前台服务 - * 用于绕过 Android 10+ 对后台直接启动 Activity 的限制 - * 执行流程:BootReceiver → startForegroundService() → 本服务 → setFullScreenIntent → InitActivity - */ -class BootService : Service() { - - companion object { - /** 通知渠道 ID */ - private const val CHANNEL_ID = "boot_channel" - /** 前台通知 ID */ - private const val NOTIFICATION_ID = 1001 - } - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // 若 App 进程已在前台运行(主设备系统签名场景),则不重复启动,避免闪退 - val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager - val isAppForeground = activityManager.runningAppProcesses?.any { - it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND - && it.processName == packageName - } ?: false - - // App 未运行时才设置 fullScreenIntent,由系统自动启动 Activity - // 直接调用 startActivity() 在部分 Android 10 设备上会被 ActivityTaskManager 拦截 - val pendingIntent = if (!isAppForeground) createLaunchPendingIntent() else null - - // Android 14+ startForeground 需要传入 foregroundServiceType - val notification = buildNotification(pendingIntent) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - startForeground(NOTIFICATION_ID, notification) - } - - stopSelf() - return START_NOT_STICKY - } - - /** 创建启动 InitActivity 的 PendingIntent */ - private fun createLaunchPendingIntent(): PendingIntent { - val launchIntent = Intent(this, InitActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - return PendingIntent.getActivity( - this, 0, launchIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - } - - /** - * 构建前台服务通知 - * fullScreenIntent 不为空时设置全屏意图,系统会在显示通知时自动启动 Activity - * 通知渠道必须为 IMPORTANCE_HIGH,否则 fullScreenIntent 不会触发 - */ - private fun buildNotification(fullScreenIntent: PendingIntent?): Notification { - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - val channel = NotificationChannel( - CHANNEL_ID, - "开机自启动", - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "应用开机自动启动通知渠道" - setShowBadge(false) - } - manager.createNotificationChannel(channel) - - return Notification.Builder(this, CHANNEL_ID) - .setContentTitle("正在启动应用") - .setSmallIcon(R.mipmap.ic_logo512) - .apply { - if (fullScreenIntent != null) { - setFullScreenIntent(fullScreenIntent, true) - } - } - .build() - } -} -