ConnectionThread now calls CaptureService.onNativeExit() via JNI on exit when g_bExit == S_CLIENT_EXIT (server-initiated). The Java side posts stopSelf() on the service handler. Guard: instance is null in the user-initiated teardown path (onDestroy nulls it before nativeStop), so onNativeExit is a no-op in that case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
410 lines
17 KiB
Kotlin
410 lines
17 KiB
Kotlin
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<Int, Int> {
|
||
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<Int, Int> {
|
||
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()
|
||
}
|
||
}
|