1
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 高预览下的估算焦距 px(5.1mm / 5.30mm 传感器),
|
/// iPhone 13 主摄在 1280 高预览下的估算焦距 px(5.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;
|
||||||
|
|||||||
@@ -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,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 已缓存,无需重新下载)';
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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() {
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user