package com.yama.client import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent import android.hardware.display.DisplayManager import android.hardware.display.VirtualDisplay import android.media.MediaCodec import android.media.MediaCodecInfo import android.media.MediaFormat import android.media.projection.MediaProjection import android.media.projection.MediaProjectionManager import android.os.Build import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper import android.provider.Settings import android.util.DisplayMetrics import android.util.Log import android.view.WindowManager import android.widget.Toast class CaptureService : Service() { companion object { const val TAG = "YAMA" const val CHANNEL_ID = "yama_service" const val NOTIF_ID = 1 const val EXTRA_SERVER_IP = "server_ip" const val EXTRA_SERVER_PORT = "server_port" const val EXTRA_PROJECTION_RESULT = "projection_result" const val EXTRA_PROJECTION_DATA = "projection_data" private const val MAX_LONG_SIDE = 1080 // CS-01 fix: 通过 idrHandler.post 序列化到主线程,避免 TOCTOU 竞争 @Volatile var instance: CaptureService? = null @JvmStatic fun onNativeStatus(msg: String) { val svc = instance ?: return svc.idrHandler.post { Toast.makeText(svc, msg, Toast.LENGTH_LONG).show() } } @JvmStatic fun onNativeExit() { val svc = instance ?: return Log.i(TAG, "onNativeExit: server requested disconnect, stopping service") svc.idrHandler.post { svc.stopSelf() } } @JvmStatic fun requestIdr() { val svc = instance ?: return svc.idrHandler.post { runCatching { val p = Bundle() p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) svc.mediaCodec?.setParameters(p) } } } /** * 静止屏幕黑屏修复:SurfaceFlinger 在无脏区时不向 VirtualDisplay 推帧, * 导致编码器无输入,REQUEST_SYNC_FRAME 悬空排队,浏览器等 10-60 秒才见画面。 * 解决方法:将 VirtualDisplay 宽度临时改变 2px,触发一次强制合成, * 编码器随即得到输入帧并消费已排队的 IDR 请求。150ms 后恢复原始尺寸。 */ @JvmStatic fun forceFirstFrame() { val svc = instance ?: return svc.idrHandler.post { runCatching { val vd = svc.virtualDisplay ?: return@runCatching val w = ControlService.encW val h = ControlService.encH val dpi = svc.resources.displayMetrics.densityDpi Log.d(TAG, "forceFirstFrame: resize ${w}x${h} → ${w+2}x${h} to trigger composition") vd.resize(w + 2, h, dpi) svc.idrHandler.postDelayed({ runCatching { vd.resize(w, h, dpi) } Log.d(TAG, "forceFirstFrame: restored ${w}x${h}") }, 150) } } } } private var mediaProjection: MediaProjection? = null private var virtualDisplay: VirtualDisplay? = null private var mediaCodec: MediaCodec? = null private val idrHandler = Handler(Looper.getMainLooper()) // CS-04 fix: runCatching 包住 setParameters,防止 codec 已停止时抛异常 private val idrRunnable = object : Runnable { override fun run() { runCatching { val p = Bundle() p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) mediaCodec?.setParameters(p) } idrHandler.postDelayed(this, 1000) } } override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() instance = this createNotificationChannel() // CS-06: start with DATA_SYNC type only; upgrade to MEDIA_PROJECTION in // onStartCommand when the token is actually available. On Android 14+ // (targetSdk 34+) calling startForeground(MEDIA_PROJECTION) without // immediately pairing it with getMediaProjection() causes the system to // kill the service after a ~5-second grace period — the root cause of the // 3-9 s disconnect seen on Google TV when no screen-capture permission // was granted. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(NOTIF_ID, buildNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { startForeground(NOTIF_ID, buildNotification()) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // CS-03 fix: START_STICKY 重启时 intent 为 null,必须在最前面检查 if (intent == null) return START_NOT_STICKY val ip = intent.getStringExtra(EXTRA_SERVER_IP) ?: return START_NOT_STICKY val port = intent.getIntExtra(EXTRA_SERVER_PORT, 443) val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) ?: "" val model = "${Build.MANUFACTURER} ${Build.MODEL}" val osVer = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" val (sw, sh) = getPhysicalResolution() val res = "1:${sw}*${sh}" val user = Build.USER Log.i(TAG, "CaptureService: server=$ip:$port device=$model res=$res") // YB-01 fix: so 库加载失败是 Error,不被 catch(Exception) 捕获,需单独处理 // ExceptionInInitializerError 包裹 UnsatisfiedLinkError(首次访问 object 时触发) val initRet: Int try { initRet = YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "", filesDir.absolutePath) } catch (e: UnsatisfiedLinkError) { Log.e(TAG, "Native library load failed: $e") stopSelf() return START_NOT_STICKY } catch (e: ExceptionInInitializerError) { Log.e(TAG, "Native library init failed: ${e.cause}") stopSelf() return START_NOT_STICKY } if (initRet != 0) { Log.e(TAG, "nativeInit failed: ret=$initRet") stopSelf() return START_NOT_STICKY } val projResult = intent.getIntExtra(EXTRA_PROJECTION_RESULT, -1) val projData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) intent.getParcelableExtra(EXTRA_PROJECTION_DATA, Intent::class.java) else @Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_PROJECTION_DATA) if (projResult == android.app.Activity.RESULT_OK && projData != null) { // Android 14+: upgrade foreground type to MEDIA_PROJECTION in the same // start command that calls getMediaProjection(), as required by API 34+. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(NOTIF_ID, buildNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) } val pm = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val mp = pm.getMediaProjection(projResult, projData) mediaProjection = mp // CS-05 fix: 分辨率为 0 时不启动采集,避免 MediaCodec 配置崩溃 if (sw > 0 && sh > 0) { try { startCapture(mp, sw, sh) } catch (e: Exception) { Log.e(TAG, "Screen capture failed: $e — running without capture") } } else { Log.e(TAG, "Invalid screen resolution ${sw}x${sh}, skipping capture") } } else { Log.w(TAG, "No MediaProjection token, skipping screen capture") } return START_STICKY } override fun onDestroy() { super.onDestroy() instance = null stopCapture() YamaBridge.nativeStop() Log.i(TAG, "CaptureService destroyed") } // ---------------------------------------------------------------- MediaProjection + MediaCodec private fun startCapture(mp: MediaProjection, physW: Int, physH: Int) { val (encW, encH) = scaleDown(physW, physH, MAX_LONG_SIDE) val dpi = resources.displayMetrics.densityDpi Log.i(TAG, "Capture: phys=${physW}x${physH} enc=${encW}x${encH} dpi=$dpi") val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) val surface = configureEncoder(codec, encW, encH) var configData: ByteArray? = null codec.setCallback(object : MediaCodec.Callback() { override fun onInputBufferAvailable(mc: MediaCodec, index: Int) { /* Surface 模式不用 */ } override fun onOutputBufferAvailable(mc: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { val buf = mc.getOutputBuffer(index) if (buf == null || info.size == 0) { mc.releaseOutputBuffer(index, false) return } buf.position(info.offset) buf.limit(info.offset + info.size) if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { configData = ByteArray(info.size).also { buf.get(it) } mc.releaseOutputBuffer(index, false) return } val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0 val frame = ByteArray(info.size).also { buf.get(it) } mc.releaseOutputBuffer(index, false) // 部分安卓硬件编码器(如某些高通实现)对 REQUEST_SYNC_FRAME 强制产生的 // IDR 帧不设置 BUFFER_FLAG_KEY_FRAME,导致 SendLoop 将所有帧视为 P 帧丢弃。 // 补充扫描 NALU 字节来识别真正的关键帧,确保首帧能顺利发出。 val actualIsKey = isKey || isNaluKeyframe(frame) val payload = if (actualIsKey && configData != null) configData!! + frame else frame YamaBridge.nativeOnH264Frame(payload, 0, payload.size, actualIsKey) } override fun onError(mc: MediaCodec, e: MediaCodec.CodecException) { Log.e(TAG, "MediaCodec error: $e") } override fun onOutputFormatChanged(mc: MediaCodec, format: MediaFormat) { Log.i(TAG, "Output format: $format") } }) codec.start() mediaCodec = codec idrHandler.postDelayed(idrRunnable, 500) // Android 14+ requires callback registered before createVirtualDisplay() mp.registerCallback(object : MediaProjection.Callback() { override fun onStop() { Log.i(TAG, "MediaProjection stopped — switching to dataSync foreground type") stopCapture() // Android 14+: system auto-kills a foreground service whose mediaProjection // is revoked. Re-declare as dataSync-only to survive without screen capture. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(NOTIF_ID, buildNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } } }, null) virtualDisplay = mp.createVirtualDisplay( "YAMA", encW, encH, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, surface, null, null ) YamaBridge.nativeSetScreenSize(encW, encH) ControlService.physW = physW ControlService.physH = physH ControlService.encW = encW ControlService.encH = encH Log.i(TAG, "MediaCodec + VirtualDisplay started ${encW}x${encH}") } private fun buildFmt(w: Int, h: Int, lowLatency: Boolean, baseline: Boolean) = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, w, h).apply { setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000) setInteger(MediaFormat.KEY_FRAME_RATE, 30) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) setInteger(MediaFormat.KEY_PRIORITY, 0) setInteger(MediaFormat.KEY_OPERATING_RATE, 30) if (lowLatency) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) setInteger(MediaFormat.KEY_LATENCY, 0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setInteger(MediaFormat.KEY_LOW_LATENCY, 1) } if (baseline) setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) } // 逐级降级:低延迟+Baseline → 低延迟 → 默认,确保真机兼容 private fun configureEncoder(codec: MediaCodec, w: Int, h: Int): android.view.Surface { val configs = listOf( true to true, true to false, false to false, ) for ((ll, bl) in configs) { try { val fmt = buildFmt(w, h, ll, bl) codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) Log.i(TAG, "Encoder configured: lowLatency=$ll baseline=$bl") return codec.createInputSurface() } catch (e: Exception) { Log.w(TAG, "Encoder config failed (lowLatency=$ll baseline=$bl): $e, retrying…") try { codec.reset() } catch (_: Exception) {} } } throw RuntimeException("MediaCodec: all encoder configs failed") } private fun stopCapture() { idrHandler.removeCallbacks(idrRunnable) runCatching { virtualDisplay?.release() } // CS-07 fix: stop 和 release 分开,stop 失败不会跳过 release 导致资源泄漏 runCatching { mediaCodec?.stop() } runCatching { mediaCodec?.release() } runCatching { mediaProjection?.stop() } virtualDisplay = null mediaCodec = null mediaProjection = null } // ---------------------------------------------------------------- 辅助函数 private fun getPhysicalResolution(): Pair { val wm = getSystemService(WINDOW_SERVICE) as WindowManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val b = wm.currentWindowMetrics.bounds Pair(b.width(), b.height()) } else { @Suppress("DEPRECATION") val dm = DisplayMetrics() @Suppress("DEPRECATION") wm.defaultDisplay.getRealMetrics(dm) Pair(dm.widthPixels, dm.heightPixels) } } /** 扫描 Annex-B NALU 流,遇到 NAL type 5(IDR)、7(SPS)、8(PPS) 即判定为关键帧 */ private fun isNaluKeyframe(data: ByteArray): Boolean { var i = 0 while (i + 4 < data.size) { if (data[i] == 0.toByte() && data[i+1] == 0.toByte() && data[i+2] == 0.toByte() && data[i+3] == 1.toByte()) { val nalType = data[i + 4].toInt() and 0x1F if (nalType == 5 || nalType == 7 || nalType == 8) return true i += 5 } else { i++ } } return false } /** 按长边上限等比缩小,宽高各自 2 对齐(H.264 要求) */ private fun scaleDown(w: Int, h: Int, maxLong: Int): Pair { val longSide = maxOf(w, h) if (longSide <= maxLong) return Pair(w and -2, h and -2) val scale = maxLong.toDouble() / longSide return Pair((w * scale).toInt() and -2, (h * scale).toInt() and -2) } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val ch = NotificationChannel(CHANNEL_ID, "YAMA Service", NotificationManager.IMPORTANCE_LOW) getSystemService(NotificationManager::class.java)?.createNotificationChannel(ch) } } private fun buildNotification(): Notification { val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL_ID) else @Suppress("DEPRECATION") Notification.Builder(this) return b.setContentTitle("YAMA") .setContentText("Screen monitoring active") .setSmallIcon(android.R.drawable.ic_menu_camera) .build() } }