feat(scale): 新增 UDP 广播兜底发现机制并恢复开机自启原始方案
- 新增 UdpBroadcastSender/UdpBroadcastReceiver,子设备定时广播,主设备监听,与 mDNS 互为备份 - 恢复 BootReceiver 为直接启动 Activity 的原始方式,删除 BootService - 移除 InitActivity 开机自启相关 Window flags - 移除 PyTorch 依赖,缩减安装包体积 - 删除仓库中的 release apk 和 pt 模型文件
This commit is contained in:
@@ -137,8 +137,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.camera.extensions)
|
implementation(libs.androidx.camera.extensions)
|
||||||
|
|
||||||
//pytorch
|
//pytorch
|
||||||
implementation (libs.pytorch.android)
|
// implementation (libs.pytorch.android)
|
||||||
implementation (libs.pytorch.android.torchvision)
|
// implementation (libs.pytorch.android.torchvision)
|
||||||
|
|
||||||
// objectbox
|
// objectbox
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
|
|||||||
Binary file not shown.
@@ -21,8 +21,6 @@
|
|||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<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
|
<supports-screens
|
||||||
android:anyDensity="true"
|
android:anyDensity="true"
|
||||||
@@ -107,24 +105,12 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".utils.BootReceiver"
|
android:name=".utils.BootReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="true"
|
android:exported="true">
|
||||||
android:directBootAware="true">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
<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>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- 开机自启中转前台服务,exported=false 仅允许本应用内部启动 -->
|
|
||||||
<service
|
|
||||||
android:name=".utils.BootService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
android:directBootAware="true" />
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
Binary file not shown.
@@ -8,8 +8,6 @@ import android.content.SharedPreferences
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.shuwei.dish.match.R
|
import com.shuwei.dish.match.R
|
||||||
import com.shuwei.dish.match.db.DatabaseProvider
|
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.scale.ScaleServiceManager
|
||||||
import com.shuwei.dish.match.ui.InitActivity.Companion.TAG
|
import com.shuwei.dish.match.ui.InitActivity.Companion.TAG
|
||||||
import com.shuwei.dish.match.utils.AppUtil
|
import com.shuwei.dish.match.utils.AppUtil
|
||||||
@@ -42,7 +40,7 @@ class BaseApp : Application() {
|
|||||||
// GlobalData.deviceId = "39a7abdd06b3c7ab"
|
// GlobalData.deviceId = "39a7abdd06b3c7ab"
|
||||||
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
|
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
|
||||||
registerReceiver(BootReceiver(), filter)
|
registerReceiver(BootReceiver(), filter)
|
||||||
ObjectBox.init(this)
|
// ObjectBox.init(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
|
|||||||
@@ -1,308 +1,308 @@
|
|||||||
package com.shuwei.dish.match.objbox
|
//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)
|
|
||||||
//
|
//
|
||||||
// val sortedScoreList = maxScoreList.sortedWith(compareBy {
|
//import android.content.Context
|
||||||
// orderList.indexOf(it.name)
|
//import android.graphics.Bitmap
|
||||||
// })
|
//import android.util.Log
|
||||||
val sortedScoreList = maxScoreList.sortedBy { it.score }
|
//import androidx.core.graphics.scale
|
||||||
logInfo("getFoodScoreList数据:${sortedScoreList.toJsonString()}")
|
//import com.google.gson.Gson
|
||||||
return sortedScoreList
|
//import com.google.gson.reflect.TypeToken
|
||||||
}
|
//import com.shuwei.dish.match.utils.AssetsTool
|
||||||
|
//import com.shuwei.dish.match.utils.LogSaveUtil
|
||||||
// suspend fun queryFood(floatArray: FloatArray, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
|
//import com.shuwei.dish.match.utils.ext.toJsonString
|
||||||
// val map = mutableMapOf<String, Int>()
|
//import kotlinx.coroutines.Dispatchers
|
||||||
// val nameScoreList = queryFoodNameScore(floatArray, queryCount)
|
//import kotlinx.coroutines.withContext
|
||||||
// nameScoreList.filter { it.score < 0.05 }.forEach {
|
//import org.pytorch.IValue
|
||||||
// val count = map[it.name] ?: 0
|
//import org.pytorch.Module
|
||||||
// map[it.name] = count + 1
|
//import org.pytorch.torchvision.TensorImageUtils
|
||||||
// }
|
//import java.io.File
|
||||||
// val list = map.entries.sortedByDescending { it.value }.map { it.key }
|
//import java.io.FileOutputStream
|
||||||
// return list
|
//import java.io.IOException
|
||||||
// }
|
//import java.io.InputStream
|
||||||
|
|
||||||
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) {
|
//object FoodModule {
|
||||||
// fos.write(buf, 0, byteCount)
|
//
|
||||||
|
// 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()
|
//// fun uri2FloatArray(uri: Uri): FloatArray? {
|
||||||
// } finally {
|
//// return MyApp.instance?.let { context ->
|
||||||
|
//// ImageUtil.uriToBitmap(context, uri)?.let {
|
||||||
|
//// bitmap2FloatArray(it)
|
||||||
|
//// }
|
||||||
|
//// }
|
||||||
|
//// }
|
||||||
|
//
|
||||||
|
// fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? {
|
||||||
|
// var rgb565Bitmap: Bitmap? = null
|
||||||
// try {
|
// try {
|
||||||
// // TODO 一定要记得关闭资源,为了不去性能的磨损
|
// val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT)
|
||||||
// fos?.flush()
|
// if (isRecycle) {
|
||||||
// `is`?.close()
|
// originBitmap.recycle()
|
||||||
// fos?.close()
|
|
||||||
// } catch (e: IOException) {
|
|
||||||
// e.printStackTrace()
|
|
||||||
// }
|
// }
|
||||||
|
// 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
|
// return null
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
fun copyAssetToCache(context: Context, fileName: String): String? {
|
//// fun queryFood(uri: Uri, queryCount: Int = DEFAULT_QUERY_COUNT): List<String>? {
|
||||||
val cacheFile = File(context.cacheDir, fileName)
|
//// return uri2FloatArray(uri)?.let {
|
||||||
val buffer = ByteArray(8 * 1024)
|
//// queryFood(it, queryCount)
|
||||||
var inputStream: InputStream? = null
|
//// }
|
||||||
var outputStream: FileOutputStream? = null
|
//// }
|
||||||
|
//
|
||||||
try {
|
//// /**
|
||||||
inputStream = context.assets.open(fileName)
|
//// * 返回识别物品名称列表
|
||||||
outputStream = FileOutputStream(cacheFile)
|
//// */
|
||||||
var byteCount: Int
|
//// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
|
||||||
while (inputStream.read(buffer).also { byteCount = it } != -1) {
|
//// val floatArray = bitmap2FloatArray(bitmap)
|
||||||
outputStream.write(buffer, 0, byteCount)
|
//// return queryFood(floatArray, queryCount)
|
||||||
}
|
//// }
|
||||||
outputStream.channel.force(true) // 强制物理落盘,比flush更彻底
|
//
|
||||||
return cacheFile.absolutePath
|
//// /**
|
||||||
} catch (e: IOException) {
|
//// * 返回识别物品IdNameScore对象列表
|
||||||
e.printStackTrace()
|
//// */
|
||||||
logInfo("文件拷贝失败 fileName=$fileName, error=${e.message}")
|
//// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
|
||||||
// 拷贝失败时删除残缺文件,避免下次读取到损坏文件
|
//// val floatArray = bitmap2FloatArray(bitmap)
|
||||||
if (cacheFile.exists()) {
|
//// return queryFoodNameScore(floatArray, queryCount)
|
||||||
cacheFile.delete()
|
//// }
|
||||||
}
|
//
|
||||||
} finally {
|
// suspend fun queryFoodNameScore(
|
||||||
outputStream?.close()
|
// floatArray: FloatArray?,
|
||||||
inputStream?.close()
|
// queryCount: Int = DEFAULT_QUERY_COUNT
|
||||||
}
|
// ): List<IdNameScore> {
|
||||||
return null
|
// if (floatArray == null) return emptyList()
|
||||||
}
|
//// val query: Query<Food> = box.query(Food_.foodVector.nearestNeighbors(floatArray, queryCount)).build()
|
||||||
|
//// //查询比较分数
|
||||||
private fun logInfo(msg:String) {
|
////// val tempList = query.findWithScores().sortedBy { it.score }.map { "${it.get().name}|${it.get().foodIdx}|${it.score}" }
|
||||||
Log.d("FoodModule", msg)
|
//// var idScoreList: List<IdWithScore>
|
||||||
LogSaveUtil.saveLogFile(msg)
|
//// 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 mdnsRegister: MdnsRegisterManager? = null
|
||||||
private var wsServer: ScaleWebSocketServer? = null
|
private var wsServer: ScaleWebSocketServer? = null
|
||||||
|
private var udpSender: UdpBroadcastSender? = null
|
||||||
|
|
||||||
// 以下仅主设备使用
|
// 以下仅主设备使用
|
||||||
private var mdnsDiscovery: MdnsDiscoveryManager? = null
|
private var mdnsDiscovery: MdnsDiscoveryManager? = null
|
||||||
|
private var udpReceiver: UdpBroadcastReceiver? = null
|
||||||
private var wsClient: ScaleWebSocketClient? = null
|
private var wsClient: ScaleWebSocketClient? = null
|
||||||
private var aggregator: ScaleDataAggregator? = null
|
private var aggregator: ScaleDataAggregator? = null
|
||||||
|
|
||||||
@@ -66,6 +68,10 @@ object ScaleServiceManager {
|
|||||||
|
|
||||||
if (role == DeviceRole.MASTER) {
|
if (role == DeviceRole.MASTER) {
|
||||||
startMasterServices(context, deviceId)
|
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
|
wsClient = client
|
||||||
|
|
||||||
// mDNS 发现:发现子设备 → 建立 WebSocket 连接;设备离线 → 断开连接并清除数据
|
// 统一的设备发现处理:mDNS 和 UDP 共用同一逻辑
|
||||||
mdnsDiscovery = MdnsDiscoveryManager(context).also { discovery ->
|
fun onDeviceFound(remoteId: String, host: String, port: Int) {
|
||||||
discovery.onDeviceFound = { remoteId, host, port ->
|
if (remoteId == deviceId) return
|
||||||
// 不连接自身
|
Log.d(TAG, "发现子设备 $remoteId ($host:$port), 建立连接")
|
||||||
if (remoteId != deviceId) {
|
|
||||||
Log.d(TAG, "发现子设备 $remoteId, 建立连接")
|
|
||||||
// 记录子设备 IP,供 UI 展示使用
|
|
||||||
aggregator?.setDeviceIp(remoteId, host)
|
aggregator?.setDeviceIp(remoteId, host)
|
||||||
client.connect(remoteId, host, port)
|
client.connect(remoteId, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mDNS 发现:发现子设备 → 建立 WebSocket 连接;设备离线 → 断开连接并清除数据
|
||||||
|
mdnsDiscovery = MdnsDiscoveryManager(context).also { discovery ->
|
||||||
|
discovery.onDeviceFound = { remoteId, host, port ->
|
||||||
|
onDeviceFound(remoteId, host, port)
|
||||||
}
|
}
|
||||||
discovery.onDeviceLost = { remoteId ->
|
discovery.onDeviceLost = { remoteId ->
|
||||||
Log.d(TAG, "子设备离线 $remoteId, 断开连接并清除数据")
|
Log.d(TAG, "子设备离线 $remoteId, 断开连接并清除数据")
|
||||||
client.disconnect(remoteId)
|
client.disconnect(remoteId)
|
||||||
aggregator?.removeDevice(remoteId)
|
aggregator?.removeDevice(remoteId)
|
||||||
|
udpReceiver?.removeDevice(remoteId)
|
||||||
}
|
}
|
||||||
discovery.startDiscovery()
|
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() {
|
fun stop() {
|
||||||
mdnsDiscovery?.stopDiscovery()
|
mdnsDiscovery?.stopDiscovery()
|
||||||
|
udpReceiver?.stop()
|
||||||
|
udpSender?.stop()
|
||||||
wsClient?.shutdown()
|
wsClient?.shutdown()
|
||||||
aggregator?.stop()
|
aggregator?.stop()
|
||||||
wsServer?.stop()
|
wsServer?.stop()
|
||||||
mdnsRegister?.unregister()
|
mdnsRegister?.unregister()
|
||||||
|
|
||||||
mdnsDiscovery = null
|
mdnsDiscovery = null
|
||||||
|
udpReceiver = null
|
||||||
|
udpSender = null
|
||||||
wsClient = null
|
wsClient = null
|
||||||
aggregator = null
|
aggregator = null
|
||||||
wsServer = 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.databinding.ActivityInitBinding
|
||||||
import com.shuwei.dish.match.db.AppRepository
|
import com.shuwei.dish.match.db.AppRepository
|
||||||
import com.shuwei.dish.match.http.UrlConfig
|
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.AppUtil
|
||||||
import com.shuwei.dish.match.utils.QRCodeUtil
|
import com.shuwei.dish.match.utils.QRCodeUtil
|
||||||
import com.shuwei.dish.match.utils.SpTool
|
import com.shuwei.dish.match.utils.SpTool
|
||||||
@@ -42,9 +41,9 @@ class InitActivity : BaseActivity() {
|
|||||||
// useBackground(false)
|
// useBackground(false)
|
||||||
binding = ActivityInitBinding.inflate(layoutInflater)
|
binding = ActivityInitBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
lifecycleScope.launch {
|
// lifecycleScope.launch {
|
||||||
FoodModule.init(this@InitActivity)
|
// FoodModule.init(this@InitActivity)
|
||||||
}
|
// }
|
||||||
// setHeaderBackground(isHomePage = true)
|
// setHeaderBackground(isHomePage = true)
|
||||||
setHeaderBgVisible(false)
|
setHeaderBgVisible(false)
|
||||||
BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString()
|
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.databinding.FragmentCollectBinding
|
||||||
import com.shuwei.dish.match.dialog.Loading
|
import com.shuwei.dish.match.dialog.Loading
|
||||||
import com.shuwei.dish.match.objbox.FoodCollectionBean
|
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.ui.SettingActivity
|
||||||
import com.shuwei.dish.match.utils.BitmapSaver
|
import com.shuwei.dish.match.utils.BitmapSaver
|
||||||
import com.shuwei.dish.match.utils.Debouncer
|
import com.shuwei.dish.match.utils.Debouncer
|
||||||
@@ -120,38 +119,38 @@ class CollectFragment : BaseFragment<FragmentCollectBinding>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getImageVector(index: Int, bitmap: Bitmap) {
|
private fun getImageVector(index: Int, bitmap: Bitmap) {
|
||||||
// val bitmap = BitmapCropper.cropCenter(
|
//// val bitmap = BitmapCropper.cropCenter(
|
||||||
// original = srcBmp,
|
//// original = srcBmp,
|
||||||
// targetWidth = 900, targetHeight = 900,
|
//// targetWidth = 900, targetHeight = 900,
|
||||||
//// offsetX = 30, offsetY = 100
|
////// 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 {
|
// log("${this.javaClass.simpleName}-cameraCallback-裁剪bitmap保存文件路径:${file?.absolutePath}")
|
||||||
FoodModule.bitmap2FloatArray(bitmap, false)
|
//
|
||||||
} catch (e: Exception) {
|
// activity?.runOnUiThread {
|
||||||
e.printStackTrace()
|
// foodCollectionList[index].let {
|
||||||
toast("操作失败")
|
// it.imageVector = imageVector
|
||||||
log("操作失败:${e.message}")
|
// it.bitmap = null
|
||||||
hideWaitingDialog()
|
// it.isShowCamera = false
|
||||||
return
|
// it.imageFile = file
|
||||||
}
|
// }
|
||||||
val file = BitmapSaver.saveToAppFilesDir(
|
// collectionAdapter.notifyItemChanged(index)
|
||||||
bitmap, requireActivity(), "IMG_CROP_${System.currentTimeMillis()}.jpg"
|
// }
|
||||||
)
|
// if (bitmap.isRecycled.not()) {
|
||||||
log("${this.javaClass.simpleName}-cameraCallback-裁剪bitmap保存文件路径:${file?.absolutePath}")
|
// bitmap.recycle()
|
||||||
|
// }
|
||||||
activity?.runOnUiThread {
|
// hideWaitingDialog()
|
||||||
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")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
|||||||
@@ -3,25 +3,15 @@ package com.shuwei.dish.match.utils
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import com.shuwei.dish.match.ui.InitActivity
|
||||||
|
|
||||||
class BootReceiver : BroadcastReceiver() {
|
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) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val action = intent.action ?: return
|
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
|
||||||
if (action in BOOT_ACTIONS) {
|
// 启动服务或Activity
|
||||||
// Android 10+ 禁止在后台直接启动 Activity
|
val launchIntent = Intent(context, InitActivity::class.java)
|
||||||
// 改为启动前台服务,由 BootService 负责拉起 InitActivity
|
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
val serviceIntent = Intent(context, BootService::class.java)
|
context.startActivity(launchIntent)
|
||||||
context.startForegroundService(serviceIntent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user