1
This commit is contained in:
@@ -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<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
|
||||
|
||||
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()
|
||||
|
||||
@@ -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<AuthScreen> {
|
||||
|
||||
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<String>(
|
||||
future: appVersion(),
|
||||
builder: (context, snapshot) => Text(
|
||||
'v${snapshot.data ?? ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
|
||||
'环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
|
||||
'不同模型的置信度分布不同,识别不到目标时可适当调低阈值。',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -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<MotionRegion> motionRegions,
|
||||
List<MotionRegion> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = 候选,渲染为虚线
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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<void> _workerMain(SendPort mainPort) async {
|
||||
r.bottom,
|
||||
r.modelId,
|
||||
r.modelName,
|
||||
r.classId,
|
||||
])
|
||||
.toList(),
|
||||
motionRegions
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
|
||||
@@ -39,7 +39,8 @@ class VisualPrior {
|
||||
if (results.isEmpty || width <= 0 || height <= 0) return results;
|
||||
final kept = <DetectionResult>[];
|
||||
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;
|
||||
|
||||
@@ -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<HomeScreen> {
|
||||
],
|
||||
),
|
||||
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<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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -46,8 +46,12 @@ class _UpdateScreenState extends State<UpdateScreen> {
|
||||
String? _message;
|
||||
StreamSubscription<InstallEvent>? _installSub;
|
||||
|
||||
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
|
||||
Timer? _installTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_installTimer?.cancel();
|
||||
_installSub?.cancel();
|
||||
_client.close();
|
||||
super.dispose();
|
||||
@@ -125,6 +129,15 @@ class _UpdateScreenState extends State<UpdateScreen> {
|
||||
_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<UpdateScreen> {
|
||||
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<UpdateScreen> {
|
||||
});
|
||||
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<UpdateScreen> {
|
||||
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 已缓存,无需重新下载)';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user