Files
SimpleRemoter/android/app/src/main/java/com/yama/client/CaptureService.kt
yuanyuanxiang 5296e534ed Fix: Android client stops service when server sends BYE (delete client)
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>
2026-06-25 17:38:01 +02:00

410 lines
17 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}