From abccda0bd6ca58df3c6f0a9c5c05c3ab25559a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Tue, 1 Sep 2026 15:18:05 +0800 Subject: [PATCH] 1 --- .../com/example/observer/InstallerChannel.kt | 61 ++++++- .../com/example/observer/MainActivity.kt | 47 +++++ flutter_app/lib/auth/auth_screen.dart | 172 ++++++++++-------- flutter_app/lib/camera/camera_screen.dart | 2 +- flutter_app/lib/camera/camera_view_model.dart | 9 +- flutter_app/lib/camera/detection_overlay.dart | 20 +- flutter_app/lib/config/app_version.dart | 11 ++ .../lib/detection/detection_result.dart | 5 + .../lib/detection/detector_worker.dart | 2 + .../lib/detection/tflite_detector.dart | 1 + flutter_app/lib/detection/visual_prior.dart | 3 +- flutter_app/lib/home/home_screen.dart | 122 +++++++------ flutter_app/lib/update/update_screen.dart | 32 +++- flutter_app/pubspec.lock | 9 +- flutter_app/pubspec.yaml | 9 +- .../lib/camera_android_camerax.dart | 6 + .../camera_android_camerax/pubspec.yaml | 14 ++ server/data/observer.db | Bin 425984 -> 425984 bytes server/main.go | 16 +- 19 files changed, 381 insertions(+), 160 deletions(-) create mode 100644 flutter_app/lib/config/app_version.dart create mode 100644 flutter_app/third_party/camera_android_camerax/lib/camera_android_camerax.dart create mode 100644 flutter_app/third_party/camera_android_camerax/pubspec.yaml diff --git a/flutter_app/android/app/src/main/kotlin/com/example/observer/InstallerChannel.kt b/flutter_app/android/app/src/main/kotlin/com/example/observer/InstallerChannel.kt index 2381207..c78ce4f 100644 --- a/flutter_app/android/app/src/main/kotlin/com/example/observer/InstallerChannel.kt +++ b/flutter_app/android/app/src/main/kotlin/com/example/observer/InstallerChannel.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.provider.Settings +import android.util.Log import java.io.File import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -36,6 +37,11 @@ class InstallerChannel( private var progressSink: EventChannel.EventSink? = null private var activeSession: PackageInstaller.Session? = null + /// 安装完成轮询兜底开关:部分 ROM(如 MIUI)点确认后不回调 + /// onProgressChanged/onFinished,仅靠回调会永久停在「安装中」。 + @Volatile + private var installSettled = false + fun register() { method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result -> when (call.method) { @@ -55,7 +61,9 @@ class InstallerChannel( } private fun install(path: String, result: MethodChannel.Result) { + installSettled = false val file = File(path) + Log.d(TAG, "install: path=$path exists=${file.exists()} len=${file.length()}") if (!file.exists()) { result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null) return @@ -63,6 +71,7 @@ class InstallerChannel( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !activity.packageManager.canRequestPackageInstalls() ) { + Log.d(TAG, "install: permission_required") val intent = Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${activity.packageName}"), @@ -79,6 +88,7 @@ class InstallerChannel( val sessionId = pm.packageInstaller.createSession(params) val session = pm.packageInstaller.openSession(sessionId) activeSession = session + Log.d(TAG, "install: session=$sessionId opened") // API 36 起 registerSessionCallback(int, ...) 变体被移除,只剩全局注册形式, // 回调按 sessionId 过滤,避免响应其他会话事件 val callback = object : PackageInstaller.SessionCallback() { @@ -90,20 +100,30 @@ class InstallerChannel( override fun onProgressChanged(id: Int, progressPercent: Float) { if (id != sessionId) return + Log.d(TAG, "callback onProgressChanged=$progressPercent") emit("progress", "progress" to progressPercent.toInt()) } override fun onFinished(id: Int, success: Boolean) { if (id != sessionId) return + Log.d(TAG, "callback onFinished success=$success") + installSettled = true emit("finished", "success" to success) pm.packageInstaller.unregisterSessionCallback(this) activeSession = null } } pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper())) + // 安装前已装版本号(更新安装成功后必然变化,轮询兜底依据) + val installedCode = try { + pm.getPackageInfo(activity.packageName, 0).versionCode + } catch (e: Exception) { + -1 + } // 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框 Thread { try { + Log.d(TAG, "writeThread: start") session.openWrite("apk", 0, file.length()).use { out -> file.inputStream().use { input -> val buf = ByteArray(1 shl 20) @@ -114,19 +134,47 @@ class InstallerChannel( } } } + Log.d(TAG, "writeThread: written, commit") + // commit 的 status receiver 必须 MUTABLE:系统要向 intent 写入安装结果, + // FLAG_IMMUTABLE 直接抛 "The commit() status receiver should come from a + // mutable PendingIntent"(模拟器 AOSP 严格校验;部分 ROM 不校验但回调丢失) val sender = PendingIntent.getActivity( activity, 0, Intent(activity, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, ).intentSender session.commit(sender) } catch (e: Exception) { + Log.e(TAG, "writeThread: failed", e) session.abandon() emit("failed", "error" to (e.message ?: "安装会话写入失败")) activeSession = null } }.start() + // 完成轮询兜底:回调缺失时靠版本号变化判定安装成功(2s 间隔,最长 120s) + Thread { + var waited = 0 + while (!installSettled && waited < 120_000) { + Thread.sleep(2000) + waited += 2000 + val now = try { + pm.getPackageInfo(activity.packageName, 0).versionCode + } catch (e: Exception) { + -1 + } + if (now != installedCode && now != -1) { + Log.d(TAG, "poll: versionCode changed $installedCode->$now, settled") + installSettled = true + emit("finished", "success" to true) + return@Thread + } + } + if (!installSettled) { + Log.d(TAG, "poll: timeout after 120s, not settled") + emit("failed", "error" to "安装超时,请重试") + } + }.start() result.success("installing") } catch (e: Exception) { result.error("INSTALL_FAILED", e.message, null) @@ -134,6 +182,15 @@ class InstallerChannel( } private fun emit(event: String, vararg pairs: Pair) { - progressSink?.success(mapOf("event" to event, *pairs)) + // EventSink.success 内部 dispatchPlatformMessage 要求主线程(@UiThread 校验), + // 写 APK 线程 / 轮询线程直接调用会崩溃,必须投递主线程 + Handler(Looper.getMainLooper()).post { + Log.d(TAG, "emit: $event $pairs") + progressSink?.success(mapOf("event" to event, *pairs)) + } + } + + companion object { + private const val TAG = "InstallerChannel" } } diff --git a/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt index fed93b4..47b17ca 100644 --- a/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt @@ -1,5 +1,10 @@ package com.example.observer +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.os.Bundle +import android.util.Log import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -7,6 +12,44 @@ class MainActivity : FlutterActivity() { private var cameraChannel: CameraChannel? = null private var installerChannel: InstallerChannel? = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleInstallIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleInstallIntent(intent) + } + + /** + * Android 14+ 安装确认回调:PackageInstaller 会话 commit 后如需要用户确认 + * (确认对话框/安装未知来源授权),系统不直接弹 packageinstaller 界面, + * 而是经 commit 传入的 statusReceiver(指向本 Activity 的 PendingIntent) + * 回传 STATUS_PENDING_USER_ACTION,并在 EXTRA_INTENT 里附上确认界面 Intent + * (ACTION_CONFIRM_INSTALL → InstallStart),由调用方负责启动。 + * 不处理则确认框永不出现,安装卡在 0.8% 直至超时(AOSP 见 + * PackageInstallerSession.sendOnUserActionRequired)。 + */ + private fun handleInstallIntent(intent: Intent?) { + val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) ?: return + if (status != PackageInstaller.STATUS_PENDING_USER_ACTION) return + val confirm = if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + if (confirm == null) return + try { + confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(confirm) + Log.d(TAG, "handleInstallIntent: started confirm ${confirm.component}") + } catch (e: Exception) { + Log.e(TAG, "handleInstallIntent: start confirm failed", e) + } + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) // 自写原生相机(替代 camera_android_camerax 插件): @@ -18,6 +61,10 @@ class MainActivity : FlutterActivity() { installerChannel = InstallerChannel(this, flutterEngine).also { it.register() } } + companion object { + private const val TAG = "MainActivity" + } + override fun onDestroy() { cameraChannel?.destroy() super.onDestroy() diff --git a/flutter_app/lib/auth/auth_screen.dart b/flutter_app/lib/auth/auth_screen.dart index 8794f47..ef67221 100644 --- a/flutter_app/lib/auth/auth_screen.dart +++ b/flutter_app/lib/auth/auth_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../config/app_version.dart'; import 'auth_view_model.dart'; /// 登录/注册页:手机号 + 密码;注册成功后自动登录。 @@ -51,85 +52,104 @@ class _AuthScreenState extends State { return Scaffold( body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.pets, size: 64, color: Colors.green), - const SizedBox(height: 12), - const Text( - '视野', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - '动物实时识别', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey.shade600), - ), - const SizedBox(height: 32), - TextField( - controller: _phoneCtrl, - keyboardType: TextInputType.phone, - maxLength: 11, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - labelText: '手机号', - prefixIcon: Icon(Icons.phone_android), - border: OutlineInputBorder(), - counterText: '', + child: Column( + children: [ + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.pets, size: 64, color: Colors.green), + const SizedBox(height: 12), + const Text( + '视野', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + '动物实时识别', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + const SizedBox(height: 32), + TextField( + controller: _phoneCtrl, + keyboardType: TextInputType.phone, + maxLength: 11, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: '手机号', + prefixIcon: Icon(Icons.phone_android), + border: OutlineInputBorder(), + counterText: '', + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordCtrl, + obscureText: _obscure, + maxLength: 64, + decoration: InputDecoration( + labelText: '密码', + prefixIcon: const Icon(Icons.lock_outline), + border: const OutlineInputBorder(), + counterText: '', + suffixIcon: IconButton( + icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: vm.loading ? null : _submit, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: vm.loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isLogin ? '登录' : '注册并登录'), + ), + const SizedBox(height: 12), + TextButton( + onPressed: vm.loading ? null : vm.switchMode, + child: Text(isLogin ? '没有账号?去注册' : '已有账号?去登录'), + ), + if (vm.error != null) ...[ + const SizedBox(height: 12), + Text( + vm.error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ], + ], ), ), - const SizedBox(height: 16), - TextField( - controller: _passwordCtrl, - obscureText: _obscure, - maxLength: 64, - decoration: InputDecoration( - labelText: '密码', - prefixIcon: const Icon(Icons.lock_outline), - border: const OutlineInputBorder(), - counterText: '', - suffixIcon: IconButton( - icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility), - onPressed: () => setState(() => _obscure = !_obscure), - ), - ), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: vm.loading ? null : _submit, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - child: vm.loading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(isLogin ? '登录' : '注册并登录'), - ), - const SizedBox(height: 12), - TextButton( - onPressed: vm.loading ? null : vm.switchMode, - child: Text(isLogin ? '没有账号?去注册' : '已有账号?去登录'), - ), - if (vm.error != null) ...[ - const SizedBox(height: 12), - Text( - vm.error!, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), - ), - ], - ], + ), ), - ), + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: FutureBuilder( + future: appVersion(), + builder: (context, snapshot) => Text( + 'v${snapshot.data ?? ''}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + ), + ), + ), + ], ), ), ); diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart index 65ff49a..e95480b 100644 --- a/flutter_app/lib/camera/camera_screen.dart +++ b/flutter_app/lib/camera/camera_screen.dart @@ -91,7 +91,7 @@ class _CameraScreenState extends State { const SizedBox(height: 8), const Text( '阈值越低识别越灵敏(低分框越多,误报也可能增加);' - '环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。', + '不同模型的置信度分布不同,识别不到目标时可适当调低阈值。', style: TextStyle(color: Colors.white54, fontSize: 12), ), const SizedBox(height: 16), diff --git a/flutter_app/lib/camera/camera_view_model.dart b/flutter_app/lib/camera/camera_view_model.dart index 7acba72..75e0205 100644 --- a/flutter_app/lib/camera/camera_view_model.dart +++ b/flutter_app/lib/camera/camera_view_model.dart @@ -105,9 +105,10 @@ class CameraViewModel extends ChangeNotifier { visible.add(r.copyWith(confirmed: t.confirmed)); } - // 提醒:仅新确认的环颈雉鸡轨迹(确认瞬间触发一次,10s 同类冷却在 Reminder 内) + // 提醒:仅新确认的目标物种轨迹(class 0,如环颈雉鸡;确认瞬间触发一次,10s 同类冷却在 Reminder 内) for (final t in _tracks.values) { - if (t.label != 'pheasant' || !t.confirmed || t.reminded) continue; + final isSuspect = t.result.classId > 0 || t.label == 'suspect'; + if (isSuspect || !t.confirmed || t.reminded) continue; final age = now - t.firstSeenMs; if (age >= displayAgeMs && age <= displayAgeMs + 1600 && now - t.lastSeenMs <= 300) { @@ -184,7 +185,7 @@ class CameraViewModel extends ChangeNotifier { /// - 环颈雉鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示 bool _shouldDisplay(_Track t, List motionRegions, List noveltyRegions) { - if (t.label == 'suspect') return true; + if (t.result.classId > 0 || t.label == 'suspect') return true; if (t.confirmed) return true; return t.result.score >= highConf || _hasActivity(t.result, motionRegions, noveltyRegions); @@ -203,7 +204,7 @@ class CameraViewModel extends ChangeNotifier { if (w <= 0 || h <= 0) return false; final aspect = w / h; if (aspect < 0.3 || aspect > 3.0) return false; - if (r.label == 'suspect') return h >= 0.01 && h <= 0.5; + if (r.classId > 0 || r.label == 'suspect') return h >= 0.01 && h <= 0.5; return h >= 0.01 && h <= 0.3; } diff --git a/flutter_app/lib/camera/detection_overlay.dart b/flutter_app/lib/camera/detection_overlay.dart index a9afeaf..4bfba16 100644 --- a/flutter_app/lib/camera/detection_overlay.dart +++ b/flutter_app/lib/camera/detection_overlay.dart @@ -44,14 +44,11 @@ class _OverlayPainter extends CustomPainter { _OverlayPainter(this.results, this.rotation, this.imageWidthPx, this.imageHeightPx); - static const _colors = { - 'pheasant': Color(0xFFE53935), - 'suspect': Color(0xFFFDD835), - }; static const _labels = {'pheasant': '环颈雉鸡', 'suspect': '疑似'}; - /// 参考体型(米):环颈雉鸡身高 / 植被高度(参照旧 DistanceEstimator) - static const _refSizeM = {'pheasant': 0.45, 'suspect': 0.50}; + /// 参考体型(米):目标物种(class 0,如环颈雉鸡)身高 / suspect 植被高度 + static const double _refSizeSpeciesM = 0.45; + static const double _refSizeSuspectM = 0.50; /// iPhone 13 主摄在 1280 高预览下的估算焦距 px(5.1mm / 5.30mm 传感器), /// 单目误差 ±30%,仅作参考 @@ -72,8 +69,10 @@ class _OverlayPainter extends CustomPainter { size.width, size.height, ); - final color = _colors[r.label] ?? Colors.white; - final isSuspect = r.label == 'suspect'; + // 颜色按类别索引而非 label 文本:模型类别名可能为中文(环颈雉)或 + // 随数据集变化,class 0 恒为目标物种(红),其余类恒为 suspect(黄) + final isSuspect = r.classId > 0 || r.label == 'suspect'; + final color = isSuspect ? Color(0xFFFDD835) : Color(0xFFE53935); final confirmed = r.confirmed && !isSuspect; final paint = Paint() ..color = color.withValues(alpha: confirmed ? 1.0 : 0.55) @@ -113,8 +112,9 @@ class _OverlayPainter extends CustomPainter { } String _distanceLabel(DetectionResult r) { - final refH = _refSizeM[r.label]; - if (refH == null) return ''; + final refH = (r.classId > 0 || r.label == 'suspect') + ? _refSizeSuspectM + : _refSizeSpeciesM; final hPx = r.height * imageHeightPx; if (hPx < 8) return ''; final m = focalPx * refH / hPx; diff --git a/flutter_app/lib/config/app_version.dart b/flutter_app/lib/config/app_version.dart new file mode 100644 index 0000000..19424da --- /dev/null +++ b/flutter_app/lib/config/app_version.dart @@ -0,0 +1,11 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +String? _cached; + +/// 当前 App 版本号(一次获取后缓存,用于登录页/首页底部展示) +Future appVersion() async { + if (_cached != null) return _cached!; + final info = await PackageInfo.fromPlatform(); + _cached = info.version; + return _cached!; +} diff --git a/flutter_app/lib/detection/detection_result.dart b/flutter_app/lib/detection/detection_result.dart index ebca1c4..2d76131 100644 --- a/flutter_app/lib/detection/detection_result.dart +++ b/flutter_app/lib/detection/detection_result.dart @@ -9,6 +9,9 @@ class DetectionResult { /// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线 final bool confirmed; + /// 类别索引:0 = 目标物种(红色框),>0 = suspect(黄色框);-1 = 未知 + final int classId; + /// 产出该框的模型(数据集 id 与名称;无来源为 -1/空) final int modelId; final String modelName; @@ -21,6 +24,7 @@ class DetectionResult { required this.right, required this.bottom, this.confirmed = true, + this.classId = -1, this.modelId = -1, this.modelName = '', }); @@ -46,6 +50,7 @@ class DetectionResult { right: right ?? this.right, bottom: bottom ?? this.bottom, confirmed: confirmed ?? this.confirmed, + classId: classId, modelId: modelId, modelName: modelName, ); diff --git a/flutter_app/lib/detection/detector_worker.dart b/flutter_app/lib/detection/detector_worker.dart index 7c00b6f..b41b932 100644 --- a/flutter_app/lib/detection/detector_worker.dart +++ b/flutter_app/lib/detection/detector_worker.dart @@ -148,6 +148,7 @@ class DetectorWorker { bottom: v[5] as double, modelId: v.length > 6 ? (v[6] as num).toInt() : -1, modelName: v.length > 7 ? v[7] as String : '', + classId: v.length > 8 ? (v[8] as num).toInt() : -1, ); }).toList(); final motion = (list[5] as List) @@ -421,6 +422,7 @@ Future _workerMain(SendPort mainPort) async { r.bottom, r.modelId, r.modelName, + r.classId, ]) .toList(), motionRegions diff --git a/flutter_app/lib/detection/tflite_detector.dart b/flutter_app/lib/detection/tflite_detector.dart index 3b2767a..b4ecaf8 100644 --- a/flutter_app/lib/detection/tflite_detector.dart +++ b/flutter_app/lib/detection/tflite_detector.dart @@ -479,6 +479,7 @@ class TfliteDetector { top: (cy - h / 2).clamp(0.0, 1.0), right: (cx + w / 2).clamp(0.0, 1.0), bottom: (cy + h / 2).clamp(0.0, 1.0), + classId: bestCls, modelId: modelId, modelName: modelName, )); diff --git a/flutter_app/lib/detection/visual_prior.dart b/flutter_app/lib/detection/visual_prior.dart index ee8a3d8..91c1ce1 100644 --- a/flutter_app/lib/detection/visual_prior.dart +++ b/flutter_app/lib/detection/visual_prior.dart @@ -39,7 +39,8 @@ class VisualPrior { if (results.isEmpty || width <= 0 || height <= 0) return results; final kept = []; for (final r in results) { - final lowConfPheasant = r.label == 'pheasant' && r.score < maxScore; + final lowConfPheasant = + (r.classId == 0 || r.label == 'pheasant') && r.score < maxScore; if (lowConfPheasant && _reject(r, planes, strides, width, height, isBgra, rgbaOrder)) { continue; diff --git a/flutter_app/lib/home/home_screen.dart b/flutter_app/lib/home/home_screen.dart index 0191890..7a19ea8 100644 --- a/flutter_app/lib/home/home_screen.dart +++ b/flutter_app/lib/home/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../auth/session_store.dart'; +import '../config/app_version.dart'; import 'home_view_model.dart'; /// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口 @@ -104,61 +105,80 @@ class _HomeScreenState extends State { ], ), body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 24), - _StatusCard( - licenseText: statusText, - active: active, - ), - const SizedBox(height: 40), - SizedBox( - height: 88, - child: FilledButton.icon( - onPressed: vm.verifying ? null : _onSearch, - style: FilledButton.styleFrom( - backgroundColor: Colors.green, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(44), + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + _StatusCard( + licenseText: statusText, + active: active, ), - ), - icon: vm.verifying - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.visibility, size: 32), - label: Text( - vm.verifying ? '正在校验授权…' : '打开视野', - style: const TextStyle( - fontSize: 22, fontWeight: FontWeight.w600), + const SizedBox(height: 40), + SizedBox( + height: 88, + child: FilledButton.icon( + onPressed: vm.verifying ? null : _onSearch, + style: FilledButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(44), + ), + ), + icon: vm.verifying + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.visibility, size: 32), + label: Text( + vm.verifying ? '正在校验授权…' : '打开视野', + style: const TextStyle( + fontSize: 22, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 52, + child: OutlinedButton.icon( + onPressed: _onRecharge, + icon: const Icon(Icons.payment), + label: const Text('充值', style: TextStyle(fontSize: 16)), + ), + ), + if (vm.error != null && vm.license != null) ...[ + const SizedBox(height: 16), + Text( + vm.error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ], + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: FutureBuilder( + future: appVersion(), + builder: (context, snapshot) => Text( + 'v${snapshot.data ?? ''}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, ), ), ), - const SizedBox(height: 16), - SizedBox( - height: 52, - child: OutlinedButton.icon( - onPressed: _onRecharge, - icon: const Icon(Icons.payment), - label: const Text('充值', style: TextStyle(fontSize: 16)), - ), - ), - if (vm.error != null && vm.license != null) ...[ - const SizedBox(height: 16), - Text( - vm.error!, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), - ), - ], - ], - ), + ), + ], ), ), ); diff --git a/flutter_app/lib/update/update_screen.dart b/flutter_app/lib/update/update_screen.dart index 6237a69..658a7c4 100644 --- a/flutter_app/lib/update/update_screen.dart +++ b/flutter_app/lib/update/update_screen.dart @@ -46,8 +46,12 @@ class _UpdateScreenState extends State { String? _message; StreamSubscription? _installSub; + /// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」 + Timer? _installTimer; + @override void dispose() { + _installTimer?.cancel(); _installSub?.cancel(); _client.close(); super.dispose(); @@ -125,6 +129,15 @@ class _UpdateScreenState extends State { _message = null; }); await _installSub?.cancel(); + // 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」 + _installTimer?.cancel(); + _installTimer = Timer(const Duration(seconds: 120), () { + if (!mounted) return; + setState(() { + _stage = _Stage.failed; + _message = '安装超时,请重试'; + }); + }); _installSub = ApkInstaller.progress().listen((e) { if (!mounted) return; switch (e.event) { @@ -132,6 +145,7 @@ class _UpdateScreenState extends State { setState(() => _progress = e.progress.toDouble()); break; case 'finished': + _installTimer?.cancel(); final ok = e.success == true; setState(() { _stage = ok ? _Stage.finished : _Stage.failed; @@ -139,6 +153,8 @@ class _UpdateScreenState extends State { }); break; case 'failed': + _installTimer?.cancel(); + debugPrint('UpdateScreen: install failed: ${e.error}'); setState(() { _stage = _Stage.failed; _message = e.error ?? '安装失败,请重试'; @@ -146,16 +162,30 @@ class _UpdateScreenState extends State { break; } }, onError: (Object _) { + _installTimer?.cancel(); if (!mounted) return; setState(() { _stage = _Stage.failed; _message = '安装失败,请重试'; }); }); - final result = await ApkInstaller.install(_apkFile.path); + final String result; + try { + result = await ApkInstaller.install(_apkFile.path); + } catch (_) { + // 原生安装通道异常(如会话创建失败):不捕获则 UI 永久停在「安装中」 + _installTimer?.cancel(); + if (!mounted) return; + setState(() { + _stage = _Stage.failed; + _message = '安装启动失败,请重试'; + }); + return; + } if (!mounted) return; if (result == 'permission_required') { // 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装 + _installTimer?.cancel(); setState(() { _stage = _Stage.failed; _message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)'; diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index 4da36b0..e1cb2d7 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -90,12 +90,11 @@ packages: source: hosted version: "0.11.4" camera_android_camerax: - dependency: transitive + dependency: "direct overridden" description: - name: camera_android_camerax - sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" - url: "https://pub.flutter-io.cn" - source: hosted + path: "third_party/camera_android_camerax" + relative: true + source: path version: "0.6.30" camera_avfoundation: dependency: "direct main" diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index a409e42..3f61b13 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -2,7 +2,7 @@ name: observer description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费" publish_to: 'none' -version: 1.0.8+9 +version: 1.0.21+26 environment: sdk: ^3.12.2 @@ -44,6 +44,13 @@ tobias: ios: universal_link: https://YOUR_DOMAIN.com/alipay/ +# camera_android_camerax 屏蔽:Android 相机走自写 CameraChannel,不需要 CameraX; +# 插件注册即初始化 CameraX,无相机环境(模拟器)下回调线程直接 +# dispatchPlatformMessage 触发 @UiThread 校验崩溃。空实现见 third_party/。 +dependency_overrides: + camera_android_camerax: + path: third_party/camera_android_camerax + dev_dependencies: flutter_test: sdk: flutter diff --git a/flutter_app/third_party/camera_android_camerax/lib/camera_android_camerax.dart b/flutter_app/third_party/camera_android_camerax/lib/camera_android_camerax.dart new file mode 100644 index 0000000..70bf622 --- /dev/null +++ b/flutter_app/third_party/camera_android_camerax/lib/camera_android_camerax.dart @@ -0,0 +1,6 @@ +/// 空实现:Android 相机走自写 CameraChannel,屏蔽 camera_android_camerax +/// 插件(其注册即初始化 CameraX,无相机环境下回调线程 dispatchPlatformMessage +/// 触发 @UiThread 校验崩溃)。任何调用都会缺失,Android 路径不依赖它。 +class CameraAndroidCameraxBlank { + static void registerWith() {} +} diff --git a/flutter_app/third_party/camera_android_camerax/pubspec.yaml b/flutter_app/third_party/camera_android_camerax/pubspec.yaml new file mode 100644 index 0000000..75f8d86 --- /dev/null +++ b/flutter_app/third_party/camera_android_camerax/pubspec.yaml @@ -0,0 +1,14 @@ +name: camera_android_camerax +description: "空实现:屏蔽 CameraX 初始化(Android 相机走自写 CameraChannel,插件注册即初始化 CameraX,无相机环境下回调线程 dispatchPlatformMessage 触发 @UiThread 崩溃)" +version: 0.6.30 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +flutter: + plugin: + implements: camera + platforms: + android: + dartPluginClass: CameraAndroidCameraxBlank diff --git a/server/data/observer.db b/server/data/observer.db index 6d09d65fde9dcfb745be81001e630dcf7b0ed84c..cd4b5a6d4899c61900772c5fc475c046ca8e8eab 100644 GIT binary patch delta 19489 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 diff --git a/server/main.go b/server/main.go index 89604c2..7ec4300 100644 --- a/server/main.go +++ b/server/main.go @@ -16,14 +16,14 @@ import ( func main() { ctx := context.Background() initDatabase(ctx) - // 封面存量迁移:历史固定命名 cover* → UUID jpg(幂等,新库空跑) - if err := service.Dataset.MigrateLegacyCovers(ctx); err != nil { - g.Log().Errorf(ctx, "封面存量迁移失败: %+v", err) - } - // 封面存量尺寸统一:非 1248x704 的压缩覆盖写(幂等,新库空跑) - if err := service.Dataset.CompressExistingCovers(ctx); err != nil { - g.Log().Errorf(ctx, "封面存量压缩失败: %+v", err) - } + //// 封面存量迁移:历史固定命名 cover* → UUID jpg(幂等,新库空跑) + //if err := service.Dataset.MigrateLegacyCovers(ctx); err != nil { + // g.Log().Errorf(ctx, "封面存量迁移失败: %+v", err) + //} + //// 封面存量尺寸统一:非 1248x704 的压缩覆盖写(幂等,新库空跑) + //if err := service.Dataset.CompressExistingCovers(ctx); err != nil { + // g.Log().Errorf(ctx, "封面存量压缩失败: %+v", err) + //} s := g.Server() // Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk,