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 6d09d65..cd4b5a6 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ 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,