训练体系整合与标注单阶段化

- 标注:AI 预标注直写 labels_json(去候选确认两阶段);重叠去重(minIoU);全量标注按钮
- 训练:脚本迁移入 server/training/(Go 化 prepare_yolo/analyze_rfdetr,保留 train_server.py);tflite 产物自检并入训练流程(check_tflite)
- 数据目录/权重不进 git;.gitignore 迁移至仓库根
This commit is contained in:
2026-08-26 18:22:56 +08:00
parent 4f39e85882
commit a0b115d954
108 changed files with 10877 additions and 798 deletions
@@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- App 内更新安装 APKPackageInstaller 会话安装,见 InstallerChannel.kt -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="视野"
android:name="${applicationName}"
@@ -0,0 +1,568 @@
package com.example.observer
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.SurfaceTexture
import android.os.Build
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.graphics.ImageFormat
import android.media.Image
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.Log
import android.util.Size
import android.view.Surface
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.TextureRegistry
/**
* 自写原生相机:Android 框架 camera2 API 完全自研,零相机三方依赖(含 CameraX)。
*
* 核心:分析帧在原生侧旋转成竖屏方向再回传,Flutter 侧恒用 rotation=0
* 与 iOScamera_avfoundation,帧本来就是竖屏)行为对齐,彻底消除
* "横屏传感器帧 → 90° 旋转 + FIT_COVER crop 映射"的标注偏移根因。
*
* 旋转角来自设备标准值 sensorOrientation - displayRotationcamera2 特性,
* 非厂商 hack),因此映射跨厂商一致:任何设备上"分析帧 = 预览帧同 sensor
* 同旋转",两者几何必然一致。
*
* 预览:SurfaceTexture 注册进 FlutterTextureRegistryFlutter 侧 Texture widget
* 渲染(与插件 CameraPreview 相同的合成方式,overlay/诊断行/按钮可叠加;
* AndroidView+SurfaceView 会盖住 Flutter UI,不可用)。
*
* 通道:
* - MethodChannel "observer/camera"start/stop/getZoomRange/setZoom/isStreaming/errorDescription
* - EventChannel "observer/camera/frames":每帧 [width, height, rotationDegrees, bgra, bytes]
* - rotationDegrees 恒 0(帧已竖屏);bgra=falseRGBA 字节序)
*/
class CameraChannel(
private val activity: FlutterActivity,
private val engine: FlutterEngine,
) {
private val messenger = engine.dartExecutor.binaryMessenger
private val method = MethodChannel(messenger, "observer/camera")
private val frames = EventChannel(messenger, "observer/camera/frames")
private val cameraManager: CameraManager = activity.getSystemService(CameraManager::class.java)
private var cameraDevice: CameraDevice? = null
private var captureSession: CameraCaptureSession? = null
private var imageReader: ImageReader? = null
private var surfaceEntry: TextureRegistry.SurfaceTextureEntry? = null
private var previewSurface: Surface? = null
private var eventSink: EventChannel.EventSink? = null
private var sensorOrientation = 90
private var activeArray = Rect(0, 0, 1920, 1080)
private var maxZoom = 1.0f
/// 预览/分析共用分辨率(传感器方向,从设备流配置动态选择)
private var previewSize = Size(1920, 1080)
@Volatile
private var running = false
@Volatile
private var errorDescription: String? = null
private val mainHandler = Handler(activity.mainLooper)
private val analysisThread = HandlerThread("observer-analysis").also { it.start() }
private val analysisHandler = Handler(analysisThread.looper)
@Volatile
private var lastEmitMs = 0L
// 诊断计数(frameListener 线程写,stats 轮询读):
// 帧回调到达次数 / 成功发出 / 发出异常 / 无订阅者丢弃
@Volatile
private var frameCallbacks = 0L
@Volatile
private var emitOk = 0L
@Volatile
private var emitErr = 0L
@Volatile
private var sinkNullCount = 0L
// 实际发出帧尺寸(缩小后)
@Volatile
private var emitW = 0
@Volatile
private var emitH = 0
fun register() {
method.setMethodCallHandler(::onMethodCall)
frames.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
})
}
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"start" -> start(result)
"stop" -> {
stop()
result.success(true)
}
"getZoomRange" -> result.success(listOf(1.0, maxZoom.toDouble()))
"setZoom" -> {
val v = (call.arguments as Number).toFloat()
setZoom(v)
result.success(true)
}
"isStreaming" -> result.success(running)
"errorDescription" -> result.success(errorDescription)
"stats" -> result.success(
mapOf(
"callbacks" to frameCallbacks,
"emitOk" to emitOk,
"emitErr" to emitErr,
"sinkNull" to sinkNullCount,
"sink" to (eventSink != null),
"running" to running,
"rotation" to rotation,
"quarterTurns" to displayDegrees / 90,
"displayDegrees" to displayDegrees,
"size" to "${previewSize.width}x${previewSize.height}",
"emitSize" to "${emitW}x$emitH",
"emitAgeMs" to
if (lastEmitMs == 0L) -1L
else SystemClock.elapsedRealtime() - lastEmitMs,
"error" to errorDescription,
),
)
else -> result.notImplemented()
}
}
private fun start(result: MethodChannel.Result) {
if (running) {
result.success(true)
return
}
running = true
errorDescription = null
try {
val cameraId = pickBackCamera() ?: throw IllegalStateException("未找到后置摄像头")
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 90
activeArray = characteristics.get(
CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE,
) ?: Rect(0, 0, 1920, 1080)
maxZoom = characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1.0f
// 分辨率不写死:从设备流配置查询(传感器方向尺寸,宽≥高),
// 预览与分析共用同一尺寸,保证两者几何一致(对齐根因)。
// 查询是优化而非必需:个别设备 getOutputSizes 会返回 null 或抛异常
// (实测某设备内部抛 getClass NPE),失败一律回退默认分辨率,
// 绝不让相机启动失败
try {
val streamMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
if (streamMap != null) {
val previewSizes =
streamMap.getOutputSizes(SurfaceTexture::class.java) ?: emptyArray()
val analysisSizes =
streamMap.getOutputSizes(ImageFormat.YUV_420_888) ?: emptyArray()
previewSize = pickSize(previewSizes, analysisSizes)
}
} catch (e: Exception) {
Log.w(TAG, "size query failed, fallback ${previewSize.width}x${previewSize.height}", e)
}
val entry = surfaceEntry ?: engine.getRenderer().createSurfaceTexture().also {
surfaceEntry = it
}
// 关键:SurfaceTexture 未设默认 buffer 尺寸时 createCaptureSession 会配置失败
// camera2 按该尺寸做 stream 校验)。显式指定与 ImageReader 一致的分辨率,
// 保证预览与分析帧同分辨率同裁剪
entry.surfaceTexture().setDefaultBufferSize(previewSize.width, previewSize.height)
previewSurface?.release()
previewSurface = Surface(entry.surfaceTexture())
imageReader?.close()
// YUV_420_888 是 camera2 对所有设备保证支持的 ImageReader 输出格式;
// RGBA_8888 个别设备不支持(实测:查询 NPE + 配置失败双连败),弃用
imageReader = ImageReader.newInstance(
previewSize.width, previewSize.height, ImageFormat.YUV_420_888, 2,
).also { it.setOnImageAvailableListener(frameListener, analysisHandler) }
cameraManager.openCamera(
cameraId,
object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
cameraDevice = device
createSession(result)
}
override fun onDisconnected(device: CameraDevice) {
device.close()
cameraDevice = null
}
override fun onError(device: CameraDevice, error: Int) {
device.close()
cameraDevice = null
running = false
errorDescription = "camera open error $error"
result.error("start", errorDescription, null)
}
},
mainHandler,
)
} catch (e: Exception) {
running = false
errorDescription = e.toString()
// details 带完整堆栈:任何残留异常都能在诊断行看到精确位置
result.error("start", e.toString(), Log.getStackTraceString(e))
}
}
private fun createSession(result: MethodChannel.Result) {
val device = cameraDevice ?: return
val surfaces = listOfNotNull(previewSurface, imageReader?.surface)
device.createCaptureSession(
surfaces,
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
captureSession = session
try {
val request = buildRequest(session.device, surfaces)
session.setRepeatingRequest(request, null, mainHandler)
result.success(
mapOf(
"textureId" to (surfaceEntry?.id() ?: -1L),
// 传感器方向尺寸(宽≥高):Flutter 侧 SizedBox 在 RotatedBox
// 内部声明纹理尺寸,旋转后视觉上才是竖屏 1080x1920
"w" to previewSize.width,
"h" to previewSize.height,
// 预览旋转 = 显示旋转(不是 rotation/90!):
// Flutter 引擎渲染 Texture 时已自动应用 SurfaceTexture
// 变换矩阵(传感器方向补偿),预览只需再按显示旋转补偿
"quarterTurns" to displayDegrees / 90,
// 诊断:旋转计算输入值
"sensorOrientation" to sensorOrientation,
"displayDegrees" to displayDegrees,
),
)
} catch (e: Exception) {
errorDescription = "onConfigured: $e"
result.error("start", errorDescription, Log.getStackTraceString(e))
}
}
override fun onConfigureFailed(session: CameraCaptureSession) {
running = false
errorDescription = "capture session configure failed"
result.error("start", errorDescription, null)
}
},
mainHandler,
)
}
private fun buildRequest(device: CameraDevice, targets: List<Surface>): CaptureRequest {
val builder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
targets.forEach { builder.addTarget(it) }
builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO)
// 连续对焦:无 AF 能力的设备忽略该设置
try {
builder.set(
CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE,
)
} catch (_: IllegalArgumentException) {
}
return builder.build()
}
private fun setZoom(z: Float) {
val session = captureSession ?: return
val clamped = z.coerceIn(1.0f, maxZoom)
val r = activeArray
val insetW = r.width() * (1 - 1 / clamped) / 2
val insetH = r.height() * (1 - 1 / clamped) / 2
val crop = RectF(
r.left + insetW,
r.top + insetH,
r.right - insetW,
r.bottom - insetH,
)
try {
val surface = previewSurface ?: return
val builder = session.device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
builder.addTarget(surface)
imageReader?.surface?.let { builder.addTarget(it) }
builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO)
builder.set(
CaptureRequest.SCALER_CROP_REGION,
Rect(
crop.left.toInt(),
crop.top.toInt(),
crop.right.toInt(),
crop.bottom.toInt(),
),
)
session.setRepeatingRequest(builder.build(), null, mainHandler)
} catch (e: Exception) {
Log.w(TAG, "setZoom failed", e)
}
}
private fun stop() {
running = false
try {
captureSession?.close()
} catch (_: Exception) {
}
captureSession = null
try {
cameraDevice?.close()
} catch (_: Exception) {
}
cameraDevice = null
}
fun destroy() {
stop()
imageReader?.close()
imageReader = null
previewSurface?.release()
previewSurface = null
surfaceEntry?.release()
surfaceEntry = null
analysisThread.quitSafely()
}
private fun pickBackCamera(): String? {
val ids = cameraManager.cameraIdList
for (id in ids) {
val c = cameraManager.getCameraCharacteristics(id)
val facing = c.get(CameraCharacteristics.LENS_FACING)
if (facing == CameraCharacteristics.LENS_FACING_BACK) return id
}
return ids.firstOrNull()
}
/**
* 从设备流配置挑预览/分析共用分辨率(均为传感器方向尺寸,宽≥高):
* 取两者交集,16:9 优先、长边 ≤1920 内取最大(推理输入 640x640,
* 更高只增帧传输与旋转开销,无精度收益);无 16:9 时退回最大交集尺寸。
*/
private fun pickSize(previewSizes: Array<Size>, analysisSizes: Array<Size>): Size {
val common = previewSizes.filter { analysisSizes.contains(it) }
val fallback = common.maxByOrNull { it.width.toLong() * it.height }
?: return Size(1920, 1080)
return common
.filter {
Math.abs(it.width.toDouble() / it.height - 16.0 / 9.0) < 0.03 &&
it.width <= 1920 && it.height <= 1920
}
.maxByOrNull { it.width.toLong() * it.height }
?: fallback
}
/**
* 显示旋转角(度)。Display.ROTATION_* 在 API 36 是受限常量,直接用其值(0/1/2/3)。
* API 30+ 用 activity.display(现代 API,实测可靠跟踪旋转);
* 旧版 windowManager.defaultDisplay.rotation 已废弃,实测在部分设备恒返回 0。
*/
private val displayDegrees: Int
get() = try {
val rot: Int = if (Build.VERSION.SDK_INT >= 30) {
activity.display?.rotation ?: 0
} else {
@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay.rotation
}
when (rot) {
1 -> 90
2 -> 180
3 -> 270
else -> 0
}
} catch (e: Exception) {
Log.w(TAG, "display rotation query failed", e)
0
}
/** 传感器 → 竖屏显示的顺时针旋转角(后摄无镜像) */
private val rotation: Int
get() = ((sensorOrientation - displayDegrees) % 360 + 360) % 360
private val frameListener = ImageReader.OnImageAvailableListener { reader ->
// 诊断计数:回调是否到达(放在一切判定之前,任何丢弃都先计数)
frameCallbacks++
if (frameCallbacks % 50 == 0L) {
Log.i(TAG, "frames cb=$frameCallbacks ok=$emitOk err=$emitErr sinkNull=$sinkNullCount")
}
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
try {
val sink = eventSink
if (sink == null) {
// 诊断:有帧但无订阅者(Dart 侧订阅未建立/被取消)
sinkNullCount++
errorDescription = "帧监听无订阅者"
return@OnImageAvailableListener
}
if (!running) return@OnImageAvailableListener
val now = SystemClock.elapsedRealtime()
// 节流:推理 ~100ms 一帧,避免数 MB 帧无谓传输
if (now - lastEmitMs < 100) return@OnImageAvailableListener
lastEmitMs = now
val bytes = yuvToRgba(image, rotation)
// 缩小后再发:8.3MB/帧对主线程编码与通道传输都过重,540x960 足够
// (推理输入 704,坐标归一化,映射不受分辨率影响)
val scaled = downscaleToFit(
bytes[0] as Int, bytes[1] as Int, bytes[2] as ByteArray, 960,
)
emitW = scaled[0] as Int
emitH = scaled[1] as Int
if (emitOk == 0L) Log.i(TAG, "first emit ${emitW}x${emitH}")
// 关键:EventSink.success 内部 FlutterJNI.dispatchPlatformMessage 强制主线程
// ensureRunningOnMainThread 抛 RuntimeException),后台线程直接调必失败——
// 曾因此每帧抛"必须主线程"异常、Dart 侧永远 流:0。必须 post 到主线程发送
mainHandler.post {
try {
sink.success(listOf(scaled[0], scaled[1], 0, false, scaled[2]))
emitOk++
} catch (e: Exception) {
emitErr++
Log.e(TAG, "emit frame failed", e)
errorDescription = "帧异常: ${e.javaClass.simpleName}: ${e.message}"
}
}
} catch (e: Exception) {
emitErr++
Log.e(TAG, "emit frame failed", e)
errorDescription = "帧异常: ${e.javaClass.simpleName}: ${e.message}"
} finally {
image.close()
}
}
/**
* YUV_420_888 三平面 → RGBA 单平面,同时顺时针旋转成竖屏。
* 返回 [width, height, bytes]。整数 BT.601 转换;U/V 兼容 planar
* pixelStride=1)与 semi-planarpixelStride=2NV21 式交错)布局。
* dst(x', y') = src(y, W-1-x')90° 顺时针),逐像素处理 rowStride 填充。
*/
private fun yuvToRgba(image: Image, deg: Int): List<Any> {
val yPlane = image.planes[0]
val uPlane = image.planes[1]
val vPlane = image.planes[2]
val srcW = image.width
val srcH = image.height
val yStride = yPlane.rowStride
val uStride = uPlane.rowStride
val vStride = vPlane.rowStride
val uPixel = uPlane.pixelStride
val vPixel = vPlane.pixelStride
val yBuf = yPlane.buffer
val uBuf = uPlane.buffer
val vBuf = vPlane.buffer
val dstW = if (deg == 90 || deg == 270) srcH else srcW
val dstH = if (deg == 90 || deg == 270) srcW else srcH
val dst = ByteArray(dstW * dstH * 4)
for (dy in 0 until dstH) {
val rowBase = dy * dstW * 4
for (dx in 0 until dstW) {
val sx: Int
val sy: Int
when (deg) {
90 -> {
sx = dy
sy = srcH - 1 - dx
}
180 -> {
sx = srcW - 1 - dx
sy = srcH - 1 - dy
}
270 -> {
sx = srcW - 1 - dy
sy = dx
}
else -> {
sx = dx
sy = dy
}
}
val yy = (yBuf.get(sy * yStride + sx).toInt() and 0xFF) - 16
val uu = (uBuf.get((sy / 2) * uStride + (sx / 2) * uPixel).toInt() and 0xFF) - 128
val vv = (vBuf.get((sy / 2) * vStride + (sx / 2) * vPixel).toInt() and 0xFF) - 128
val r = ((298 * yy + 409 * vv + 128) shr 8).coerceIn(0, 255)
val g = ((298 * yy - 100 * uu - 208 * vv + 128) shr 8).coerceIn(0, 255)
val b = ((298 * yy + 516 * uu + 128) shr 8).coerceIn(0, 255)
val di = rowBase + dx * 4
dst[di] = r.toByte()
dst[di + 1] = g.toByte()
dst[di + 2] = b.toByte()
dst[di + 3] = 0xFF.toByte()
}
}
return listOf(dstW, dstH, dst)
}
/**
* RGBA 帧整数倍缩小,长边 ≤ maxLong 时原样返回(f=1)。
* 2x2 盒式平均(对 640x640 推理输入足够;比最近邻平滑,颜色更准)。
*/
private fun downscaleToFit(w: Int, h: Int, rgba: ByteArray, maxLong: Int): List<Any> {
val long = maxOf(w, h)
if (long <= maxLong) return listOf(w, h, rgba)
val f = (long + maxLong - 1) / maxLong
val dw = w / f
val dh = h / f
val out = ByteArray(dw * dh * 4)
for (dy in 0 until dh) {
val y0 = dy * f
val y1 = minOf(y0 + f, h)
val rows = y1 - y0
for (dx in 0 until dw) {
val x0 = dx * f
val x1 = minOf(x0 + f, w)
var r = 0L
var g = 0L
var b = 0L
var a = 0L
for (sy in y0 until y1) {
var si = sy * w * 4 + x0 * 4
for (sx in x0 until x1) {
r += rgba[si].toInt() and 0xFF
g += rgba[si + 1].toInt() and 0xFF
b += rgba[si + 2].toInt() and 0xFF
a += rgba[si + 3].toInt() and 0xFF
si += 4
}
}
val n = rows * (x1 - x0)
val di = (dy * dw + dx) * 4
out[di] = (r / n).toByte()
out[di + 1] = (g / n).toByte()
out[di + 2] = (b / n).toByte()
out[di + 3] = (a / n).toByte()
}
}
return listOf(dw, dh, out)
}
companion object {
private const val TAG = "CameraChannel"
}
}
@@ -0,0 +1,139 @@
package com.example.observer
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import java.io.File
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* 原生 APK 安装通道(App 内更新安装):PackageInstaller 会话安装,
* 安装进度经 EventChannel 实时回传 Flutter(下载进度由 Flutter 侧 http 流式下载自算)。
*
* 通道:
* - MethodChannel "observer/installer"install(path)
* - result.success("installing"):已提交安装
* - result.success("permission_required"):未开「安装未知应用」,已拉起系统设置页
* - EventChannel "observer/installer/progress"{event: progress/finished/failed, ...}
*/
class InstallerChannel(
private val activity: FlutterActivity,
private val engine: FlutterEngine,
) {
private val messenger = engine.dartExecutor.binaryMessenger
private val method = MethodChannel(messenger, "observer/installer")
private val progress = EventChannel(messenger, "observer/installer/progress")
private var progressSink: EventChannel.EventSink? = null
private var activeSession: PackageInstaller.Session? = null
fun register() {
method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"install" -> install(call.argument<String>("path") ?: "", result)
else -> result.notImplemented()
}
}
progress.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
progressSink = events
}
override fun onCancel(arguments: Any?) {
progressSink = null
}
})
}
private fun install(path: String, result: MethodChannel.Result) {
val file = File(path)
if (!file.exists()) {
result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!activity.packageManager.canRequestPackageInstalls()
) {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${activity.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
result.success("permission_required")
return
}
try {
val pm = activity.packageManager
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setSize(file.length())
}
val sessionId = pm.packageInstaller.createSession(params)
val session = pm.packageInstaller.openSession(sessionId)
activeSession = session
// API 36 起 registerSessionCallback(int, ...) 变体被移除,只剩全局注册形式,
// 回调按 sessionId 过滤,避免响应其他会话事件
val callback = object : PackageInstaller.SessionCallback() {
override fun onCreated(id: Int) {}
override fun onBadgingChanged(id: Int) {}
override fun onActiveChanged(id: Int, active: Boolean) {}
override fun onProgressChanged(id: Int, progressPercent: Float) {
if (id != sessionId) return
emit("progress", "progress" to progressPercent.toInt())
}
override fun onFinished(id: Int, success: Boolean) {
if (id != sessionId) return
emit("finished", "success" to success)
pm.packageInstaller.unregisterSessionCallback(this)
activeSession = null
}
}
pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper()))
// 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框
Thread {
try {
session.openWrite("apk", 0, file.length()).use { out ->
file.inputStream().use { input ->
val buf = ByteArray(1 shl 20)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
}
}
}
val sender = PendingIntent.getActivity(
activity,
0,
Intent(activity, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
).intentSender
session.commit(sender)
} catch (e: Exception) {
session.abandon()
emit("failed", "error" to (e.message ?: "安装会话写入失败"))
activeSession = null
}
}.start()
result.success("installing")
} catch (e: Exception) {
result.error("INSTALL_FAILED", e.message, null)
}
}
private fun emit(event: String, vararg pairs: Pair<String, Any>) {
progressSink?.success(mapOf("event" to event, *pairs))
}
}
@@ -1,5 +1,25 @@
package com.example.observer
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private var cameraChannel: CameraChannel? = null
private var installerChannel: InstallerChannel? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 自写原生相机(替代 camera_android_camerax 插件):
// 分析帧在原生侧旋转成竖屏后回传,Flutter 侧恒 rotation=0(对齐 iOS)。
// configureFlutterEngineonCreate 阶段)只注册通道与 viewFactory
// 实际 bindToLifecycle 由 Flutter 相机页 start 时触发(此时已 RESUMED
cameraChannel = CameraChannel(this, flutterEngine).also { it.register() }
// App 内更新安装 APKPackageInstaller 会话安装 + 进度回传)
installerChannel = InstallerChannel(this, flutterEngine).also { it.register() }
}
override fun onDestroy() {
cameraChannel?.destroy()
super.onDestroy()
}
}