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.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()
+96 -76
View File
@@ -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,
),
),
),
),
],
),
),
);
+1 -1
View File
@@ -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;
}
+10 -10
View File
@@ -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 高预览下的估算焦距 px5.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;
+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 = 候选,渲染为虚线
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,
));
+2 -1
View File
@@ -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;
+71 -51
View File
@@ -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),
),
],
],
),
),
],
),
),
);
+31 -1
View File
@@ -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 已缓存,无需重新下载)';
+4 -5
View File
@@ -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"
+8 -1
View File
@@ -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
@@ -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() {
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