diff --git a/app/src/main/java/com/shuwei/dish/match/base/BaseActivity.kt b/app/src/main/java/com/shuwei/dish/match/base/BaseActivity.kt index 4aa8227..831b77a 100644 --- a/app/src/main/java/com/shuwei/dish/match/base/BaseActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/base/BaseActivity.kt @@ -247,16 +247,19 @@ open class BaseActivity : AppCompatActivity() { /** * 隐藏状态栏和导航栏 * 使用 WindowInsetsControllerCompat(Androidx 推荐方式,向下兼容性好) - * - 状态栏:完全隐藏 - * - 导航栏:完全隐藏,底部上滑时临时出现,松手后自动收回(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE) + * - setDecorFitsSystemWindows 只在首次调用时设置,避免反复触发布局重绘 + * (重绘会导致 RecyclerView 滑动时抛出 "view is not a child, cannot hide" 异常) + * - 每次调用仍重新执行 hide(),保证系统栏在滑出后能被重新隐藏 */ + private var systemBarsConfigured = false private fun hideSystemBars() { - // 允许布局延伸到系统栏区域(edge-to-edge),避免内容被系统栏遮挡 - WindowCompat.setDecorFitsSystemWindows(window, false) + if (!systemBarsConfigured) { + // 只执行一次:允许布局延伸到系统栏区域 + WindowCompat.setDecorFitsSystemWindows(window, false) + systemBarsConfigured = true + } WindowInsetsControllerCompat(window, window.decorView).apply { - // 同时隐藏状态栏和导航栏 hide(WindowInsetsCompat.Type.systemBars()) - // 底部上滑时临时显示导航栏,松手后自动隐藏(沉浸式 Sticky 模式) systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } diff --git a/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt b/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt index c16317d..e403b33 100644 --- a/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt +++ b/app/src/main/java/com/shuwei/dish/match/base/BaseApp.kt @@ -14,6 +14,7 @@ import com.shuwei.dish.match.scale.ScaleServiceManager import com.shuwei.dish.match.ui.InitActivity.Companion.TAG import com.shuwei.dish.match.utils.AppUtil import com.shuwei.dish.match.utils.BootReceiver +import com.shuwei.dish.match.utils.CrashHandler class BaseApp : Application() { @@ -23,6 +24,7 @@ class BaseApp : Application() { override fun onCreate() { super.onCreate() instance = this + CrashHandler.init(this) val deviceId = AppUtil.getUDID(this) Log.d(TAG, "onCreate: deviceId=$deviceId") GlobalData.deviceId = deviceId diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt index 2d9f731..4f097a5 100644 --- a/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt @@ -1,10 +1,10 @@ package com.shuwei.dish.match.scale import android.content.Context -import android.net.wifi.WifiManager import android.util.Log import com.shuwei.dish.match.base.DeviceRole import com.shuwei.dish.match.base.GlobalData +import com.shuwei.dish.match.utils.NetworkUtil import kotlinx.coroutines.flow.StateFlow /** @@ -70,17 +70,10 @@ object ScaleServiceManager { } /** - * 通过 WifiManager 获取本机 WiFi IP 地址 + * 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容 */ private fun getLocalIp(context: Context): String { - return try { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val ip = wm.connectionInfo.ipAddress - if (ip == 0) "" else - "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}" - } catch (e: Exception) { - "" - } + return NetworkUtil.getLocalIpAddress(context) } /** diff --git a/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt index 5b398e8..55a28f9 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt @@ -14,6 +14,7 @@ import com.shuwei.dish.match.databinding.ActivityMasterScaleBinding import com.shuwei.dish.match.databinding.ListItemScaleDataBinding import com.shuwei.dish.match.scale.ScaleData import com.shuwei.dish.match.scale.ScaleServiceManager +import com.shuwei.dish.match.utils.NetworkUtil import com.shuwei.dish.match.utils.WeightUtil import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -50,6 +51,7 @@ class MasterScaleActivity : BaseActivity() { setHeaderBackground() binding.rvScaleList.layoutManager = LinearLayoutManager(this) + binding.rvScaleList.itemAnimator = null // 关闭默认动画,避免数据频繁更新时出现闪烁错乱 binding.rvScaleList.adapter = adapter observeScaleData() @@ -66,6 +68,8 @@ class MasterScaleActivity : BaseActivity() { flow.collectLatest { scaleMap -> // 按 deviceId 分组,本机设备排最前,其余按 deviceId 排序 val localId = com.shuwei.dish.match.base.GlobalData.deviceId + // 本机 IP 实时读取,避免 App 启动时网络未就绪导致缓存为空 + val localIp = NetworkUtil.getLocalIpAddress(this@MasterScaleActivity) val groups = scaleMap.values .groupBy { it.deviceId } .entries @@ -73,7 +77,8 @@ class MasterScaleActivity : BaseActivity() { .map { (deviceId, scales) -> DeviceGroup( deviceId = deviceId, - ip = scales.firstOrNull()?.ip ?: "", + ip = if (deviceId == localId) localIp + else scales.firstOrNull { it.ip.isNotEmpty() }?.ip ?: "", scales = scales.sortedBy { it.address } ) } diff --git a/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt index 7e28133..45e41ec 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt @@ -1,7 +1,6 @@ package com.shuwei.dish.match.ui import android.annotation.SuppressLint -import android.net.wifi.WifiManager import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -12,6 +11,7 @@ import com.shuwei.dish.match.base.GlobalData import com.shuwei.dish.match.databinding.ActivitySlaveBinding import com.shuwei.dish.match.databinding.ListItemScaleRowBinding import com.shuwei.dish.match.scale.ScaleServiceManager +import com.shuwei.dish.match.utils.NetworkUtil import com.shuwei.dish.match.utils.WeightUtil /** @@ -41,6 +41,7 @@ class SlaveActivity : BaseActivity() { binding.tvDeviceId.text = "设备:${GlobalData.deviceId}" binding.tvDeviceIp.text = "IP:${getLocalIpAddress()}" binding.rvScaleList.layoutManager = LinearLayoutManager(this) + binding.rvScaleList.itemAnimator = null // 关闭默认动画,避免数据频繁更新时出现闪烁错乱 binding.rvScaleList.adapter = adapter updateMasterStatus(false) @@ -101,19 +102,11 @@ class SlaveActivity : BaseActivity() { } /** - * 获取本机 WiFi IP 地址 - * 优先使用 WifiManager,获取失败时返回"未知" + * 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容 + * 获取失败时返回"未知" */ - @SuppressLint("WifiManagerLeak") private fun getLocalIpAddress(): String { - return try { - val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager - val ip = wifiManager.connectionInfo.ipAddress - if (ip == 0) "未知" else - "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}" - } catch (e: Exception) { - "未知" - } + return NetworkUtil.getLocalIpAddress(this).ifEmpty { "未知" } } private class ScaleAdapter(private val data: List) : diff --git a/app/src/main/java/com/shuwei/dish/match/utils/CrashHandler.kt b/app/src/main/java/com/shuwei/dish/match/utils/CrashHandler.kt new file mode 100644 index 0000000..922b7ae --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/utils/CrashHandler.kt @@ -0,0 +1,235 @@ +package com.shuwei.dish.match.utils + +import android.app.ActivityManager +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.util.Log +import com.shuwei.dish.match.ui.HomeActivity +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.system.exitProcess + +/** + * 崩溃处理 + */ +class CrashHandler private constructor(private val context: Context) : + Thread.UncaughtExceptionHandler { + + companion object { + private const val TAG = "CrashHandler" + private const val CRASH_REPORTS_DIR = "crash_reports" + private const val LOG_LINES = 500 // 收集最近500行日志 + + @Volatile + private var instance: CrashHandler? = null + + fun init(context: Context) { + if (instance == null) { + synchronized(CrashHandler::class.java) { + if (instance == null) { + instance = CrashHandler(context.applicationContext) + } + } + } + } + + fun getCrashReportFiles(context: Context): Array { + val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR) + return if (crashDir.exists() && crashDir.isDirectory) { + crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray() + } else { + emptyArray() + } + } + + fun clearCrashReports(context: Context) { + getCrashReportFiles(context).forEach { it.delete() } + } + } + + private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + init { + Thread.setDefaultUncaughtExceptionHandler(this) + } + + override fun uncaughtException(thread: Thread, ex: Throwable) { + handleException(thread, ex) + } + + /** + * 自动重启app + */ + private fun restartApp() { + // 延迟1秒后重启应用 + Handler(Looper.getMainLooper()).postDelayed({ + val intent = Intent(context, HomeActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent) + + Process.killProcess(Process.myPid()) + exitProcess(1) + }, 1000) + } + + private fun handleException(thread: Thread, ex: Throwable) { + // 收集设备信息和异常信息 + val crashInfo = collectCrashInfo(thread, ex) + + // 保存日志文件 + saveCrashInfoToFile(crashInfo) + + // 这里可以添加其他处理逻辑,比如上传到服务器等 + restartApp() + } + + private fun collectCrashInfo(thread: Thread, ex: Throwable): String { + return buildString { + // 收集设备信息 + collectDeviceInfo(this) + + // 收集应用日志 + try { + append("\n\n").append(collectLogs()) + } catch (e: OutOfMemoryError) { + append("collectLogs发生OutOfMemoryError:${e.message}") + } + + // 收集线程和异常信息 + append("\n\n========== Thread & Exception Info ==========\n") + append("Thread: ${thread.name}\n") + append("Stack Trace:\n") + + val sw = StringWriter() + val pw = PrintWriter(sw) + ex.printStackTrace(pw) + var cause: Throwable? = ex.cause + while (cause != null) { + cause.printStackTrace(pw) + cause = cause.cause + } + pw.close() + append(sw.toString()) + } + } + + private fun collectDeviceInfo(sb: StringBuilder) { + sb.append("========== Device Info ==========\n") + try { + // 应用信息 + val pm = context.packageManager + val pi = pm.getPackageInfo(context.packageName, 0) + + sb.append("App Version: ${pi.versionName}_${pi.versionCode}\n") + + // Android 版本信息 + sb.append("OS Version: ${Build.VERSION.RELEASE}_${Build.VERSION.SDK_INT}\n") + + // 设备信息 + sb.append("Vendor: ${Build.MANUFACTURER}\n") + sb.append("Model: ${Build.MODEL}\n") + sb.append("CPU ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}\n") + + // 其他信息 + sb.append("Locale: ${Locale.getDefault()}\n") + sb.append( + "Current Time: ${ + SimpleDateFormat( + "yyyy-MM-dd HH:mm:ss", + Locale.getDefault() + ).format(Date()) + }\n" + ) + + // 内存信息 + val memoryInfo = ActivityManager.MemoryInfo() + val activityManager = + context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + activityManager.getMemoryInfo(memoryInfo) + sb.append("Available Memory: ${memoryInfo.availMem / (1024 * 1024)}MB\n") + sb.append("Total Memory: ${memoryInfo.totalMem / (1024 * 1024)}MB\n") + sb.append("Low Memory: ${memoryInfo.lowMemory}\n") + + } catch (e: Exception) { + Log.e(TAG, "Error while collecting device info", e) + sb.append("Error while collecting device info: ${e.message}\n") + } + } + + private fun collectLogs(): String { + return buildString { + append("========== Application Logs ==========\n") + try { + val process = Runtime.getRuntime().exec("logcat -d -v threadtime") + val reader = process.inputStream.bufferedReader() + + val logLines = reader.readLines() + val start = kotlin.comparisons.maxOf(0, logLines.size - LOG_LINES) + + logLines.subList(start, logLines.size).forEach { + append(it).append("\n") + } + } catch (e: IOException) { + Log.e(TAG, "Error collecting logs", e) + append("Error collecting logs: ${e.message}\n") + } + } + } + + private fun saveCrashInfoToFile(crashInfo: String) { + try { + val time = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date()) + val fileName = "crash_$time.log" + + val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR) + if (!crashDir.exists() && !crashDir.mkdirs()) { + Log.d(TAG,"Failed to create crash report directory") + return + } + + val crashFile = File(crashDir, fileName) + FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) } + + Log.d(TAG,"Crash info saved to: ${crashFile.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Error saving crash info to file", e) + } + } + + /** + * 清理旧的崩溃日志 + */ + fun cleanupOldCrashReports(maxAgeDays: Int = 7) { + val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR) + if (!crashDir.exists() || !crashDir.isDirectory) return + + val now = System.currentTimeMillis() + val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L + + crashDir.listFiles()?.forEach { file -> + if (file.lastModified() < now - maxAgeMillis) { + file.delete() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/shuwei/dish/match/utils/NetworkUtil.kt b/app/src/main/java/com/shuwei/dish/match/utils/NetworkUtil.kt new file mode 100644 index 0000000..3a4ccd6 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/utils/NetworkUtil.kt @@ -0,0 +1,62 @@ +package com.shuwei.dish.match.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.wifi.WifiManager +import android.os.Build +import java.net.Inet4Address + +/** + * 网络工具类 + */ +object NetworkUtil { + + /** + * 获取本机局域网 IPv4 地址 + * - Android 12+(API 31+):使用 ConnectivityManager + LinkProperties,避免废弃 API + * - Android 12 以下:回退到 WifiManager.connectionInfo.ipAddress + * @return IP 字符串,获取失败返回空字符串 + */ + fun getLocalIpAddress(context: Context): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + getIpViaConnectivityManager(context) + } else { + getIpViaWifiManager(context) + } + } + + /** + * Android 12+ 方式:通过 ConnectivityManager 获取活跃网络的 LinkProperties + */ + private fun getIpViaConnectivityManager(context: Context): String { + return try { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = cm.activeNetwork ?: return "" + val props: LinkProperties = cm.getLinkProperties(network) ?: return "" + props.linkAddresses + .map { it.address } + .filterIsInstance() + .firstOrNull { !it.isLoopbackAddress } + ?.hostAddress ?: "" + } catch (e: Exception) { + "" + } + } + + /** + * Android 12 以下方式:通过 WifiManager 获取 IP(仅 WiFi 有效) + */ + @Suppress("DEPRECATION") + private fun getIpViaWifiManager(context: Context): String { + return try { + val wm = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager + val ip = wm.connectionInfo.ipAddress + if (ip == 0) "" + else "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}" + } catch (e: Exception) { + "" + } + } +}