This commit is contained in:
2026-09-01 15:18:05 +08:00
parent dcde05d67c
commit abccda0bd6
19 changed files with 381 additions and 160 deletions
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.util.Log
import java.io.File import java.io.File
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -36,6 +37,11 @@ class InstallerChannel(
private var progressSink: EventChannel.EventSink? = null private var progressSink: EventChannel.EventSink? = null
private var activeSession: PackageInstaller.Session? = null private var activeSession: PackageInstaller.Session? = null
/// 安装完成轮询兜底开关:部分 ROM(如 MIUI)点确认后不回调
/// onProgressChanged/onFinished,仅靠回调会永久停在「安装中」。
@Volatile
private var installSettled = false
fun register() { fun register() {
method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result -> method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) { when (call.method) {
@@ -55,7 +61,9 @@ class InstallerChannel(
} }
private fun install(path: String, result: MethodChannel.Result) { private fun install(path: String, result: MethodChannel.Result) {
installSettled = false
val file = File(path) val file = File(path)
Log.d(TAG, "install: path=$path exists=${file.exists()} len=${file.length()}")
if (!file.exists()) { if (!file.exists()) {
result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null) result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null)
return return
@@ -63,6 +71,7 @@ class InstallerChannel(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!activity.packageManager.canRequestPackageInstalls() !activity.packageManager.canRequestPackageInstalls()
) { ) {
Log.d(TAG, "install: permission_required")
val intent = Intent( val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${activity.packageName}"), Uri.parse("package:${activity.packageName}"),
@@ -79,6 +88,7 @@ class InstallerChannel(
val sessionId = pm.packageInstaller.createSession(params) val sessionId = pm.packageInstaller.createSession(params)
val session = pm.packageInstaller.openSession(sessionId) val session = pm.packageInstaller.openSession(sessionId)
activeSession = session activeSession = session
Log.d(TAG, "install: session=$sessionId opened")
// API 36 起 registerSessionCallback(int, ...) 变体被移除,只剩全局注册形式, // API 36 起 registerSessionCallback(int, ...) 变体被移除,只剩全局注册形式,
// 回调按 sessionId 过滤,避免响应其他会话事件 // 回调按 sessionId 过滤,避免响应其他会话事件
val callback = object : PackageInstaller.SessionCallback() { val callback = object : PackageInstaller.SessionCallback() {
@@ -90,20 +100,30 @@ class InstallerChannel(
override fun onProgressChanged(id: Int, progressPercent: Float) { override fun onProgressChanged(id: Int, progressPercent: Float) {
if (id != sessionId) return if (id != sessionId) return
Log.d(TAG, "callback onProgressChanged=$progressPercent")
emit("progress", "progress" to progressPercent.toInt()) emit("progress", "progress" to progressPercent.toInt())
} }
override fun onFinished(id: Int, success: Boolean) { override fun onFinished(id: Int, success: Boolean) {
if (id != sessionId) return if (id != sessionId) return
Log.d(TAG, "callback onFinished success=$success")
installSettled = true
emit("finished", "success" to success) emit("finished", "success" to success)
pm.packageInstaller.unregisterSessionCallback(this) pm.packageInstaller.unregisterSessionCallback(this)
activeSession = null activeSession = null
} }
} }
pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper())) pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper()))
// 安装前已装版本号(更新安装成功后必然变化,轮询兜底依据)
val installedCode = try {
pm.getPackageInfo(activity.packageName, 0).versionCode
} catch (e: Exception) {
-1
}
// 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框 // 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框
Thread { Thread {
try { try {
Log.d(TAG, "writeThread: start")
session.openWrite("apk", 0, file.length()).use { out -> session.openWrite("apk", 0, file.length()).use { out ->
file.inputStream().use { input -> file.inputStream().use { input ->
val buf = ByteArray(1 shl 20) 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( val sender = PendingIntent.getActivity(
activity, activity,
0, 0,
Intent(activity, MainActivity::class.java), Intent(activity, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
).intentSender ).intentSender
session.commit(sender) session.commit(sender)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "writeThread: failed", e)
session.abandon() session.abandon()
emit("failed", "error" to (e.message ?: "安装会话写入失败")) emit("failed", "error" to (e.message ?: "安装会话写入失败"))
activeSession = null activeSession = null
} }
}.start() }.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") result.success("installing")
} catch (e: Exception) { } catch (e: Exception) {
result.error("INSTALL_FAILED", e.message, null) result.error("INSTALL_FAILED", e.message, null)
@@ -134,6 +182,15 @@ class InstallerChannel(
} }
private fun emit(event: String, vararg pairs: Pair<String, Any>) { private fun emit(event: String, vararg pairs: Pair<String, Any>) {
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"
} }
} }
@@ -1,5 +1,10 @@
package com.example.observer 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.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -7,6 +12,44 @@ class MainActivity : FlutterActivity() {
private var cameraChannel: CameraChannel? = null private var cameraChannel: CameraChannel? = null
private var installerChannel: InstallerChannel? = 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) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// 自写原生相机(替代 camera_android_camerax 插件): // 自写原生相机(替代 camera_android_camerax 插件):
@@ -18,6 +61,10 @@ class MainActivity : FlutterActivity() {
installerChannel = InstallerChannel(this, flutterEngine).also { it.register() } installerChannel = InstallerChannel(this, flutterEngine).also { it.register() }
} }
companion object {
private const val TAG = "MainActivity"
}
override fun onDestroy() { override fun onDestroy() {
cameraChannel?.destroy() cameraChannel?.destroy()
super.onDestroy() super.onDestroy()
+96 -76
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../config/app_version.dart';
import 'auth_view_model.dart'; import 'auth_view_model.dart';
/// 登录/注册页:手机号 + 密码;注册成功后自动登录。 /// 登录/注册页:手机号 + 密码;注册成功后自动登录。
@@ -51,85 +52,104 @@ class _AuthScreenState extends State<AuthScreen> {
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: Center( child: Column(
child: SingleChildScrollView( children: [
padding: const EdgeInsets.symmetric(horizontal: 32), Expanded(
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.symmetric(horizontal: 32),
children: [ child: Column(
const Icon(Icons.pets, size: 64, color: Colors.green), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 12), crossAxisAlignment: CrossAxisAlignment.stretch,
const Text( children: [
'视野', const Icon(Icons.pets, size: 64, color: Colors.green),
textAlign: TextAlign.center, const SizedBox(height: 12),
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), const Text(
), '视野',
const SizedBox(height: 4), textAlign: TextAlign.center,
Text( style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
'动物实时识别', ),
textAlign: TextAlign.center, const SizedBox(height: 4),
style: TextStyle(color: Colors.grey.shade600), Text(
), '动物实时识别',
const SizedBox(height: 32), textAlign: TextAlign.center,
TextField( style: TextStyle(color: Colors.grey.shade600),
controller: _phoneCtrl, ),
keyboardType: TextInputType.phone, const SizedBox(height: 32),
maxLength: 11, TextField(
inputFormatters: [FilteringTextInputFormatter.digitsOnly], controller: _phoneCtrl,
decoration: const InputDecoration( keyboardType: TextInputType.phone,
labelText: '手机号', maxLength: 11,
prefixIcon: Icon(Icons.phone_android), inputFormatters: [FilteringTextInputFormatter.digitsOnly],
border: OutlineInputBorder(), decoration: const InputDecoration(
counterText: '', 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<String>(
future: appVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
),
),
],
), ),
), ),
); );
+1 -1
View File
@@ -91,7 +91,7 @@ class _CameraScreenState extends State<CameraScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'阈值越低识别越灵敏(低分框越多,误报也可能增加);' '阈值越低识别越灵敏(低分框越多,误报也可能增加);'
'环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。', '不同模型置信度分布不同,识别不到目标时可适当调低阈值',
style: TextStyle(color: Colors.white54, fontSize: 12), style: TextStyle(color: Colors.white54, fontSize: 12),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -105,9 +105,10 @@ class CameraViewModel extends ChangeNotifier {
visible.add(r.copyWith(confirmed: t.confirmed)); visible.add(r.copyWith(confirmed: t.confirmed));
} }
// 提醒:仅新确认的环颈雉鸡轨迹(确认瞬间触发一次,10s 同类冷却在 Reminder 内) // 提醒:仅新确认的目标物种轨迹(class 0,如环颈雉鸡确认瞬间触发一次,10s 同类冷却在 Reminder 内)
for (final t in _tracks.values) { 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; final age = now - t.firstSeenMs;
if (age >= displayAgeMs && age <= displayAgeMs + 1600 && if (age >= displayAgeMs && age <= displayAgeMs + 1600 &&
now - t.lastSeenMs <= 300) { now - t.lastSeenMs <= 300) {
@@ -184,7 +185,7 @@ class CameraViewModel extends ChangeNotifier {
/// - 环颈雉鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示 /// - 环颈雉鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示
bool _shouldDisplay(_Track t, List<MotionRegion> motionRegions, bool _shouldDisplay(_Track t, List<MotionRegion> motionRegions,
List<MotionRegion> noveltyRegions) { List<MotionRegion> noveltyRegions) {
if (t.label == 'suspect') return true; if (t.result.classId > 0 || t.label == 'suspect') return true;
if (t.confirmed) return true; if (t.confirmed) return true;
return t.result.score >= highConf || return t.result.score >= highConf ||
_hasActivity(t.result, motionRegions, noveltyRegions); _hasActivity(t.result, motionRegions, noveltyRegions);
@@ -203,7 +204,7 @@ class CameraViewModel extends ChangeNotifier {
if (w <= 0 || h <= 0) return false; if (w <= 0 || h <= 0) return false;
final aspect = w / h; final aspect = w / h;
if (aspect < 0.3 || aspect > 3.0) return false; 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; return h >= 0.01 && h <= 0.3;
} }
+10 -10
View File
@@ -44,14 +44,11 @@ class _OverlayPainter extends CustomPainter {
_OverlayPainter(this.results, this.rotation, this.imageWidthPx, _OverlayPainter(this.results, this.rotation, this.imageWidthPx,
this.imageHeightPx); this.imageHeightPx);
static const _colors = {
'pheasant': Color(0xFFE53935),
'suspect': Color(0xFFFDD835),
};
static const _labels = {'pheasant': '环颈雉鸡', 'suspect': '疑似'}; static const _labels = {'pheasant': '环颈雉鸡', 'suspect': '疑似'};
/// 参考体型(米):环颈雉鸡身高 / 植被高度(参照旧 DistanceEstimator /// 参考体型(米):目标物种(class 0,如环颈雉鸡身高 / suspect 植被高度
static const _refSizeM = {'pheasant': 0.45, 'suspect': 0.50}; static const double _refSizeSpeciesM = 0.45;
static const double _refSizeSuspectM = 0.50;
/// iPhone 13 主摄在 1280 高预览下的估算焦距 px5.1mm / 5.30mm 传感器), /// iPhone 13 主摄在 1280 高预览下的估算焦距 px5.1mm / 5.30mm 传感器),
/// 单目误差 ±30%,仅作参考 /// 单目误差 ±30%,仅作参考
@@ -72,8 +69,10 @@ class _OverlayPainter extends CustomPainter {
size.width, size.width,
size.height, size.height,
); );
final color = _colors[r.label] ?? Colors.white; // 颜色按类别索引而非 label 文本:模型类别名可能为中文(环颈雉)或
final isSuspect = r.label == 'suspect'; // 随数据集变化,class 0 恒为目标物种(红),其余类恒为 suspect(黄)
final isSuspect = r.classId > 0 || r.label == 'suspect';
final color = isSuspect ? Color(0xFFFDD835) : Color(0xFFE53935);
final confirmed = r.confirmed && !isSuspect; final confirmed = r.confirmed && !isSuspect;
final paint = Paint() final paint = Paint()
..color = color.withValues(alpha: confirmed ? 1.0 : 0.55) ..color = color.withValues(alpha: confirmed ? 1.0 : 0.55)
@@ -113,8 +112,9 @@ class _OverlayPainter extends CustomPainter {
} }
String _distanceLabel(DetectionResult r) { String _distanceLabel(DetectionResult r) {
final refH = _refSizeM[r.label]; final refH = (r.classId > 0 || r.label == 'suspect')
if (refH == null) return ''; ? _refSizeSuspectM
: _refSizeSpeciesM;
final hPx = r.height * imageHeightPx; final hPx = r.height * imageHeightPx;
if (hPx < 8) return ''; if (hPx < 8) return '';
final m = focalPx * refH / hPx; final m = focalPx * refH / hPx;
+11
View File
@@ -0,0 +1,11 @@
import 'package:package_info_plus/package_info_plus.dart';
String? _cached;
/// 当前 App 版本号(一次获取后缓存,用于登录页/首页底部展示)
Future<String> appVersion() async {
if (_cached != null) return _cached!;
final info = await PackageInfo.fromPlatform();
_cached = info.version;
return _cached!;
}
@@ -9,6 +9,9 @@ class DetectionResult {
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线 /// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
final bool confirmed; final bool confirmed;
/// 类别索引:0 = 目标物种(红色框),>0 = suspect(黄色框);-1 = 未知
final int classId;
/// 产出该框的模型(数据集 id 与名称;无来源为 -1/空) /// 产出该框的模型(数据集 id 与名称;无来源为 -1/空)
final int modelId; final int modelId;
final String modelName; final String modelName;
@@ -21,6 +24,7 @@ class DetectionResult {
required this.right, required this.right,
required this.bottom, required this.bottom,
this.confirmed = true, this.confirmed = true,
this.classId = -1,
this.modelId = -1, this.modelId = -1,
this.modelName = '', this.modelName = '',
}); });
@@ -46,6 +50,7 @@ class DetectionResult {
right: right ?? this.right, right: right ?? this.right,
bottom: bottom ?? this.bottom, bottom: bottom ?? this.bottom,
confirmed: confirmed ?? this.confirmed, confirmed: confirmed ?? this.confirmed,
classId: classId,
modelId: modelId, modelId: modelId,
modelName: modelName, modelName: modelName,
); );
@@ -148,6 +148,7 @@ class DetectorWorker {
bottom: v[5] as double, bottom: v[5] as double,
modelId: v.length > 6 ? (v[6] as num).toInt() : -1, modelId: v.length > 6 ? (v[6] as num).toInt() : -1,
modelName: v.length > 7 ? v[7] as String : '', modelName: v.length > 7 ? v[7] as String : '',
classId: v.length > 8 ? (v[8] as num).toInt() : -1,
); );
}).toList(); }).toList();
final motion = (list[5] as List) final motion = (list[5] as List)
@@ -421,6 +422,7 @@ Future<void> _workerMain(SendPort mainPort) async {
r.bottom, r.bottom,
r.modelId, r.modelId,
r.modelName, r.modelName,
r.classId,
]) ])
.toList(), .toList(),
motionRegions motionRegions
@@ -479,6 +479,7 @@ class TfliteDetector {
top: (cy - h / 2).clamp(0.0, 1.0), top: (cy - h / 2).clamp(0.0, 1.0),
right: (cx + w / 2).clamp(0.0, 1.0), right: (cx + w / 2).clamp(0.0, 1.0),
bottom: (cy + h / 2).clamp(0.0, 1.0), bottom: (cy + h / 2).clamp(0.0, 1.0),
classId: bestCls,
modelId: modelId, modelId: modelId,
modelName: modelName, modelName: modelName,
)); ));
+2 -1
View File
@@ -39,7 +39,8 @@ class VisualPrior {
if (results.isEmpty || width <= 0 || height <= 0) return results; if (results.isEmpty || width <= 0 || height <= 0) return results;
final kept = <DetectionResult>[]; final kept = <DetectionResult>[];
for (final r in results) { 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 && if (lowConfPheasant &&
_reject(r, planes, strides, width, height, isBgra, rgbaOrder)) { _reject(r, planes, strides, width, height, isBgra, rgbaOrder)) {
continue; continue;
+71 -51
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../auth/session_store.dart'; import '../auth/session_store.dart';
import '../config/app_version.dart';
import 'home_view_model.dart'; import 'home_view_model.dart';
/// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口 /// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口
@@ -104,61 +105,80 @@ class _HomeScreenState extends State<HomeScreen> {
], ],
), ),
body: SafeArea( body: SafeArea(
child: Padding( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24), children: [
child: Column( Expanded(
crossAxisAlignment: CrossAxisAlignment.stretch, child: Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 24),
const SizedBox(height: 24), child: Column(
_StatusCard( crossAxisAlignment: CrossAxisAlignment.stretch,
licenseText: statusText, children: [
active: active, const SizedBox(height: 24),
), _StatusCard(
const SizedBox(height: 40), licenseText: statusText,
SizedBox( active: active,
height: 88,
child: FilledButton.icon(
onPressed: vm.verifying ? null : _onSearch,
style: FilledButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(44),
), ),
), const SizedBox(height: 40),
icon: vm.verifying SizedBox(
? const SizedBox( height: 88,
width: 24, child: FilledButton.icon(
height: 24, onPressed: vm.verifying ? null : _onSearch,
child: CircularProgressIndicator( style: FilledButton.styleFrom(
strokeWidth: 2, color: Colors.white), backgroundColor: Colors.green,
) shape: RoundedRectangleBorder(
: const Icon(Icons.visibility, size: 32), borderRadius: BorderRadius.circular(44),
label: Text( ),
vm.verifying ? '正在校验授权…' : '打开视野', ),
style: const TextStyle( icon: vm.verifying
fontSize: 22, fontWeight: FontWeight.w600), ? 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<String>(
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),
),
],
],
),
), ),
), ),
); );
+31 -1
View File
@@ -46,8 +46,12 @@ class _UpdateScreenState extends State<UpdateScreen> {
String? _message; String? _message;
StreamSubscription<InstallEvent>? _installSub; StreamSubscription<InstallEvent>? _installSub;
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
Timer? _installTimer;
@override @override
void dispose() { void dispose() {
_installTimer?.cancel();
_installSub?.cancel(); _installSub?.cancel();
_client.close(); _client.close();
super.dispose(); super.dispose();
@@ -125,6 +129,15 @@ class _UpdateScreenState extends State<UpdateScreen> {
_message = null; _message = null;
}); });
await _installSub?.cancel(); await _installSub?.cancel();
// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
_installTimer?.cancel();
_installTimer = Timer(const Duration(seconds: 120), () {
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装超时,请重试';
});
});
_installSub = ApkInstaller.progress().listen((e) { _installSub = ApkInstaller.progress().listen((e) {
if (!mounted) return; if (!mounted) return;
switch (e.event) { switch (e.event) {
@@ -132,6 +145,7 @@ class _UpdateScreenState extends State<UpdateScreen> {
setState(() => _progress = e.progress.toDouble()); setState(() => _progress = e.progress.toDouble());
break; break;
case 'finished': case 'finished':
_installTimer?.cancel();
final ok = e.success == true; final ok = e.success == true;
setState(() { setState(() {
_stage = ok ? _Stage.finished : _Stage.failed; _stage = ok ? _Stage.finished : _Stage.failed;
@@ -139,6 +153,8 @@ class _UpdateScreenState extends State<UpdateScreen> {
}); });
break; break;
case 'failed': case 'failed':
_installTimer?.cancel();
debugPrint('UpdateScreen: install failed: ${e.error}');
setState(() { setState(() {
_stage = _Stage.failed; _stage = _Stage.failed;
_message = e.error ?? '安装失败,请重试'; _message = e.error ?? '安装失败,请重试';
@@ -146,16 +162,30 @@ class _UpdateScreenState extends State<UpdateScreen> {
break; break;
} }
}, onError: (Object _) { }, onError: (Object _) {
_installTimer?.cancel();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_stage = _Stage.failed; _stage = _Stage.failed;
_message = '安装失败,请重试'; _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 (!mounted) return;
if (result == 'permission_required') { if (result == 'permission_required') {
// 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装 // 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装
_installTimer?.cancel();
setState(() { setState(() {
_stage = _Stage.failed; _stage = _Stage.failed;
_message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)'; _message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)';
+4 -5
View File
@@ -90,12 +90,11 @@ packages:
source: hosted source: hosted
version: "0.11.4" version: "0.11.4"
camera_android_camerax: camera_android_camerax:
dependency: transitive dependency: "direct overridden"
description: description:
name: camera_android_camerax path: "third_party/camera_android_camerax"
sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" relative: true
url: "https://pub.flutter-io.cn" source: path
source: hosted
version: "0.6.30" version: "0.6.30"
camera_avfoundation: camera_avfoundation:
dependency: "direct main" dependency: "direct main"
+8 -1
View File
@@ -2,7 +2,7 @@ name: observer
description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费" description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费"
publish_to: 'none' publish_to: 'none'
version: 1.0.8+9 version: 1.0.21+26
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@@ -44,6 +44,13 @@ tobias:
ios: ios:
universal_link: https://YOUR_DOMAIN.com/alipay/ 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
@@ -0,0 +1,6 @@
/// 空实现:Android 相机走自写 CameraChannel,屏蔽 camera_android_camerax
/// 插件(其注册即初始化 CameraX,无相机环境下回调线程 dispatchPlatformMessage
/// 触发 @UiThread 校验崩溃)。任何调用都会缺失,Android 路径不依赖它。
class CameraAndroidCameraxBlank {
static void registerWith() {}
}
@@ -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
Binary file not shown.
+8 -8
View File
@@ -16,14 +16,14 @@ import (
func main() { func main() {
ctx := context.Background() ctx := context.Background()
initDatabase(ctx) initDatabase(ctx)
// 封面存量迁移:历史固定命名 cover* → UUID jpg(幂等,新库空跑) //// 封面存量迁移:历史固定命名 cover* → UUID jpg(幂等,新库空跑)
if err := service.Dataset.MigrateLegacyCovers(ctx); err != nil { //if err := service.Dataset.MigrateLegacyCovers(ctx); err != nil {
g.Log().Errorf(ctx, "封面存量迁移失败: %+v", err) // g.Log().Errorf(ctx, "封面存量迁移失败: %+v", err)
} //}
// 封面存量尺寸统一:非 1248x704 的压缩覆盖写(幂等,新库空跑) //// 封面存量尺寸统一:非 1248x704 的压缩覆盖写(幂等,新库空跑)
if err := service.Dataset.CompressExistingCovers(ctx); err != nil { //if err := service.Dataset.CompressExistingCovers(ctx); err != nil {
g.Log().Errorf(ctx, "封面存量压缩失败: %+v", err) // g.Log().Errorf(ctx, "封面存量压缩失败: %+v", err)
} //}
s := g.Server() s := g.Server()
// Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk // Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk