diff --git a/app/src/main/java/com/sw/dualscreen/GlobalData.kt b/app/src/main/java/com/sw/dualscreen/GlobalData.kt index 8a63be6..5aaf5dd 100644 --- a/app/src/main/java/com/sw/dualscreen/GlobalData.kt +++ b/app/src/main/java/com/sw/dualscreen/GlobalData.kt @@ -20,7 +20,8 @@ object GlobalData { /** * 具体业务baseurl */ - var appBaseUrl: String = "" + var appBaseUrl: String = "http://192.168.1.201:14801" + //var appBaseUrl: String = "https://dev.yixiong-tech.com:8081" /** * 横排数量 diff --git a/app/src/main/java/com/sw/dualscreen/MyApp.kt b/app/src/main/java/com/sw/dualscreen/MyApp.kt index 902d5b0..4fb52c1 100644 --- a/app/src/main/java/com/sw/dualscreen/MyApp.kt +++ b/app/src/main/java/com/sw/dualscreen/MyApp.kt @@ -21,10 +21,9 @@ class MyApp : App() { instance = this Timber.plant(Timber.DebugTree()) var deviceId = AppUtil.getUDID(this) - Timber.d("UDID = ${AppUtil.getUDID(this)}") + Timber.d("deviceId = $deviceId") // deviceId = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e" GlobalData.deviceId = deviceId - GlobalData.appBaseUrl = "http://192.168.1.201:14801" ObjectBox.init(this) // 初始化崩溃处理器 CrashHandler.init(this) diff --git a/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt b/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt index c4ded40..c1b146b 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt @@ -36,23 +36,35 @@ import com.sw.dualscreen.model.response.ClickBackEvent import com.sw.dualscreen.model.response.FoodInfo import com.sw.dualscreen.model.response.FoodOrder import com.sw.dualscreen.model.response.PaySuccessEvent +import com.sw.dualscreen.model.response.UserFaceModel import com.sw.dualscreen.objbox.FoodModule import com.sw.dualscreen.objbox.FoodModule.IdNameScore import com.sw.dualscreen.presentation.MainScreenPresentation import com.sw.dualscreen.sdk.SensorScaleUtils +import com.sw.dualscreen.socket.LanServer +import com.sw.dualscreen.socket.TcpClient import com.sw.dualscreen.utils.BitmapSaver import com.sw.dualscreen.utils.Debouncer import com.sw.dualscreen.utils.GsonUtils import com.sw.dualscreen.utils.ImageUtil +import com.sw.dualscreen.utils.IntervalExecutor import com.sw.dualscreen.utils.SPUtil +import com.sw.dualscreen.utils.SpTool import com.sw.dualscreen.view.CustomBottomSheetDialog import com.sw.dualscreen.viewmodel.BaseViewModel import com.sw.dualscreen.viewmodel.UserViewModel +import com.sw.face.collect.view.LanServerListenerImpl +import com.sw.face.collect.view.TcpClientListenerImpl +import com.sw.plate.utils.Base64 import com.sw.plate.utils.LightManager import com.sw.plate.utils.ToastUtils +import com.sw.plate.utils.arcface.facedb.FaceDatabase +import com.sw.plate.utils.arcface.facedb.entity.FaceEntity import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel +import kotlinx.coroutines.Job import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.json.JSONObject import timber.log.Timber import java.io.File import java.time.LocalDateTime @@ -127,6 +139,7 @@ class MainActivity : BaseActivity() { override fun initialize() { super.initialize() + isPageVisible = true addBackEventListener() FoodModule.init(this) initView() @@ -140,6 +153,10 @@ class MainActivity : BaseActivity() { private fun initData() { // viewModel.getEquipmentToken() //viewModel.getUserFaceCache2() + + startFaceTask() + +// addSocketListener() } private fun initView() { @@ -150,7 +167,18 @@ class MainActivity : BaseActivity() { // // Log.d("ScreenInfo", "DPI: $screenDPI, density: $density"); - viewModel.activeEngine() + viewModel.getDeviceConfig { deviceConfig -> + runOnUiThread { + if (deviceConfig == null) { + ToastUtils.showToast("获取设备配置数据失败") + return@runOnUiThread + } + GlobalData.appId = deviceConfig.arcsoftAppId ?: "" + GlobalData.sdkKey = deviceConfig.arcsoftSdkKey ?: "" + GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: "" + viewModel.activeEngine() + } + } updateDateTime() binding.ivSetting.setOnClickListener { startActivity(Intent(this, SettingActivity::class.java)) @@ -292,7 +320,9 @@ class MainActivity : BaseActivity() { SensorScaleUtils.addWeightListener { weight -> Timber.d("registerDataChange weight = $weight") - if (isPageVisible.not() || isStartRecognize.not()) return@addWeightListener + if (isPageVisible.not() || isStartRecognize.not()) { + return@addWeightListener + } val isWeightChange = weight - lastWeight > 0.05 recognizeByWeight(weight, isWeightChange) } @@ -607,6 +637,7 @@ class MainActivity : BaseActivity() { if (displays.size > 1) return displays[1] return null } + private val recognizeViewModel by lazy { viewModels().value } private fun setupSecondaryDisplay() { val secondaryDisplay = getSecondaryDisplay() @@ -619,7 +650,7 @@ class MainActivity : BaseActivity() { activity = this, display = secondaryDisplay, userViewModel = viewModel, - recognizeViewModel = viewModels().value + recognizeViewModel = recognizeViewModel ).apply { setStepChangeCallback { step -> if (step == 1) { @@ -653,13 +684,6 @@ class MainActivity : BaseActivity() { adapter.notifyDataSetChanged() } - override fun onDestroy() { - //SensorScaleUtils.closeScale() - presentation?.cancel() - super.onDestroy() - shutdownCamera() - } - private fun updateDateTime() { val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日 E", Locale.CHINA) val dateTime = formatter.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))) @@ -696,7 +720,7 @@ class MainActivity : BaseActivity() { binding.root.postDelayed({ recognizeByWeight(weight, true) - },500) + }, 500) } isRefreshPage = !isRefreshPage } @@ -763,6 +787,10 @@ class MainActivity : BaseActivity() { return } } + if (eatNum == 0) { + ToastUtils.showToast("您好,当前重量不足一份") + return + } val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) val order = FoodOrder( deviceId = GlobalData.deviceId, @@ -836,7 +864,9 @@ class MainActivity : BaseActivity() { } } } + private var isRefreshPage = false + @Subscribe(threadMode = ThreadMode.MAIN) fun onClickBackEvent(event: ClickBackEvent) { isRefreshPage = false @@ -893,4 +923,168 @@ class MainActivity : BaseActivity() { } } + override fun onDestroy() { + //SensorScaleUtils.closeScale() + faceTaskJob?.cancel() + presentation?.cancel() + shutdownCamera() + super.onDestroy() + } + + private val intervalExecutor by lazy { IntervalExecutor() } + private var faceTaskJob: Job? = null + + // private val initialDelay = 5 * 60 * 1000L +// private val dealyMillis = 10 * 60 * 1000L + private val initialDelay = 60 * 1000L + private val dealyMillis = 30 * 1000L + private var taskPageNo = 1 + fun startFaceTask() { + faceTaskJob = + intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) { + viewModel.getFaceIncrementList( + pageNo = taskPageNo, + timestamp = SpTool.getLastFaceTimestamp() + ) { list -> + runOnUiThread { + if (list.isEmpty()) { + return@runOnUiThread + } + SpTool.setLastFaceTimestamp(System.currentTimeMillis()) + updateFaceData(list) + } + } + } + } + + private fun updateFaceData(list: List) { + Thread { + val faceList = mutableListOf() + try { + list.forEach { model -> + if (model.faceDeleted == true) { + //删除数据 + FaceDatabase.getInstance(this).faceDao().deleteFaceById(model.userId) + } else { + //保存数据 + val faceEntity = FaceEntity( + model.userId, + null, + Base64.decode(model.faceFeatureStr) + ).also { + it.userType = "1" + } + faceList.add(faceEntity) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + try { + if (faceList.isNotEmpty()) { + FaceDatabase.getInstance(this).faceDao().insert(faceList) + } + recognizeViewModel.refreshFaceList(); + } catch (e: Exception) { + e.printStackTrace() + } + }.start() + } + + private fun sendFaceData(faceData: String) { + // 给某个客户端发送 + val jsonObject = JSONObject().also { + it.put("type", "faceFeature") + it.put("content", faceData) + } + tcpClient?.send(jsonObject) + } + + private var tcpClient: TcpClient? = null + +// private fun addSocketListener() { +// //val ipAddress = NetworkUtils.getIPAddress(true) +// //Log.d(TAG, "addSocketListener,ipAddress: $ipAddress"); +// tcpClient = TcpClient( +// "192.168.1.95", +// 5000, +// GlobalData.deviceId, // clientId +// 5000, // connectTimeoutMs +// 10 * 1000, // heartbeatIntervalMs +// 30 * 1000 // heartbeatTimeoutMs +// ) +// +// tcpClient?.setListener(object : TcpClientListenerImpl() { +// override fun onSendSuccess(json: JSONObject?) { +// super.onSendSuccess(json) +//// toast("发送成功") +// } +// +// override fun onSendFailed(json: JSONObject?, e: java.lang.Exception?) { +// super.onSendFailed(json, e) +// ToastUtils.showToast("发送失败${e?.message}") +// } +// +// override fun onMessage(json: JSONObject?) { +// super.onMessage(json) +// json?.let { receiveMessage(json!!) } +// } +// }) +// tcpClient?.start(); +// +// try { +// LanServer.getInstance().let { +// it.setListener(object : LanServerListenerImpl() { +// override fun onMessageReceived(clientId: String?, message: JSONObject?) { +// super.onMessageReceived(clientId, message) +// //toast("收到消息clientId=$clientId") +// } +// }) +// it.start() +// } +// } catch (e: Exception) { +// e.printStackTrace() +// } +// } + +// /** +// * 接收服务端发送的消息 +// * +// * @param json 人脸数据 +// */ +// private fun receiveMessage(json: JSONObject) { +// try { +// val faceId = json.getString("faceId"); +// val faceFeature = json.getString("faceFeature"); +// queryByUserName(faceId, { faceEntity -> +// if (faceEntity != null) { +// //已存在用户faceId的人脸数据 +// return@queryByUserName +// } +// //不存在用户faceId,保存数据 +// val faceEntity2 = FaceEntity(faceId, null, Base64.decode(faceFeature)) +// //设置用户类型:1-内部员工、2-临时用户、3-普通会员、或者其它待定类型 +// faceEntity2.setUserType("2") +// saveFaceData(faceEntity2) +// }) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// } + +// private fun saveFaceData(faceEntity: FaceEntity) { +// Thread { +// FaceDatabase.getInstance(this).faceDao().insert(faceEntity) +// }.start() +// } +// +// private fun queryByUserName(userName: String, block: (FaceEntity) -> Unit) { +// Thread { +// val faceEntity = FaceDatabase.getInstance(this).faceDao().queryByUserName(userName) +// runOnUiThread { +// block(faceEntity) +// } +// }.start() +// } + } \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/activity/fragment/CollectFragment.kt b/app/src/main/java/com/sw/dualscreen/activity/fragment/CollectFragment.kt index c43757d..83fe513 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/fragment/CollectFragment.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/fragment/CollectFragment.kt @@ -166,7 +166,7 @@ class CollectFragment : BaseFragment() { ToastUtils.showToast("每次只允许保存${MAX_COUNT}条数据") return } - settingActivity?.showWaitingDialog("拍照中……") + settingActivity?.showWaitingDialog("采集中……") cameraUtils.takePhoto(cameraCallback) { errMsg -> ToastUtils.showToast("拍照异常,请重新操作") settingActivity?.hideWaitingDialog() @@ -374,10 +374,8 @@ class CollectFragment : BaseFragment() { clickIndex = -1 binding.editFoodName.setText("") -// foodList.clear() -// foodAdapter.updateData(mutableListOf()) -// foodAdapter.notifyDataSetChanged() - //loadEmptyView() + searchFoodList.clear() + searchFoodAdapter.notifyDataSetChanged() } override fun onResume() { diff --git a/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/NumberPayFragment.kt b/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/NumberPayFragment.kt index 31e25d9..ffec937 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/NumberPayFragment.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/NumberPayFragment.kt @@ -48,8 +48,10 @@ class NumberPayFragment : BaseFragment() { payActivity.getMemberInfoByPhone(phone, key) { memberInfo -> if (memberInfo == null) { - payActivity.hideWaitingDialog() - //ToastUtils.showToast("查询会员信息失败,请稍后重试") + binding.root.postDelayed({ + payActivity.hideWaitingDialog() + ToastUtils.showToast("未查询到会员信息,请稍后重试") + }, 1000) return@getMemberInfoByPhone } //绑定订单使用faceUserId diff --git a/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/PayResultFragment.kt b/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/PayResultFragment.kt index c298fb1..4c02703 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/PayResultFragment.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/PayResultFragment.kt @@ -137,7 +137,8 @@ class PayResultFragment : BaseFragment() { payActivity.getQrCodeImg( orderId = payActivity.foodOrderId, userId = memberInfo!!.id, - totalFee = payAmount.format2String(2) + //混合支付,实际支付金额 + //totalFee = payAmount.format2String(2) ) { qrCodeImg -> binding.ivPayQrCode.load(qrCodeImg) //更新副屏二维码 diff --git a/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/ScanQrCodePayFragment.kt b/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/ScanQrCodePayFragment.kt index 0e19568..5d55f72 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/ScanQrCodePayFragment.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/fragment/pay/ScanQrCodePayFragment.kt @@ -44,7 +44,8 @@ class ScanQrCodePayFragment : BaseFragment() { //不需要绑定会员id payActivity.getQrCodeImg( orderId = payActivity.foodOrderId, - totalFee = payAmount.format2String(2) + //非会员扫码支付,不用传金额,后端处理,混合支付要传 + //totalFee = payAmount.format2String(2) ) { payQrCodePic = it binding.ivPayQrCode.load(payQrCodePic) diff --git a/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt b/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt index 89f09b2..236b486 100644 --- a/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt +++ b/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.ByteArrayOutputStream +import java.math.BigDecimal +import java.math.RoundingMode /** * 将 Int 值转换为 dp 值 @@ -65,6 +67,10 @@ val Float.sp: Float Resources.getSystem().displayMetrics ) +fun Double.roundedDecimalPlace(num: Int = 2): Double { + return BigDecimal(this).setScale(num, RoundingMode.HALF_UP).toDouble() +} + fun Double?.format2String(): String = this.format2String(1) fun Double?.format2String(num:Int): String = "%.${num}f".format(this?:0.0) diff --git a/app/src/main/java/com/sw/dualscreen/model/response/DataBean.kt b/app/src/main/java/com/sw/dualscreen/model/response/DataBean.kt index 8caddd2..e2e66f8 100644 --- a/app/src/main/java/com/sw/dualscreen/model/response/DataBean.kt +++ b/app/src/main/java/com/sw/dualscreen/model/response/DataBean.kt @@ -130,4 +130,10 @@ data class ChargeModeEvent( data class ClickBackEvent( var name: String = "" +) + +data class DeviceConfig( + var arcsoftAppId:String?=null, + var arcsoftSdkKey:String?=null, + var arcsoftActiveKey:String?=null ) \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/model/response/UserFaceModel.kt b/app/src/main/java/com/sw/dualscreen/model/response/UserFaceModel.kt index 01b2dda..2fd3524 100644 --- a/app/src/main/java/com/sw/dualscreen/model/response/UserFaceModel.kt +++ b/app/src/main/java/com/sw/dualscreen/model/response/UserFaceModel.kt @@ -20,7 +20,9 @@ data class UserFaceModel( // val userFaceId: String? = "", // @SerializedName("userId") val userId: String? = "", - val faceFeatureStr: String? = "" + val faceFeatureStr: String? = "", + + val faceDeleted: Boolean? = false ) : Parcelable @Parcelize diff --git a/app/src/main/java/com/sw/dualscreen/network/api/ApiService.kt b/app/src/main/java/com/sw/dualscreen/network/api/ApiService.kt index 0df52d3..216d248 100644 --- a/app/src/main/java/com/sw/dualscreen/network/api/ApiService.kt +++ b/app/src/main/java/com/sw/dualscreen/network/api/ApiService.kt @@ -3,6 +3,7 @@ package com.sw.dualscreen.network.api import com.sw.dualscreen.GlobalData import com.sw.dualscreen.model.request.UserNutritionParam import com.sw.dualscreen.model.response.ApiResponse +import com.sw.dualscreen.model.response.DeviceConfig import com.sw.dualscreen.model.response.DinnerType import com.sw.dualscreen.model.response.FaceData import com.sw.dualscreen.model.response.FoodInfo @@ -66,7 +67,7 @@ interface ApiService { @Body param: Map // @Query("pageNum") pageNum: Int, // @Query("pageSize") pageSize: Int - ): ApiResponse> + ): ApiResponse?> @GET suspend fun getUserFaceCache2( @@ -161,7 +162,7 @@ interface ApiService { // @Url url: String = "${GlobalData.appBaseUrl}/pay/yx-check-out-pay/turnOrderInfo", // @Body param: HashMap // ): ApiResponse - /** + /** * 查询支付结果 */ @GET @@ -197,7 +198,7 @@ interface ApiService { @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/pay/app/getQrCodeImg", @Query("orderNo") orderNo: String, @Query("memberId") memberId: String?, - @Query("totalFee") totalFee: String? + @Query("totalFee") totalFee: String? = null ): ApiResponse /** @@ -251,4 +252,20 @@ interface ApiService { @Part foodPics: List ): ApiResponse + /** + * 获取设备配置数据 + */ + @GET + suspend fun getDeviceConfig( + @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/common/app/getYxEquipmentByEquipmentCode" + ): ApiResponse + + /** + * 获取人脸增量数据 + */ + @POST + suspend fun getFaceIncrementList( + @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/common/app/faceFeature/increment/list", + @Body param: Map + ): ApiResponse?> } \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/presentation/MainScreenPresentation.kt b/app/src/main/java/com/sw/dualscreen/presentation/MainScreenPresentation.kt index bf57a62..1679e9a 100644 --- a/app/src/main/java/com/sw/dualscreen/presentation/MainScreenPresentation.kt +++ b/app/src/main/java/com/sw/dualscreen/presentation/MainScreenPresentation.kt @@ -35,6 +35,7 @@ import com.sw.dualscreen.ext.format2String import com.sw.dualscreen.ext.gone import com.sw.dualscreen.ext.load import com.sw.dualscreen.ext.maskName +import com.sw.dualscreen.ext.roundedDecimalPlace import com.sw.dualscreen.ext.visible import com.sw.dualscreen.model.response.DinnerType import com.sw.dualscreen.model.response.FoodInfo @@ -61,6 +62,7 @@ import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import timber.log.Timber +import java.text.DecimalFormat import kotlin.math.max import kotlin.math.roundToInt @@ -391,7 +393,7 @@ class MainScreenPresentation( } private var isLoadUnbilledMode = false - + private val df by lazy { DecimalFormat("#.##") } /** * 餐品识别成功,不计费模式 */ @@ -411,8 +413,10 @@ class MainScreenPresentation( //updateFoodInfo(foodInfo) binding.nutritionInclude.tvUserName.text = userNutrition?.name.maskName() - binding.nutritionInclude.tvRecommendHeat.text = - "推荐热量:${userNutrition?.recommendCalorie.format2String(2)}kcal" + val recommendCalorie = userNutrition?.recommendCalorie ?: 0.0 + + binding.nutritionInclude.tvRecommendHeat.text = "推荐热量:${df.format(recommendCalorie)}kcal" +// binding.nutritionInclude.tvRecommendHeat.text = "推荐热量:${recommendCalorie.removeTrailingZeros()}kcal" //updateWeight(lastWeight / 1000) // ------------------------------------------------- // 删除userNutritionData,改为userNutrition diff --git a/app/src/main/java/com/sw/dualscreen/presentation/UserNutritionUtils.kt b/app/src/main/java/com/sw/dualscreen/presentation/UserNutritionUtils.kt index 1abc5b5..bdc4ca4 100644 --- a/app/src/main/java/com/sw/dualscreen/presentation/UserNutritionUtils.kt +++ b/app/src/main/java/com/sw/dualscreen/presentation/UserNutritionUtils.kt @@ -24,14 +24,16 @@ object UserNutritionUtils { dinnerType: String ): UserEnergy { // 初始化变量 - var totalKcal = 0.0 + //var totalKcal = 0.0 var (calorie, grain, fruitsVegetables, meatEggs) = List(4) { 0.0 } // 处理食物信息 - calorie = foodInfo.calorie ?: 0.0 //calculateValue(foodInfo.calorie, weight) - grain = foodInfo.stapleFood ?: 0.0 //calculateValue(foodInfo.stapleFood, weight) - fruitsVegetables = foodInfo.fruitsVegetables ?: 0.0 //calculateValue(foodInfo.fruitsVegetables, weight) - meatEggs = foodInfo.meatEggs ?: 0.0 //calculateValue(foodInfo.meatEggs, weight) + if (weight > 0.0) { + calorie = calculateValue2(foodInfo.calorie,foodInfo.specWeight, weight) + grain = calculateValue2(foodInfo.stapleFood, foodInfo.specWeight,weight) + fruitsVegetables = calculateValue2(foodInfo.fruitsVegetables, foodInfo.specWeight,weight) + meatEggs = calculateValue2(foodInfo.meatEggs, foodInfo.specWeight,weight) + } // 合并用户数据 //val maxKcal = (nutrition.calorie?:0.0) * calculateDinnerTypeRatio(dinnerType) / 10.0 @@ -40,7 +42,7 @@ object UserNutritionUtils { fruitsVegetables += nutrition.fruitsVegetables ?: 0.0 meatEggs += nutrition.meatEggs ?: 0.0 - totalKcal = max(calorie, 0.0) + //totalKcal = max(calorie, 0.0) // 判断当餐最大热量 //val maxKcal = userModel.totalEnergyCalculateScoreValue() * calculateDinnerTypeRatio(dinnerType) / 10 //Timber.d("calculateNutrition maxKcal = ${maxKcal}, CalculateScoreValue = ${userModel.totalEnergyCalculateScoreValue()}") @@ -55,7 +57,7 @@ object UserNutritionUtils { // meat = parseRecommend(foodInfo.meatEggsRecommend, meatEggs) // ) return UserEnergy( - calorie = totalKcal, + calorie = calorie, grain = grain, fruitsVegetables = fruitsVegetables, meatEggs = meatEggs @@ -140,7 +142,10 @@ object UserNutritionUtils { else -> 0 } } - + private fun calculateValue2(nutrient: Double?, specWeight: Double?, weight: Double): Double { + if ((nutrient?:0.0) == 0.0 || (specWeight?:0.0) == 0.0) return 0.0 + return nutrient!! * weight / specWeight!! + } /** * 计算每百克含量 */ diff --git a/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt b/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt index 959bbe2..eea6d85 100644 --- a/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt +++ b/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt @@ -3,6 +3,7 @@ package com.sw.dualscreen.repository import com.sw.dualscreen.GlobalData import com.sw.dualscreen.model.request.UserNutritionParam import com.sw.dualscreen.model.response.ApiResponse +import com.sw.dualscreen.model.response.DeviceConfig import com.sw.dualscreen.model.response.DinnerType import com.sw.dualscreen.model.response.FaceData import com.sw.dualscreen.model.response.FoodInfo @@ -63,7 +64,7 @@ class RemoteRepository constructor( suspend fun getUserFaceCache( pageNum: Int, pageSize: Int = 100, - ): ApiResponse> { + ): ApiResponse?> { return safeApiCall { apiService.getUserFaceCache( param = mapOf( @@ -73,6 +74,24 @@ class RemoteRepository constructor( ) } } + /** + * 获取人脸数据 + */ + suspend fun getFaceIncrementList( + pageNum: Long, + pageSize: Long = 100L, + timestamp: Long + ): ApiResponse?> { + return safeApiCall { + apiService.getFaceIncrementList( + param = mapOf( + "pageNum" to pageNum, + "pageSize" to pageSize, + "timestamp" to timestamp + ) + ) + } + } suspend fun getUserFaceCache2( pageNum: Int, @@ -151,7 +170,11 @@ class RemoteRepository constructor( totalFee: String? = null ): ApiResponse { return safeApiCall { - apiService.getQrCodeImg(orderNo = orderNo, memberId = memberId, totalFee = totalFee) + apiService.getQrCodeImg( + orderNo = orderNo, + memberId = memberId, + totalFee = totalFee + ) } } @@ -275,4 +298,8 @@ class RemoteRepository constructor( apiService.uploadCollectFoodPics(params = params, foodPics = fileParts) } } + + suspend fun getDeviceConfig(): ApiResponse { + return safeApiCall { apiService.getDeviceConfig() } + } } \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/socket/LanCommunicationManager.java b/app/src/main/java/com/sw/dualscreen/socket/LanCommunicationManager.java new file mode 100644 index 0000000..f1ad82f --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/socket/LanCommunicationManager.java @@ -0,0 +1,300 @@ +package com.sw.dualscreen.socket; + +import android.annotation.SuppressLint; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 局域网多客户端通信管理器:支持并发、心跳、认证、广播、点对点发送 + * 可用于 Android 和 JVM 程序。 + */ +public class LanCommunicationManager { + + // ============ 监听配置 ============ + private final int port; + private final long HEARTBEAT_TIMEOUT_MS; + private final int MAX_CLIENT_THREADS; + + // ============ 状态 ============ + private volatile boolean running = false; + private ServerSocket serverSocket; + + // ============ 线程池 ============ + private final ExecutorService acceptExecutor = Executors.newSingleThreadExecutor(); + private final ExecutorService clientExecutor; + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + // ============ 客户端会话 ============ + private final ConcurrentHashMap clients = new ConcurrentHashMap<>(); + private final ConcurrentHashMap unAuthSessions = new ConcurrentHashMap<>(); + + // ============ 回调接口 ============ + public interface Listener { + // 新客户端完成 AUTH / 认证 + void onClientConnected(String clientId); + + // 客户端断开 + void onClientDisconnected(String clientId); + + // 收到业务消息(type != heartbeat/auth) + void onMessageReceived(String clientId, JSONObject message); + } + + private Listener listener; + + // ============ 构造 ============ + public LanCommunicationManager(int port, int maxClientThreads, long heartbeatTimeoutMs) { + this.port = port; + this.MAX_CLIENT_THREADS = maxClientThreads; + this.HEARTBEAT_TIMEOUT_MS = heartbeatTimeoutMs; + this.clientExecutor = Executors.newFixedThreadPool(Math.max(2, maxClientThreads)); + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + // ============ 启动服务端 ============ + @SuppressLint("DiscouragedApi") + public void start() throws IOException { + if (running) return; + running = true; + + serverSocket = new ServerSocket(port); + serverSocket.setSoTimeout(2000); + + acceptExecutor.execute(this::acceptLoop); + scheduler.scheduleAtFixedRate(this::heartbeatCheck, + HEARTBEAT_TIMEOUT_MS, + HEARTBEAT_TIMEOUT_MS, + TimeUnit.MILLISECONDS); + + System.out.println("LanCommunicationManager started on port " + port); + } + + private void acceptLoop() { + while (running) { + try { + Socket socket = serverSocket.accept(); + socket.setSoTimeout((int) HEARTBEAT_TIMEOUT_MS * 2); + + ClientSession session = new ClientSession(socket); + unAuthSessions.put(socket, session); + + clientExecutor.execute(() -> clientReadLoop(session)); + + } catch (SocketTimeoutException ignore) { + } catch (Exception e) { + if (running) e.printStackTrace(); + } + } + } + + // ============ 处理客户端数据读取 ============ + private void clientReadLoop(ClientSession session) { + Socket socket = session.socket; + + try (DataInputStream in = new DataInputStream(socket.getInputStream())) { + while (running && !socket.isClosed()) { + + int len; + try { + len = in.readInt(); + } catch (SocketTimeoutException ste) { + continue; + } + + if (len <= 0 || len > 10 * 1024 * 1024) break; + + byte[] buf = new byte[len]; + in.readFully(buf); + session.updateLastSeen(); + + JSONObject msg = new JSONObject(new String(buf)); + handleMessage(session, msg); + } + } catch (Exception ignored) { + } finally { + closeSession(session); + } + } + + private void handleMessage(ClientSession session, JSONObject msg) { + String type = msg.optString("type", ""); + + switch (type) { + case "auth": + handleAuth(session, msg); + break; + case "heartbeat": + session.updateLastSeen(); + break; + default: + if (listener != null && session.clientId != null) { + listener.onMessageReceived(session.clientId, msg); + } + break; + } + } + + private void handleAuth(ClientSession session, JSONObject msg) { + String clientId = msg.optString("clientId", null); + if (clientId == null) return; + + session.clientId = clientId; + + // 移动到已认证 map + unAuthSessions.remove(session.socket); + clients.put(clientId, session); + + if (listener != null) listener.onClientConnected(clientId); + + sendToSession(session, ack("auth_ok")); + } + + // ============ 心跳超时 ============ + private void heartbeatCheck() { + long now = System.currentTimeMillis(); + + for (Map.Entry e : clients.entrySet()) { + ClientSession s = e.getValue(); + if (now - s.lastSeen > HEARTBEAT_TIMEOUT_MS) { + closeSession(s); + } + } + + for (ClientSession s : unAuthSessions.values()) { + if (now - s.lastSeen > HEARTBEAT_TIMEOUT_MS * 2) { + closeSession(s); + } + } + } + + // ============ 发送 ============ + public boolean sendToClient(String clientId, JSONObject json) { + ClientSession s = clients.get(clientId); + return s != null && sendToSession(s, json); + } + + public void broadcast(JSONObject json) { + for (ClientSession s : clients.values()) { + sendToSession(s, json); + } + } + + private boolean sendToSession(ClientSession s, JSONObject json) { + try { + DataOutputStream out = s.out; + + synchronized (out) { + byte[] data = json.toString().getBytes(); + out.writeInt(data.length); + out.write(data); + out.flush(); + } + return true; + + } catch (Exception e) { + closeSession(s); + return false; + } + } + + // ============ ACK ============ + private JSONObject ack(String type) { + JSONObject j = new JSONObject(); + try { + j.put("type", "ack"); + j.put("ack", type); + } catch (JSONException e) { + throw new RuntimeException(e); + } + + return j; + } + + // ============ 停止 ============ + public void stop() { + running = false; + + try { + serverSocket.close(); + } catch (Exception ignored) { + } + + for (ClientSession s : clients.values()) closeSession(s); + for (ClientSession s : unAuthSessions.values()) closeSession(s); + + acceptExecutor.shutdownNow(); + clientExecutor.shutdownNow(); + scheduler.shutdownNow(); + + System.out.println("LanCommunicationManager stopped"); + } + + // ============ 会话类 ============ + public static class ClientSession { + public final Socket socket; + public final DataOutputStream out; + public volatile long lastSeen = System.currentTimeMillis(); + public volatile String clientId; + + public ClientSession(Socket socket) throws IOException { + this.socket = socket; + this.out = new DataOutputStream(socket.getOutputStream()); + } + + public void updateLastSeen() { + lastSeen = System.currentTimeMillis(); + } + } + + public void closeSession(ClientSession session) { + if (session == null) return; + + try { + Socket socket = session.socket; + + // 1. 从已认证表移除 + if (session.clientId != null) { + ClientSession removed = clients.remove(session.clientId); + if (removed != null && listener != null) { + listener.onClientDisconnected(session.clientId); + } + } + + // 2. 从未认证表移除 + unAuthSessions.remove(socket); + + // 3. 关闭输出流 + try { + session.out.close(); + } catch (Exception ignored) { + } + + // 4. 关闭 socket + try { + if (!socket.isClosed()) socket.close(); + } catch (Exception ignored) { + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + +} diff --git a/app/src/main/java/com/sw/dualscreen/socket/LanServer.java b/app/src/main/java/com/sw/dualscreen/socket/LanServer.java new file mode 100644 index 0000000..554f516 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/socket/LanServer.java @@ -0,0 +1,21 @@ +package com.sw.dualscreen.socket; + +public class LanServer { + + private static volatile LanCommunicationManager instance; + + public static LanCommunicationManager getInstance() { + if (instance == null) { + synchronized (LanServer.class) { + if (instance == null) { + instance = new LanCommunicationManager( + 5000, // 监听端口 + 20, // 最大客户端数 + 10_000 // 心跳超时时间 10 秒 + ); + } + } + } + return instance; + } +} diff --git a/app/src/main/java/com/sw/dualscreen/socket/LanServerListenerImpl.kt b/app/src/main/java/com/sw/dualscreen/socket/LanServerListenerImpl.kt new file mode 100644 index 0000000..9664645 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/socket/LanServerListenerImpl.kt @@ -0,0 +1,22 @@ +package com.sw.face.collect.view + +import android.util.Log +import com.sw.dualscreen.socket.LanCommunicationManager +import org.json.JSONObject + +open class LanServerListenerImpl: LanCommunicationManager.Listener { + companion object { + private const val TAG = "LanServerListenerImpl" + } + override fun onClientConnected(clientId: String?) { + Log.d(TAG, "addSocketListener,onClientConnected: clientId = $clientId") + } + + override fun onClientDisconnected(clientId: String?) { + Log.d(TAG, "addSocketListener,onClientDisconnected: clientId = $clientId") + } + + override fun onMessageReceived(clientId: String?, message: JSONObject?) { + Log.d(TAG, "addSocketListener,onMessageReceived: clientId = $clientId,message = $message") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/socket/TcpClient.java b/app/src/main/java/com/sw/dualscreen/socket/TcpClient.java new file mode 100644 index 0000000..d485ae1 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/socket/TcpClient.java @@ -0,0 +1,330 @@ +package com.sw.dualscreen.socket; + +import android.util.Log; + +import org.json.JSONObject; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * TcpClient - Android ready + *

+ * Features: + * - length-prefix protocol (int length + bytes) + * - separate read thread and write queue & writer thread + * - auto-reconnect with exponential backoff + * - heartbeat scheduler + * - send queue with optional callback for send result + * - auto send "auth" JSON after connection + */ +public class TcpClient { + + private static final String TAG = "TcpClient"; + + // configuration + private final String serverIp; + private final int serverPort; + private final String clientId; // will be sent in auth message + private final int connectTimeoutMs; + private final long heartbeatIntervalMs; + private final long heartbeatTimeoutMs; + + // socket + streams + private Socket socket; + private DataOutputStream out; + private DataInputStream in; + + // threads & executors + private final ExecutorService writerExecutor = Executors.newSingleThreadExecutor(r -> new Thread(r, "TcpClient-Writer")); + private final ExecutorService readerExecutor = Executors.newSingleThreadExecutor(r -> new Thread(r, "TcpClient-Reader")); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "TcpClient-Scheduler")); + private final ExecutorService connectExecutor = Executors.newSingleThreadExecutor(r -> new Thread(r, "TcpClient-Connect")); + + // send queue + private final BlockingQueue sendQueue = new LinkedBlockingQueue<>(); + + // state + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicBoolean connected = new AtomicBoolean(false); + private final AtomicBoolean authSent = new AtomicBoolean(false); + + // reconnection/backoff + private final long baseReconnectDelayMs = 1000; // 1s + private final long maxReconnectDelayMs = 30_000; // 30s + private final AtomicInteger reconnectAttempt = new AtomicInteger(0); + + // heartbeat task future + private ScheduledFuture heartbeatFuture; + + // listener + public interface Listener { + void onConnected(); + + void onDisconnected(Exception e); + + void onMessage(JSONObject json); + + void onSendSuccess(JSONObject json); + + void onSendFailed(JSONObject json, Exception e); + } + + private Listener listener; + + public void setListener(Listener l) { + this.listener = l; + } + + // ctor + public TcpClient(String serverIp, int serverPort, String clientId, + int connectTimeoutMs, long heartbeatIntervalMs, long heartbeatTimeoutMs) { + this.serverIp = serverIp; + this.serverPort = serverPort; + this.clientId = clientId; + this.connectTimeoutMs = connectTimeoutMs; + this.heartbeatIntervalMs = heartbeatIntervalMs; + this.heartbeatTimeoutMs = heartbeatTimeoutMs; + } + + // start client (will attempt connect) + public void start() { + if (running.getAndSet(true)) return; + scheduleConnect(0); + // writer thread drains sendQueue + writerExecutor.execute(this::writerLoop); + } + + // stop client and cleanup + public void stop() { + running.set(false); + cancelHeartbeat(); + closeSocketQuiet(); + writerExecutor.shutdownNow(); + readerExecutor.shutdownNow(); + scheduler.shutdownNow(); + connectExecutor.shutdownNow(); + sendQueue.clear(); + } + + // send JSON (queued). Non-blocking. + public void send(JSONObject json) { + if (!running.get()) return; + sendQueue.offer(json); + } + + // AUTH shortcut (immediately send auth JSON) + private void sendAuth() { + try { + JSONObject auth = new JSONObject(); + auth.put("type", "auth"); + auth.put("clientId", clientId); + sendQueue.offer(auth); + authSent.set(true); + } catch (Exception ignored) { + } + } + + // writer thread loop (serializes sends) + private void writerLoop() { + while (running.get()) { + try { + JSONObject json = sendQueue.take(); // blocks + if (connected.get() && out != null) { + try { + byte[] data = json.toString().getBytes(); + synchronized (out) { + out.writeInt(data.length); + out.write(data); + out.flush(); + } + if (listener != null) listener.onSendSuccess(json); + } catch (Exception e) { + if (listener != null) listener.onSendFailed(json, e); + // on write failure, attempt reconnect + safeCloseAndScheduleReconnect(e); + } + } else { + // not connected: requeue it and wait for connection + sendQueue.offer(json); + Thread.sleep(500); // avoid busy loop + } + } catch (InterruptedException ignored) { + break; + } + } + } + + // reader loop (runs in readerExecutor) + private void startReaderLoop() { + readerExecutor.execute(() -> { + try { + while (running.get() && connected.get() && in != null) { + int length; + try { + length = in.readInt(); // will throw SocketTimeoutException if set + } catch (SocketTimeoutException ste) { + // used to detect socket liveness; continue loop + continue; + } + if (length <= 0 || length > 10 * 1024 * 1024) { + // invalid length, break + throw new RuntimeException("Invalid message length: " + length); + } + byte[] buf = new byte[length]; + in.readFully(buf); + String s = new String(buf); + try { + JSONObject json = new JSONObject(s); + // update last seen time via heartbeat ack if needed + if ("heartbeat".equals(json.optString("type"))) { + // optionally respond or update time + } else if ("auth_ok".equals(json.optString("type")) || "ack".equals(json.optString("type"))) { + // ignore or process ack + } else { + if (listener != null) listener.onMessage(json); + } + } catch (Exception je) { + Log.w(TAG, "Invalid JSON from server: " + s, je); + } + } + } catch (Exception e) { + if (running.get()) { + safeCloseAndScheduleReconnect(e); + } + } + }); + } + + // schedule connect attempt with delay (ms) + private void scheduleConnect(long delayMs) { + connectExecutor.execute(() -> { + try { + if (delayMs > 0) Thread.sleep(delayMs); + } catch (InterruptedException ignored) { + } + if (!running.get()) return; + tryConnect(); + }); + } + + // connect logic + private void tryConnect() { + if (!running.get()) return; + closeSocketQuiet(); // ensure closed + try { + Socket s = new Socket(); + s.connect(new InetSocketAddress(serverIp, serverPort), connectTimeoutMs); + s.setSoTimeout((int) Math.max(heartbeatTimeoutMs, 5_000)); + socket = s; + out = new DataOutputStream(socket.getOutputStream()); + in = new DataInputStream(socket.getInputStream()); + connected.set(true); + reconnectAttempt.set(0); + authSent.set(false); + // start reader + startReaderLoop(); + // send auth immediately + sendAuth(); + // start heartbeat + startHeartbeat(); + if (listener != null) listener.onConnected(); + Log.i(TAG, "Connected to " + serverIp + ":" + serverPort); + } catch (Exception e) { + Log.w(TAG, "Connect failed: " + e.getMessage()); + scheduleReconnectWithBackoff(); + } + } + + // start heartbeat scheduler + private void startHeartbeat() { + cancelHeartbeat(); + heartbeatFuture = scheduler.scheduleAtFixedRate(() -> { + if (!running.get() || !connected.get()) return; + try { + JSONObject hb = new JSONObject(); + hb.put("type", "heartbeat"); + hb.put("time", System.currentTimeMillis()); + sendQueue.offer(hb); + } catch (Exception ignored) { + } + }, 0, heartbeatIntervalMs, TimeUnit.MILLISECONDS); + } + + private void cancelHeartbeat() { + if (heartbeatFuture != null && !heartbeatFuture.isCancelled()) { + heartbeatFuture.cancel(true); + heartbeatFuture = null; + } + } + + // close socket quietly and notify listener + private void safeCloseAndScheduleReconnect(Exception cause) { + closeSocketQuiet(); + if (listener != null) listener.onDisconnected(cause); + scheduleReconnectWithBackoff(); + } + + private void scheduleReconnectWithBackoff() { + int attempt = reconnectAttempt.incrementAndGet(); + long delay = Math.min(maxReconnectDelayMsFromAttempt(attempt), maxReconnectDelayMs); + Log.i(TAG, "Scheduling reconnect attempt " + attempt + " after " + delay + "ms"); + scheduleConnect(delay); + } + + // compute exponential backoff + private long maxReconnectDelayMsFromAttempt(int attempt) { + long d = baseReconnectDelayMs * (1L << Math.min(attempt, 30)); + if (d < 0) d = maxReconnectDelayMs; + return Math.min(d, maxReconnectDelayMs); + } + + // close socket and streams + private void closeSocketQuiet() { + connected.set(false); + cancelHeartbeat(); + try { + if (out != null) { + out.close(); + } + } catch (Exception ignored) { + } + try { + if (in != null) { + in.close(); + } + } catch (Exception ignored) { + } + try { + if (socket != null && !socket.isClosed()) { + socket.close(); + } + } catch (Exception ignored) { + } + out = null; + in = null; + socket = null; + } + + // helper: when manually call reconnect (immediately) + public void reconnectNow() { + scheduleConnect(0); + } + + // helper to set immediate send of a JSON and wait (blocking) until it is queued (not until delivered) + public boolean sendBlocking(JSONObject json, long timeoutMs) throws InterruptedException { + return sendQueue.offer(json, timeoutMs, TimeUnit.MILLISECONDS); + } +} diff --git a/app/src/main/java/com/sw/dualscreen/socket/TcpClientListenerImpl.kt b/app/src/main/java/com/sw/dualscreen/socket/TcpClientListenerImpl.kt new file mode 100644 index 0000000..5dd3519 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/socket/TcpClientListenerImpl.kt @@ -0,0 +1,33 @@ +package com.sw.face.collect.view + +import android.util.Log +import com.sw.dualscreen.socket.TcpClient +import org.json.JSONObject +import java.lang.Exception + +open class TcpClientListenerImpl : TcpClient.Listener { + + companion object { + private const val TAG = "TcpClientListenerImpl" + } + + override fun onConnected() { + Log.d(TAG, "addSocketListener,onConnected: ") + } + + override fun onDisconnected(e: Exception?) { + Log.d(TAG, "addSocketListener,onDisconnected: ${e?.toString()}") + } + + override fun onMessage(json: JSONObject?) { + Log.d(TAG, "addSocketListener,onMessage: $json") + } + + override fun onSendSuccess(json: JSONObject?) { + Log.d(TAG, "addSocketListener,onSendSuccess: $json") + } + + override fun onSendFailed(json: JSONObject?, e: Exception?) { + Log.d(TAG, "addSocketListener,onSendFailed: $json,e:${e?.toString()}") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/socket/UdpDiscoveryClient.java b/app/src/main/java/com/sw/dualscreen/socket/UdpDiscoveryClient.java new file mode 100644 index 0000000..37f68b6 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/socket/UdpDiscoveryClient.java @@ -0,0 +1,47 @@ +package com.sw.dualscreen.socket; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; + +/** + * Simple UDP discovery client: + * Sends "DISCOVER_SERVER" broadcast and waits for first reply "SERVER_FOUND:serverName:ip" + */ +public class UdpDiscoveryClient { + + public interface Listener { + void onFound(String ip, String serverName); + + void onError(Exception e); + } + + public void discover(int discoveryPort, int timeoutMs, Listener listener) { + new Thread(() -> { + try (DatagramSocket socket = new DatagramSocket()) { + socket.setBroadcast(true); + byte[] data = "DISCOVER_SERVER".getBytes(); + DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName("255.255.255.255"), discoveryPort); + socket.send(packet); + + socket.setSoTimeout(timeoutMs); + byte[] buf = new byte[512]; + DatagramPacket resp = new DatagramPacket(buf, buf.length); + socket.receive(resp); + + String msg = new String(resp.getData(), 0, resp.getLength()); + if (msg.startsWith("SERVER_FOUND")) { + // format: SERVER_FOUND:serverName:ip + String[] parts = msg.split(":", 3); + if (parts.length >= 3) { + listener.onFound(parts[2], parts[1]); + return; + } + } + listener.onError(new Exception("Invalid response")); + } catch (Exception e) { + listener.onError(e); + } + }).start(); + } +} diff --git a/app/src/main/java/com/sw/dualscreen/socket/test.java b/app/src/main/java/com/sw/dualscreen/socket/test.java new file mode 100644 index 0000000..c16b25e --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/socket/test.java @@ -0,0 +1,113 @@ +package com.sw.dualscreen.socket; + +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; + +public class test { + LanCommunicationManager manager = new LanCommunicationManager( + 9999, // 端口 + 20, // 最大客户端线程 + 30_000 // 心跳超时 30 秒 + ); + + public void test() { + manager.setListener(new LanCommunicationManager.Listener() { + @Override + public void onClientConnected(String clientId) { + System.out.println("新的客户端上线:" + clientId); + } + + @Override + public void onClientDisconnected(String clientId) { + System.out.println("客户端离线:" + clientId); + } + + @Override + public void onMessageReceived(String clientId, JSONObject msg) { + System.out.println("收到 " + clientId + " 的消息:" + msg); + } + }); + +// 启动 + try { + manager.start(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // 给某个客户端发送 + JSONObject j = new JSONObject(); + try { + j.put("type", "cmd"); + j.put("content", "hello"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + manager.sendToClient("device123", j); + +// 广播 + manager.broadcast(j); + } + + + public void clientTets() { + // 1) discover server (optional) + UdpDiscoveryClient disc = new UdpDiscoveryClient(); + disc.discover(9876, 3000, new UdpDiscoveryClient.Listener() { + @Override + public void onFound(String ip, String serverName) { + startClient(ip); + } + + @Override + public void onError(Exception e) { /* fallback to manual IP */ } + }); + + + } + + // 2) start client + private TcpClient client; + + private void startClient(String serverIp) { + client = new TcpClient( + serverIp, + 9999, + "device123", // clientId + 5000, // connectTimeoutMs + 10_000, // heartbeatIntervalMs + 30_000 // heartbeatTimeoutMs + ); + + client.setListener(new TcpClient.Listener() { + @Override + public void onConnected() { + Log.i("APP", "connected"); + } + + @Override + public void onDisconnected(Exception e) { + Log.i("APP", "disconnected", e); + } + + @Override + public void onMessage(JSONObject json) { + Log.i("APP", "msg:" + json); + } + + @Override + public void onSendSuccess(JSONObject json) { + } + + @Override + public void onSendFailed(JSONObject json, Exception e) { + } + }); + + client.start(); + } +} diff --git a/app/src/main/java/com/sw/dualscreen/utils/RemoveZeroUtils.kt b/app/src/main/java/com/sw/dualscreen/utils/RemoveZeroUtils.kt new file mode 100644 index 0000000..b7904eb --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/utils/RemoveZeroUtils.kt @@ -0,0 +1,66 @@ +package com.sw.dualscreen.utils + +import java.math.RoundingMode +import java.text.DecimalFormat +import kotlin.text.contains +import kotlin.text.format +import kotlin.text.replace + +/** + * 去除小数点后无效零的工具类 + * 功能示例: + * 1230.00 => 1230 + * 1230.10 => 1230.1 + * 3.40 => 3.4 + * 3.0 => 3 + */ +class RemoveZeroUtils { + + companion object { + + /** + * 方法1:使用字符串格式化(最简单) + * @param number 输入的double数值 + * @return 去除无效零后的字符串 + */ + fun removeZeroByFormat(number: Double): String { + return "%.10f".format(number) // 先格式化为固定小数位 + .replace(Regex("0*$"), "") // 移除末尾的零 + .replace(Regex("\\.$"), "") // 如果小数点后全为零,移除小数点 + } + + /** + * 方法2:使用DecimalFormat(推荐) + * @param number 输入的double数值 + * @return 去除无效零后的字符串 + */ + fun removeZeroByDecimalFormat(number: Double): String { + val format = DecimalFormat("0.##########") + format.roundingMode = RoundingMode.FLOOR + return format.format(number) + } + + /** + * 方法3:使用正则表达式处理字符串 + * @param number 输入的double数值 + * @return 去除无效零后的字符串 + */ + fun removeZeroByRegex(number: Double): String { + var str = number.toString() + + // 如果包含小数点,处理末尾的零 + if (str.contains(".")) { + str = str.replace(Regex("0+?$"), "") // 移除末尾的零 + .replace(Regex("[.]$"), "") // 如果小数点后全为零,移除小数点 + } + + return str + } + } +} + +// 扩展函数方式,更符合Kotlin风格 +fun Double?.removeTrailingZeros(): String { + if (this == null) return "" + return RemoveZeroUtils.removeZeroByDecimalFormat(this) +} diff --git a/app/src/main/java/com/sw/dualscreen/utils/SpTool.java b/app/src/main/java/com/sw/dualscreen/utils/SpTool.java new file mode 100644 index 0000000..92340b8 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/utils/SpTool.java @@ -0,0 +1,19 @@ +package com.sw.dualscreen.utils; + +import com.sw.dualscreen.MyApp; + +public class SpTool { + + public static final String LAST_FACE_TIMESTAMP = "faceTimestamp"; + + public static long getLastFaceTimestamp() { + return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(LAST_FACE_TIMESTAMP, 0L); + } + + public static void setLastFaceTimestamp(long timestamp) { + SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(LAST_FACE_TIMESTAMP, timestamp); + } + + + +} diff --git a/app/src/main/java/com/sw/dualscreen/utils/StorageUtils.kt b/app/src/main/java/com/sw/dualscreen/utils/StorageUtils.kt index 484247e..dfb4631 100644 --- a/app/src/main/java/com/sw/dualscreen/utils/StorageUtils.kt +++ b/app/src/main/java/com/sw/dualscreen/utils/StorageUtils.kt @@ -9,21 +9,21 @@ object StorageUtils { activity.requestSinglePermissionResult(Manifest.permission.WRITE_EXTERNAL_STORAGE) { callback() } -// activity.requestSinglePermissionResult(Manifest.permission.CAMERA) { -//// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { -//// if (Environment.isExternalStorageManager()) { -//// callback() -//// } else { -//// val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) -////// activity.startActivityForResult(intent, MANAGE_REQUEST_CODE) -//// activity.launchActivityResult(intent) { -//// callback() -//// } -//// } -//// } else { -// -//// } -// } + activity.requestSinglePermissionResult(Manifest.permission.CAMERA) { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { +// if (Environment.isExternalStorageManager()) { +// callback() +// } else { +// val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) +//// activity.startActivityForResult(intent, MANAGE_REQUEST_CODE) +// activity.launchActivityResult(intent) { +// callback() +// } +// } +// } else { + +// } + } } fun requestCameraPermission(activity: BaseActivity<*>, callback: () -> Unit) { diff --git a/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt b/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt index acda513..699cf0d 100644 --- a/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt +++ b/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt @@ -5,6 +5,7 @@ import com.arcsoft.face.ErrorInfo import com.sw.dualscreen.GlobalData import com.sw.dualscreen.GlobalKey import com.sw.dualscreen.model.request.UserNutritionParam +import com.sw.dualscreen.model.response.DeviceConfig import com.sw.dualscreen.model.response.DinnerType import com.sw.dualscreen.model.response.FoodInfo import com.sw.dualscreen.model.response.FoodOrder @@ -14,6 +15,7 @@ import com.sw.dualscreen.model.response.UserFaceModel2 import com.sw.dualscreen.model.response.UserNutrition import com.sw.dualscreen.objbox.CollectedFoodInfo import com.sw.dualscreen.utils.SPUtil +import com.sw.dualscreen.utils.SpTool import com.sw.plate.App import com.sw.plate.utils.Base64 import com.sw.plate.utils.ToastUtils @@ -98,6 +100,7 @@ class UserViewModel : BaseViewModel() { ToastUtils.showToast("查询人脸数据为空") return@withContext } + SpTool.setLastFaceTimestamp(System.currentTimeMillis()) // val item = list.firstOrNull { it.userId == "1951105919342936066" } // Timber.tag(TAG).d("getUserFaceCache userId = ${item?.userId}") val faceEntity = list.map { @@ -146,6 +149,30 @@ class UserViewModel : BaseViewModel() { // } // } // } + /** + * 获取人脸数据 + */ + fun getFaceIncrementList( + pageNo: Int = 1, + pageSize: Int = PAGE_SIZE, + timestamp: Long, + block: (List) -> Unit + ) { + Timber.tag(TAG).d("getFaceIncrementList") + launch { + val response = repository.getFaceIncrementList( + pageNum = pageNo.toLong(), + pageSize = pageSize.toLong(), + timestamp = timestamp + ) + if (parseResponse(response)) { + withContext(Dispatchers.Default) { + val list: List = response.data ?: emptyList() + block(list) + } + } + } + } fun getCollectedFoodList( pageNo: Int = 1, @@ -426,7 +453,7 @@ class UserViewModel : BaseViewModel() { fun bindOrder(userId: String, orderId: String, mode: Int, block: (Boolean) -> Unit) { Timber.tag(TAG).d("bindOrder") launchWithLoading { - val response = repository.bindOrder(userId = userId, orderId = orderId, mode =mode) + val response = repository.bindOrder(userId = userId, orderId = orderId, mode = mode) if (parseResponse(response)) { block(true) } else { @@ -492,4 +519,16 @@ class UserViewModel : BaseViewModel() { // } return resp.code == "00000" } + + fun getDeviceConfig(block: (DeviceConfig?) -> Unit) { + Timber.tag(TAG).d("getDeviceConfig") + launchWithLoading { + val response = repository.getDeviceConfig() + if (parseResponse(response)) { + block(response.data) + } else { + block(null) + } + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_number_pay.xml b/app/src/main/res/layout/fragment_number_pay.xml index 6e37b06..54467d8 100644 --- a/app/src/main/res/layout/fragment_number_pay.xml +++ b/app/src/main/res/layout/fragment_number_pay.xml @@ -39,7 +39,7 @@ android:layout_width="match_parent" android:layout_height="100dp" android:layout_marginHorizontal="64dp" - android:inputType="text" + android:inputType="textVisiblePassword" android:background="@drawable/setting_border_gray3" android:hint="输入消费码" android:textColorHint="#ffc4cfda" @@ -48,6 +48,7 @@ android:textSize="36sp" android:gravity="center" android:maxLines="1" + android:text="" android:paddingHorizontal="50dp" android:textStyle="bold" tools:ignore="Autofill,HardcodedText,LabelFor,TextFields" /> diff --git a/lib_face/build.gradle.kts b/lib_face/build.gradle.kts index 449f75b..76adfec 100644 --- a/lib_face/build.gradle.kts +++ b/lib_face/build.gradle.kts @@ -56,7 +56,7 @@ dependencies { // implementation("com.licheedev:android-serialport:2.1.5") val roomVersion = "2.2.5" - implementation("androidx.room:room-runtime:$roomVersion") + api("androidx.room:room-runtime:$roomVersion") annotationProcessor("androidx.room:room-compiler:$roomVersion") implementation("io.reactivex.rxjava2:rxandroid:2.0.1") diff --git a/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java b/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java index 4b3a682..50091bf 100644 --- a/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java +++ b/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java @@ -88,4 +88,7 @@ public interface FaceDao { @Query("UPDATE sqlite_sequence SET seq = 0 WHERE name ='face'") void resetId(); + + @Query("SELECT * FROM face WHERE user_name = :userName limit 1") + FaceEntity queryByUserName(String userName); } diff --git a/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/entity/FaceEntity.java b/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/entity/FaceEntity.java index aac56cb..27c8119 100644 --- a/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/entity/FaceEntity.java +++ b/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/entity/FaceEntity.java @@ -2,9 +2,11 @@ package com.sw.plate.utils.arcface.facedb.entity; import android.os.Parcel; import android.os.Parcelable; +import android.text.TextUtils; import androidx.room.ColumnInfo; import androidx.room.Entity; +import androidx.room.Ignore; import androidx.room.PrimaryKey; import java.util.Arrays; @@ -42,7 +44,14 @@ public class FaceEntity implements Parcelable { */ @ColumnInfo(name = "register_time") private long registerTime; + /** + * 用户类型:1-普通会员、2-临时用户、3-内部员工、或者其它待定类型 + */ + @ColumnInfo(name = "user_type") + private String userType; + @Ignore + private int trackId;//人脸追踪ID public FaceEntity(String userName, String imagePath, byte[] featureData) { this.userName = userName; @@ -120,6 +129,22 @@ public class FaceEntity implements Parcelable { this.registerTime = registerTime; } + public int getTrackId() { + return trackId; + } + + public void setTrackId(int trackId) { + this.trackId = trackId; + } + + public String getUserType() { + return userType; + } + + public void setUserType(String userType) { + this.userType = userType; + } + @Override public int describeContents() { return 0; @@ -132,8 +157,11 @@ public class FaceEntity implements Parcelable { dest.writeString(userName); dest.writeString(imagePath); dest.writeByteArray(featureData); + dest.writeString(userType); } + + @Override public boolean equals(Object o) { if (this == o) { @@ -143,16 +171,17 @@ public class FaceEntity implements Parcelable { return false; } FaceEntity that = (FaceEntity) o; - return faceId == that.faceId && - registerTime == that.registerTime && - userName.equals(that.userName) && - imagePath.equals(that.imagePath) && - Arrays.equals(featureData, that.featureData); + return this.faceId == that.faceId && + this.registerTime == that.registerTime && + TextUtils.equals(this.userName, that.userName) && + TextUtils.equals(this.imagePath, that.imagePath) && + Arrays.equals(featureData, that.featureData) && + TextUtils.equals(this.userType, that.userType); } @Override public int hashCode() { - int result = Objects.hash(faceId, registerTime, userName, imagePath); + int result = Objects.hash(faceId, registerTime, userName, imagePath, userType); result = 31 * result + Arrays.hashCode(featureData); return result; }