feat(scale): 新增 UDP 广播兜底发现机制并恢复开机自启原始方案

- 新增 UdpBroadcastSender/UdpBroadcastReceiver,子设备定时广播,主设备监听,与 mDNS 互为备份
- 恢复 BootReceiver 为直接启动 Activity 的原始方式,删除 BootService
- 移除 InitActivity 开机自启相关 Window flags
- 移除 PyTorch 依赖,缩减安装包体积
- 删除仓库中的 release apk 和 pt 模型文件
This commit is contained in:
2026-04-10 18:04:18 +08:00
parent 904ccdbb06
commit c72d30e88a
13 changed files with 542 additions and 473 deletions
+2 -2
View File
@@ -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) {
Binary file not shown.
+1 -15
View File
@@ -21,8 +21,6 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<supports-screens
android:anyDensity="true"
@@ -107,24 +105,12 @@
<receiver
android:name=".utils.BootReceiver"
android:enabled="true"
android:exported="true"
android:directBootAware="true">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- 开机自启中转前台服务,exported=false 仅允许本应用内部启动 -->
<service
android:name=".utils.BootService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync"
android:directBootAware="true" />
</application>
</manifest>
Binary file not shown.
@@ -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() {
@@ -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<String>? {
// return uri2FloatArray(uri)?.let {
// queryFood(it, queryCount)
// }
// }
// /**
// * 返回识别物品名称列表
// */
// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFood(floatArray, queryCount)
// }
// /**
// * 返回识别物品IdNameScore对象列表
// */
// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFoodNameScore(floatArray, queryCount)
// }
suspend fun queryFoodNameScore(
floatArray: FloatArray?,
queryCount: Int = DEFAULT_QUERY_COUNT
): List<IdNameScore> {
if (floatArray == null) return emptyList()
// val query: Query<Food> = 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<IdWithScore>
// 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<IdNameScore>()
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<IdNameScore> {
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<String, Int>()
// 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<String> {
// val map = mutableMapOf<String, Int>()
// 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<List<Float>> =
Gson().fromJson(embeddingsJson, object : TypeToken<List<List<Float>>>() {}.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<Food>()
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)
//
//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)
// }
// return outPath.getAbsolutePath()
// }
// } catch (e: IOException) {
// e.printStackTrace()
// } finally {
//
//// 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 {
// // TODO 一定要记得关闭资源,为了不去性能的磨损
// fos?.flush()
// `is`?.close()
// fos?.close()
// } catch (e: IOException) {
// e.printStackTrace()
// 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 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)
}
}
//
//// fun queryFood(uri: Uri, queryCount: Int = DEFAULT_QUERY_COUNT): List<String>? {
//// return uri2FloatArray(uri)?.let {
//// queryFood(it, queryCount)
//// }
//// }
//
//// /**
//// * 返回识别物品名称列表
//// */
//// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
//// val floatArray = bitmap2FloatArray(bitmap)
//// return queryFood(floatArray, queryCount)
//// }
//
//// /**
//// * 返回识别物品IdNameScore对象列表
//// */
//// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
//// val floatArray = bitmap2FloatArray(bitmap)
//// return queryFoodNameScore(floatArray, queryCount)
//// }
//
// suspend fun queryFoodNameScore(
// floatArray: FloatArray?,
// queryCount: Int = DEFAULT_QUERY_COUNT
// ): List<IdNameScore> {
// if (floatArray == null) return emptyList()
//// val query: Query<Food> = 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<IdWithScore>
//// 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<IdNameScore>()
// 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<IdNameScore> {
// 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<String, Int>()
//// 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<String> {
//// val map = mutableMapOf<String, Int>()
//// 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<List<Float>> =
// Gson().fromJson(embeddingsJson, object : TypeToken<List<List<Float>>>() {}.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<Food>()
// 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)
// }
//
//}
@@ -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 发现:发现子设备 → 建立 WebSocket 连接;设备离线 → 断开连接并清除数据
mdnsDiscovery = MdnsDiscoveryManager(context).also { discovery ->
discovery.onDeviceFound = { remoteId, host, port ->
// 不连接自身
if (remoteId != deviceId) {
Log.d(TAG, "发现子设备 $remoteId, 建立连接")
// 记录子设备 IP,供 UI 展示使用
// 统一的设备发现处理: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 ->
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
@@ -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<String, String>()
@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 广播接收已停止")
}
}
@@ -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 广播已停止")
}
}
@@ -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()
@@ -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<FragmentCollectBinding>() {
}
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")
@@ -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)
}
}
}
@@ -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()
}
}