wip: refactoring codebase

This commit is contained in:
Miuzarte
2026-03-26 00:56:02 +08:00
parent 0aa83f4a63
commit c567ed1bbb
21 changed files with 2269 additions and 1340 deletions

View File

@@ -1,38 +1,31 @@
package io.github.miuzarte.scrcpyforandroid
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
import java.io.File
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
/**
* Facade that centralizes ADB and scrcpy native operations.
* Facade that centralizes video rendering and ADB operations.
*
* Provides synchronous and asynchronous helpers that run on an internal
* single-thread executor to serialize access to the native session manager
* and decoders. Callers should use the provided methods from UI code but
* not perform heavy work on the main thread directly.
* Provides helpers for:
* - ADB operations (all suspend functions)
* - Surface/Decoder management for video rendering
* - Control input injection (all suspend functions)
*/
class NativeCoreFacade(private val appContext: Context) {
private val adbService = NativeAdbService(appContext)
private val sessionManager = ScrcpySessionManager(adbService)
private val executor = Executors.newSingleThreadExecutor()
val sessionManager = ScrcpySessionManager(NativeAdbService(appContext))
private val sessionLifecycleMutex = Mutex()
private val surfaceMap = ConcurrentHashMap<String, Surface>()
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
@@ -52,11 +45,10 @@ class NativeCoreFacade(private val appContext: Context) {
@Volatile
private var currentSessionInfo: ScrcpySessionInfo? = null
fun close() {
releaseAllDecoders()
runCatching { sessionManager.stop() }
runCatching { adbService.close() }
executor.shutdown()
suspend fun close() {
sessionLifecycleMutex.withLock {
releaseAllDecoders()
}
}
/**
@@ -69,29 +61,30 @@ class NativeCoreFacade(private val appContext: Context) {
* that owns the TextureView). Native decoder operations are performed synchronously
* on the UI thread only via the decoder API; heavy work happens inside the decoder.
*/
fun registerVideoSurface(tag: String, surface: Surface) {
val newId = System.identityHashCode(surface)
val oldId = surfaceIdentityMap[tag]
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
return
}
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
surfaceMap[tag] = surface
surfaceIdentityMap[tag] = newId
val session = currentSessionInfo ?: return
ensureVideoConsumerAttached()
val decoder = decoderMap[tag]
if (decoder != null) {
val switched = decoder.switchOutputSurface(surface)
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
if (switched) {
suspend fun registerVideoSurface(tag: String, surface: Surface) {
sessionLifecycleMutex.withLock {
val newId = System.identityHashCode(surface)
val oldId = surfaceIdentityMap[tag]
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
return
}
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
surfaceMap[tag] = surface
surfaceIdentityMap[tag] = newId
val session = currentSessionInfo ?: return
// ensureVideoConsumerAttached()
val decoder = decoderMap[tag]
if (decoder != null) {
val switched = decoder.switchOutputSurface(surface)
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
if (switched) {
return
}
}
createOrReplaceDecoder(tag, surface, session)
}
createOrReplaceDecoder(tag, surface, session)
}
/**
* Unregister the rendering Surface previously bound to `tag`.
*
@@ -99,331 +92,22 @@ class NativeCoreFacade(private val appContext: Context) {
* - When there is no active session, the decoder for the tag is released immediately.
* - This protects the native decoder from feeding into a released Surface.
*/
fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
val currentId = surfaceIdentityMap[tag]
val requestId = surface?.let { System.identityHashCode(it) }
if (requestId != null && currentId != null && requestId != currentId) {
Log.i(
TAG,
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId"
)
return
}
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
surfaceMap.remove(tag)
surfaceIdentityMap.remove(tag)
if (currentSessionInfo == null) {
decoderMap.remove(tag)?.release()
}
}
/**
* Pair with a device over ADB pairing protocol.
* @return true on successful pairing, false otherwise.
*/
fun adbPair(host: String, port: Int, pairingCode: String): Boolean {
return ioCall { adbService.pair(host, port, pairingCode) }
}
/**
* Discover an ADB pairing service (mDNS) and return its host:port.
* Returns null if no service is found within `timeoutMs`.
*/
fun adbDiscoverPairingService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return ioCall { adbService.discoverPairingService(timeoutMs, includeLanDevices) }
}
/**
* Discover an ADB connect service for direct connection (mDNS).
*/
fun adbDiscoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return ioCall { adbService.discoverConnectService(timeoutMs, includeLanDevices) }
}
/**
* Connect to an ADB server at the given host and port.
* Returns true on success.
*/
fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
/**
* Disconnect current ADB connection. Always returns true.
*/
fun adbDisconnect(): Boolean {
ioCall { adbService.disconnect() }
return true
}
/**
* Check whether an ADB connection is currently established.
*/
fun adbIsConnected(): Boolean = ioCall { adbService.isConnected() }
/**
* Execute a shell command over ADB and return its stdout as a string.
*/
fun adbShell(command: String): String = ioCall { adbService.shell(command) }
/**
* Set the local ADB key name used when generating or selecting key files.
*/
fun setAdbKeyName(name: String) {
adbService.keyName = name
}
/**
* Start a scrcpy session synchronously.
*
* - This method runs on the internal single-threaded [executor] via [ioCall], so
* callers block until the start completes. It handles server extraction, starting
* the session manager, creating decoders for any registered surfaces, and setting
* up audio playback when available.
* - After the session is established, `currentSessionInfo` is populated and
* bootstrap packets are reset so newly created decoders can be primed.
*/
fun scrcpyStart(request: ScrcpyStartRequest): ScrcpySessionInfo {
return ioCall {
Log.i(TAG, "scrcpyStart(): request codec=${request.videoCodec} audio=${request.audio}")
val serverJar = if (request.customServerUri.isNullOrBlank()) {
extractAssetToCache(request.serverAsset)
} else {
extractUriToCache(request.customServerUri.toUri())
}
val info = sessionManager.start(
serverJar.toPath(),
ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = request.serverVersion,
serverRemotePath = request.serverRemotePath,
video = !request.noVideo,
audio = request.audio,
control = !request.noControl,
maxSize = request.maxSize,
maxFps = request.maxFps,
videoBitRate = request.videoBitRate,
videoCodec = request.videoCodec,
audioBitRate = request.audioBitRate,
audioCodec = request.audioCodec,
videoEncoder = request.videoEncoder,
videoCodecOptions = request.videoCodecOptions,
audioEncoder = request.audioEncoder,
audioCodecOptions = request.audioCodecOptions,
audioDup = request.audioDup,
audioSource = request.audioSource,
videoSource = request.videoSource,
cameraId = request.cameraId,
cameraFacing = request.cameraFacing,
cameraSize = request.cameraSize,
cameraAr = request.cameraAr,
cameraFps = request.cameraFps,
cameraHighSpeed = request.cameraHighSpeed,
newDisplay = request.newDisplay,
displayId = request.displayId,
crop = request.crop,
),
)
if (request.turnScreenOff) {
if (request.noControl) {
Log.w(TAG, "scrcpyStart(): turnScreenOff ignored because control is disabled")
} else {
runCatching { sessionManager.setDisplayPower(on = false) }
.onFailure { e -> Log.w(TAG, "scrcpyStart(): set display power failed", e) }
}
}
val session = ScrcpySessionInfo(
width = info.width,
height = info.height,
deviceName = info.deviceName,
codec = info.codecName,
controlEnabled = info.controlEnabled,
)
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
if (!request.noVideo) {
surfaceMap.forEach { (tag, surface) ->
Log.i(TAG, "scrcpyStart(): bind decoder to tag=$tag")
createOrReplaceDecoder(tag, surface, session)
}
}
packetCount = 0
if (!request.noVideo) {
ensureVideoConsumerAttached()
}
// Audio player
audioPlayer?.release()
audioPlayer = null
if (info.audioCodecId != 0 && !request.noAudioPlayback) {
suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
sessionLifecycleMutex.withLock {
val currentId = surfaceIdentityMap[tag]
val requestId = surface?.let { System.identityHashCode(it) }
if (requestId != null && currentId != null && requestId != currentId) {
Log.i(
TAG,
"scrcpyStart(): create audio player codecId=0x${
info.audioCodecId.toUInt().toString(16)
}"
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId"
)
val player = ScrcpyAudioPlayer(info.audioCodecId)
audioPlayer = player
sessionManager.attachAudioConsumer { packet ->
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
}
} else {
Log.i(TAG, "scrcpyStart(): audio playback disabled for this session")
return
}
session
}
}
/**
* Stop any running scrcpy session and clear all video/audio consumers.
*
* - Executes on the internal executor to keep scrcpy/session-manager operations
* serialized with other IO operations.
* - Releases decoders and audio players and resets session state.
*/
fun scrcpyStop(): Boolean {
ioCall {
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
currentSessionInfo = null
sessionManager.clearVideoConsumer()
sessionManager.clearAudioConsumer()
sessionManager.stop()
audioPlayer?.release()
audioPlayer = null
}
return true
}
fun scrcpyListEncoders(
customServerUri: String?,
remotePath: String,
serverVersion: String = "3.3.4"
): ScrcpyEncoderLists {
return ioCall {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(DEFAULT_SERVER_ASSET)
} else {
extractUriToCache(customServerUri.toUri())
}
val result = sessionManager.listEncoders(
serverJarPath = serverJar.toPath(),
options = ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = serverVersion,
serverRemotePath = remotePath,
),
)
ScrcpyEncoderLists(
videoEncoders = result.videoEncoders,
audioEncoders = result.audioEncoders,
videoEncoderTypes = result.videoEncoderTypes,
audioEncoderTypes = result.audioEncoderTypes,
rawOutput = result.rawOutput,
)
}
}
fun scrcpyListCameraSizes(
customServerUri: String?,
remotePath: String,
serverVersion: String = "3.3.4"
): ScrcpyCameraSizeLists {
return ioCall {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(DEFAULT_SERVER_ASSET)
} else {
extractUriToCache(customServerUri.toUri())
}
val result = sessionManager.listCameraSizes(
serverJarPath = serverJar.toPath(),
options = ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = serverVersion,
serverRemotePath = remotePath,
),
)
ScrcpyCameraSizeLists(
sizes = result.sizes,
rawOutput = result.rawOutput,
)
}
}
fun scrcpyIsStarted(): Boolean = ioCall { sessionManager.isStarted() }
fun getLastScrcpyServerCommand(): String? = ioCall { sessionManager.getLastServerCommand() }
fun scrcpyInjectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
ioExecute {
runCatching { sessionManager.injectKeycode(action, keycode, repeat, metaState) }
}
}
fun scrcpyInjectText(text: String) {
ioExecute {
runCatching { sessionManager.injectText(text) }
}
}
fun scrcpyInjectTouch(
action: Int,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
pointerId: Long = 0L,
actionButton: Int = 1,
buttons: Int = 1,
) {
ioExecute {
runCatching {
sessionManager.injectTouch(
action = action,
pointerId = pointerId,
x = x,
y = y,
screenWidth = screenWidth,
screenHeight = screenHeight,
pressure = pressure,
actionButton = actionButton,
buttons = buttons,
)
}
}
}
fun scrcpyInjectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int = 0,
) {
ioExecute {
runCatching {
sessionManager.injectScroll(
x,
y,
screenWidth,
screenHeight,
hScroll,
vScroll,
buttons
)
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
surfaceMap.remove(tag)
surfaceIdentityMap.remove(tag)
if (currentSessionInfo == null) {
decoderMap.remove(tag)?.release()
}
}
}
@@ -444,35 +128,73 @@ class NativeCoreFacade(private val appContext: Context) {
videoFpsListeners.remove(listener)
}
fun scrcpyBackOrScreenOn(action: Int = 0) {
ioExecute {
runCatching { sessionManager.pressBackOrScreenOn(action) }
suspend fun scrcpyBackOrScreenOn(action: Int = 0) {
sessionManager.pressBackOrScreenOn(action)
}
/**
* Called by Scrcpy.kt when a session starts.
* Sets up video decoders for registered surfaces.
*/
suspend fun onScrcpySessionStarted(
session: ScrcpySessionInfo,
sessionMgr: ScrcpySessionManager
) = sessionLifecycleMutex.withLock {
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
surfaceMap.forEach { (tag, surface) ->
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag")
createOrReplaceDecoder(tag, surface, session)
}
packetCount = 0
// if (!request.noVideo) {
// ensureVideoConsumerAttached()
// }
sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}"
)
}
decoderMap.forEach { (tag, decoder) ->
if (!surfaceIdentityMap.containsKey(tag)) {
return@forEach
}
runCatching {
decoder.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig
)
}
}
}
}
private fun extractAssetToCache(assetPath: String): File {
val clean = assetPath.removePrefix("/")
val source = appContext.assets.open(clean)
val outputFile = File(appContext.cacheDir, File(clean).name)
source.use { input ->
outputFile.outputStream().use { output -> input.copyTo(output) }
/**
* Called by Scrcpy.kt when a session stops.
* Cleans up decoders and resets state.
*/
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
return outputFile
}
private fun extractUriToCache(uri: Uri): File {
val fileName = "custom-scrcpy-server.jar"
val outputFile = File(appContext.cacheDir, fileName)
appContext.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Unable to open selected server URI" }
outputFile.outputStream().use { output -> input.copyTo(output) }
}
return outputFile
currentSessionInfo = null
}
companion object {
private const val TAG = "NativeCoreFacade"
private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
private const val MAX_BOOTSTRAP_PACKETS = 90
@Volatile
@@ -484,78 +206,6 @@ class NativeCoreFacade(private val appContext: Context) {
}
}
fun defaultStartRequest(
customServerUri: String?,
maxSize: Int,
videoBitRate: Int,
remotePath: String,
videoCodec: String = "h264",
audio: Boolean = true,
audioCodec: String = "opus",
audioBitRate: Int = 128_000,
maxFps: Float = 0f,
noControl: Boolean = false,
videoEncoder: String = "",
videoCodecOptions: String = "",
audioEncoder: String = "",
audioCodecOptions: String = "",
audioDup: Boolean = false,
audioSource: String = "",
videoSource: String = "display",
cameraId: String = "",
cameraFacing: String = "",
cameraSize: String = "",
cameraAr: String = "",
cameraFps: Int = 0,
cameraHighSpeed: Boolean = false,
noAudioPlayback: Boolean = false,
requireAudio: Boolean = false,
turnScreenOff: Boolean = false,
noVideo: Boolean = false,
newDisplay: String = "",
displayId: Int? = null,
crop: String = "",
): ScrcpyStartRequest {
return ScrcpyStartRequest(
serverAsset = DEFAULT_SERVER_ASSET,
customServerUri = customServerUri,
serverVersion = "3.3.4",
serverRemotePath = remotePath,
maxSize = maxSize,
videoBitRate = videoBitRate,
videoCodec = videoCodec,
audio = audio,
audioCodec = audioCodec,
audioBitRate = audioBitRate,
maxFps = maxFps,
noControl = noControl,
videoEncoder = videoEncoder,
videoCodecOptions = videoCodecOptions,
audioEncoder = audioEncoder,
audioCodecOptions = audioCodecOptions,
audioDup = audioDup,
audioSource = audioSource,
videoSource = videoSource,
cameraId = cameraId,
cameraFacing = cameraFacing,
cameraSize = cameraSize,
cameraAr = cameraAr,
cameraFps = cameraFps,
cameraHighSpeed = cameraHighSpeed,
noAudioPlayback = noAudioPlayback,
requireAudio = requireAudio,
turnScreenOff = turnScreenOff,
noVideo = noVideo,
newDisplay = newDisplay,
displayId = displayId,
crop = crop,
)
}
fun nowLogPrefix(): String {
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
return "[$stamp]"
}
}
private data class CachedPacket(
@@ -692,8 +342,9 @@ class NativeCoreFacade(private val appContext: Context) {
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are
* isolated with `runCatching` so one codec failure doesn't stop others.
*/
private fun ensureVideoConsumerAttached() {
sessionManager.attachVideoConsumer { packet ->
@Deprecated("TODO: Determine if this is really unnecessary")
private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) {
sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
@@ -718,7 +369,6 @@ class NativeCoreFacade(private val appContext: Context) {
}
}
private fun releaseAllDecoders() {
decoderMap.values.forEach { decoder ->
runCatching { decoder.release() }
@@ -726,87 +376,11 @@ class NativeCoreFacade(private val appContext: Context) {
decoderMap.clear()
}
/**
* Execute a blocking IO task on the internal single-thread executor and return its result.
*
* - This serializes access to native adb/session operations and unwraps
* ExecutionException to rethrow the underlying cause.
*/
private fun <T> ioCall(task: () -> T): T {
return try {
executor.submit<T> { task() }.get()
} catch (e: ExecutionException) {
val cause = e.cause
if (cause is Exception) {
throw cause
}
throw RuntimeException(cause ?: e)
}
}
/**
* Submit a non-blocking IO task to the internal executor.
*
* - Use this for fire-and-forget native operations where the caller does not need
* a synchronous result (e.g. injecting input, toggling power, etc.).
*/
private fun ioExecute(task: () -> Unit) {
executor.execute(task)
}
data class ScrcpySessionInfo(
val width: Int,
val height: Int,
val deviceName: String,
val codec: String,
val controlEnabled: Boolean,
)
}
data class ScrcpyStartRequest(
val serverAsset: String,
val customServerUri: String?,
val serverVersion: String,
val serverRemotePath: String,
val maxSize: Int,
val videoBitRate: Int,
val videoCodec: String = "h264",
val audio: Boolean = true,
val audioCodec: String = "opus",
val audioBitRate: Int = 128_000,
val maxFps: Float = 0f,
val noControl: Boolean = false,
val videoEncoder: String = "",
val videoCodecOptions: String = "",
val audioEncoder: String = "",
val audioCodecOptions: String = "",
val audioDup: Boolean = false,
val audioSource: String = "",
val videoSource: String = "display",
val cameraId: String = "",
val cameraFacing: String = "",
val cameraSize: String = "",
val cameraAr: String = "",
val cameraFps: Int = 0,
val cameraHighSpeed: Boolean = false,
val noAudioPlayback: Boolean = false,
val requireAudio: Boolean = false,
val turnScreenOff: Boolean = false,
val noVideo: Boolean = false,
val newDisplay: String = "",
val displayId: Int? = null,
val crop: String = "",
)
data class ScrcpyEncoderLists(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String> = emptyMap(),
val audioEncoderTypes: Map<String, String> = emptyMap(),
val rawOutput: String = "",
)
data class ScrcpyCameraSizeLists(
val sizes: List<String>,
val rawOutput: String = "",
)
data class ScrcpySessionInfo(
val width: Int,
val height: Int,
val deviceName: String,
val codec: String,
val controlEnabled: Boolean,
)

View File

@@ -548,7 +548,7 @@ internal class DirectAdbConnection(
* Logical ADB stream abstraction mapped to a local id. Provides blocking
* `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers.
*/
internal class AdbSocketStream(
class AdbSocketStream(
val localId: Int,
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
) : Closeable {

View File

@@ -143,7 +143,7 @@ internal class DirectAdbPairingClient(
READY,
EXCHANGING_MSGS,
EXCHANGING_PEER_INFO,
STOPPED,
STOPPED;
}
private lateinit var socket: Socket

View File

@@ -2,18 +2,22 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.util.Log
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import java.nio.file.Path
import kotlin.time.Duration
/**
* Higher-level ADB service that wraps `DirectAdbTransport` and provides
* synchronized connect/disconnect/shell helpers for callers.
* coroutine-based connect/disconnect/shell helpers for callers.
*
* Methods are synchronized because the underlying transport is single-connection
* and accessed from the app's serialized IO executor.
* Methods use Mutex for thread-safety because the underlying transport is single-connection
* and may be accessed from multiple coroutines.
*/
class NativeAdbService(appContext: Context) {
private val transport = DirectAdbTransport(appContext)
private val mutex = Mutex()
@Volatile
private var connection: DirectAdbConnection? = null
@@ -30,14 +34,13 @@ class NativeAdbService(appContext: Context) {
transport.keyName = value
}
@Synchronized
fun pair(host: String, port: Int, pairingCode: String): Boolean {
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean = mutex.withLock {
val h = host.trim()
val code = pairingCode.trim()
require(h.isNotBlank()) { "host is blank" }
require(code.isNotBlank()) { "pairing code is blank" }
Log.i(TAG, "pair(): host=$h port=$port")
return try {
return@withLock try {
transport.pair(h, port, code)
} catch (e: Exception) {
Log.e(TAG, "pair(): failed host=$h port=$port", e)
@@ -46,12 +49,11 @@ class NativeAdbService(appContext: Context) {
}
}
@Synchronized
fun discoverPairingService(
suspend fun discoverPairingService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
): Pair<String, Int>? {
return try {
): Pair<String, Int>? = mutex.withLock {
return@withLock try {
transport.discoverPairingService(timeoutMs, includeLanDevices)
} catch (e: Exception) {
Log.w(TAG, "discoverPairingService(): failed", e)
@@ -59,12 +61,11 @@ class NativeAdbService(appContext: Context) {
}
}
@Synchronized
fun discoverConnectService(
suspend fun discoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
): Pair<String, Int>? {
return try {
): Pair<String, Int>? = mutex.withLock {
return@withLock try {
transport.discoverConnectService(timeoutMs, includeLanDevices)
} catch (e: Exception) {
Log.w(TAG, "discoverConnectService(): failed", e)
@@ -77,20 +78,27 @@ class NativeAdbService(appContext: Context) {
* same host:port it is reused; otherwise the previous connection is closed
* before attempting the new connect.
*/
@Synchronized
fun connect(host: String, port: Int): Boolean {
suspend fun connect(
host: String,
port: Int,
timeout: Duration = Duration.INFINITE,
) = mutex.withLock {
Log.i(TAG, "connect(): host=$host port=$port")
val existing = connection
if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) {
return true
if (connection != null
&& connection!!.isAlive()
&& connectedHost == host
&& connectedPort == port
) {
return@withLock
}
disconnect()
disconnectInternal()
try {
val conn = transport.connect(host, port)
val conn = withTimeout(timeout) { transport.connect(host, port) }
connection = conn
connectedHost = host
connectedPort = port
return true
} catch (e: Exception) {
Log.e(TAG, "connect(): failed host=$host port=$port", e)
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
@@ -101,39 +109,42 @@ class NativeAdbService(appContext: Context) {
/**
* Close the current ADB connection immediately.
*/
@Synchronized
fun disconnect() {
suspend fun disconnect() = mutex.withLock {
disconnectInternal()
}
suspend fun isConnected(): Boolean = mutex.withLock {
connection?.isAlive() == true
}
/**
* Execute a shell command on the connected device and return stdout text.
*/
suspend fun shell(command: String): String = mutex.withLock {
requireConnection().shell(command)
}
suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock {
requireConnection().openStream("shell:$command")
}
suspend fun push(localPath: Path, remotePath: String) = mutex.withLock {
requireConnection().push(localPath.toFile().readBytes(), remotePath)
}
suspend fun openAbstractSocket(name: String): AdbSocketStream = mutex.withLock {
requireConnection().openStream("localabstract:$name")
}
suspend fun close() = disconnect()
private fun disconnectInternal() {
runCatching { connection?.close() }
connection = null
connectedHost = null
connectedPort = null
}
@Synchronized
fun isConnected(): Boolean = connection?.isAlive() == true
/**
* Execute a shell command on the connected device and return stdout text.
*/
@Synchronized
fun shell(command: String): String = requireConnection().shell(command)
@Synchronized
internal fun openShellStream(command: String): AdbSocketStream =
requireConnection().openStream("shell:$command")
@Synchronized
fun push(localPath: Path, remotePath: String) {
requireConnection().push(localPath.toFile().readBytes(), remotePath)
}
@Synchronized
internal fun openAbstractSocket(name: String): AdbSocketStream =
requireConnection().openStream("localabstract:$name")
@Synchronized
fun close() = disconnect()
private fun requireConnection(): DirectAdbConnection {
return connection?.takeIf { it.isAlive() }
?: throw IllegalStateException("ADB not connected")

View File

@@ -2,6 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
import android.util.Log
import android.view.KeyEvent
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.BufferedInputStream
import java.io.BufferedReader
import java.io.DataInputStream
@@ -15,6 +19,8 @@ import kotlin.concurrent.thread
import kotlin.math.roundToInt
class ScrcpySessionManager(private val adbService: NativeAdbService) {
private val mutex = Mutex()
@Volatile
private var activeSession: ActiveSession? = null
@@ -45,29 +51,27 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
* - Initializes an [ActiveSession] which holds socket streams and reader threads.
*
* Threading notes:
* - This is synchronized to avoid concurrent starts/stops.
* - Uses Mutex for thread-safety to avoid concurrent starts/stops.
* - It may block while interacting with adb; callers should execute it off the UI
* thread when appropriate. The facade uses an executor to serialize such calls.
* thread when appropriate.
*/
@Synchronized
fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo {
stop()
synchronized(this) {
serverLogBuffer.clear()
}
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
val scid = random.nextInt(Int.MAX_VALUE)
val socketName = socketNameFor(scid)
suspend fun start(
serverJarPath: Path,
serverCommand: String,
scid: UInt,
options: ClientOptions,
): SessionInfo = mutex.withLock {
stopInternal()
serverLogBuffer.clear()
val socketName = socketNameFor(scid.toInt())
try {
adbService.push(serverJarPath, targetPath)
val cmd = buildServerCommand(targetPath, scid, options)
lastServerCommand = cmd
lastServerCommand = serverCommand
Log.i(
TAG,
"start(): socket=$socketName codec=${options.videoCodec} audio=${options.audio} audioCodec=${options.audioCodec}"
"start(): socket=$socketName codec=${options.videoCodec.string} audio=${options.audio} audioCodec=${options.audioCodec.string}"
)
val serverStream = adbService.openShellStream(cmd)
val serverStream = adbService.openShellStream(serverCommand)
val serverLogThread = startServerLogThread(serverStream, socketName)
Thread.sleep(SERVER_BOOT_DELAY_MS)
@@ -118,7 +122,8 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
}
val deviceName = readDeviceName(firstInput)
val audioCodecId = if (options.audio) audioCodecIdFromName(options.audioCodec) else 0
val audioCodecId =
if (options.audio) audioCodecIdFromName(options.audioCodec.string) else 0
val codecId: Int
val width: Int
val height: Int
@@ -175,8 +180,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
* - The reader thread stops when the session ends or the socket is closed.
* - Consumers should be resilient to occasional dropped packets or reader errors.
*/
@Synchronized
fun attachVideoConsumer(consumer: (VideoPacket) -> Unit) {
suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
val vInput = session.videoInput ?: return
val vStream = session.videoStream ?: return
@@ -224,8 +228,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
}
}
@Synchronized
fun clearVideoConsumer() {
suspend fun clearVideoConsumer() = mutex.withLock {
videoConsumer = null
}
@@ -237,8 +240,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
* - The function reads the audio stream header to determine whether audio is
* available and exits early if disabled.
*/
@Synchronized
fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) {
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
val aInput = session.audioInput ?: return // audio disabled or unavailable
val aStream = session.audioStream ?: return
@@ -307,8 +309,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
}
}
@Synchronized
fun clearAudioConsumer() {
suspend fun clearAudioConsumer() = mutex.withLock {
audioConsumer = null
}
@@ -316,15 +317,14 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
* Inject a keycode event to the control channel.
*
* - Requires an active control channel; throws if absent.
* - Synchronized to serialize control writes.
* - Uses Mutex to serialize control writes.
*/
@Synchronized
fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
}
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) =
mutex.withLock {
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
}
@Synchronized
fun injectText(text: String) {
suspend fun injectText(text: String) = mutex.withLock {
requireControlWriter().injectText(text)
}
@@ -333,10 +333,9 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
*
* - Coordinates are expected in device pixels and are written together with
* screen dimensions so the server can interpret them correctly.
* - Synchronized to serialize control writes.
* - Uses Mutex to serialize control writes.
*/
@Synchronized
fun injectTouch(
suspend fun injectTouch(
action: Int,
pointerId: Long,
x: Int,
@@ -346,7 +345,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
pressure: Float,
actionButton: Int,
buttons: Int,
) {
) = mutex.withLock {
requireControlWriter().injectTouch(
action,
pointerId,
@@ -360,8 +359,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
)
}
@Synchronized
fun injectScroll(
suspend fun injectScroll(
x: Int,
y: Int,
screenWidth: Int,
@@ -369,7 +367,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
hScroll: Float,
vScroll: Float,
buttons: Int
) {
) = mutex.withLock {
requireControlWriter().injectScroll(
x,
y,
@@ -381,13 +379,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
)
}
@Synchronized
fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) {
suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
requireControlWriter().pressBackOrScreenOn(action)
}
@Synchronized
fun setDisplayPower(on: Boolean) {
suspend fun setDisplayPower(on: Boolean) = mutex.withLock {
requireControlWriter().setDisplayPower(on)
}
@@ -397,8 +393,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
* - Interrupts and joins reader threads with short timeouts, closes sockets,
* and clears state. It is safe to call from any thread.
*/
@Synchronized
fun stop() {
suspend fun stop() = mutex.withLock {
stopInternal()
}
private fun stopInternal() {
val session = activeSession ?: return
activeSession = null
videoConsumer = null
@@ -430,20 +429,20 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
fun getLastServerCommand(): String? = lastServerCommand
@Synchronized
fun listEncoders(serverJarPath: Path, options: ScrcpyStartOptions): EncoderLists {
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
// TODO: 合并几个 --list-xxxx
suspend fun listEncoders(
serverJarPath: Path,
serverParams: ServerParams,
serverVersion: String = "3.3.4",
serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
): EncoderLists = mutex.withLock {
val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
adbService.push(serverJarPath, targetPath)
val cmd = buildServerCommand(
targetPath = targetPath,
scid = 0x1234abcd,
options = options.copy(
video = false,
audio = false,
control = false,
listEncoders = true,
cleanup = false,
),
serverParams = serverParams,
serverVersion = serverVersion,
)
Log.i(TAG, "listEncoders(): cmd=$cmd")
// scrcpy encoder list is printed in logs, so merge stderr into stdout.
@@ -457,20 +456,19 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
return parsed.copy(rawOutput = output)
}
@Synchronized
fun listCameraSizes(serverJarPath: Path, options: ScrcpyStartOptions): CameraSizeLists {
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
suspend fun listCameraSizes(
serverJarPath: Path,
serverParams: ServerParams,
serverVersion: String = "3.3.4",
serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
): CameraSizeLists = mutex.withLock {
val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
adbService.push(serverJarPath, targetPath)
val cmd = buildServerCommand(
targetPath = targetPath,
scid = 0x2234abcd,
options = options.copy(
video = false,
audio = false,
control = false,
listCameraSizes = true,
cleanup = false,
),
serverParams = serverParams,
serverVersion = serverVersion,
)
Log.i(TAG, "listCameraSizes(): cmd=$cmd")
val output = adbService.shell("$cmd 2>&1")
@@ -490,288 +488,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
private fun buildServerCommand(
targetPath: String,
scid: Int,
options: ScrcpyStartOptions
serverParams: ServerParams,
serverVersion: String,
): String {
val serverArgs = buildServerArgs(scid, options)
return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server ${options.serverVersion} $serverArgs"
}
private fun buildServerArgs(scid: Int, options: ScrcpyStartOptions): String {
val videoSource = options.videoSource.trim().ifBlank { "display" }
val isCameraSource = videoSource.equals("camera", ignoreCase = true)
val cameraId = options.cameraId.trim()
val hasCameraId = cameraId.isNotBlank()
val hasExplicitCameraSize = options.cameraSize.trim().isNotBlank()
val hasValidCameraFps = options.cameraFps > 0
val args = listOf(
ServerArg(
type = ServerArgType.STRING,
key = "scid",
value = String.format("%08x", scid),
),
ServerArg(
type = ServerArgType.STRING,
key = "log_level",
value = "debug",
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "tunnel_forward",
value = true,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "cleanup",
value = options.cleanup,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "send_device_meta",
value = true,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "send_frame_meta",
value = true,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "send_dummy_byte",
value = true,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "send_codec_meta",
value = true,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "video",
value = options.video,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "audio",
value = options.audio,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "control",
value = options.control,
),
ServerArg(
type = ServerArgType.STRING,
key = "video_source",
value = videoSource,
),
ServerArg(
type = ServerArgType.NUMBER,
key = "max_size",
value = options.maxSize,
),
ServerArg(
type = ServerArgType.STRING,
key = "video_codec",
value = options.videoCodec,
),
ServerArg(
type = ServerArgType.NUMBER,
key = "video_bit_rate",
value = options.videoBitRate,
),
ServerArg(
type = ServerArgType.NUMBER,
key = "max_fps",
value = options.maxFps,
),
ServerArg(
type = ServerArgType.STRING,
key = "camera_id",
value = cameraId,
includeWhen = isCameraSource,
),
ServerArg(
type = ServerArgType.STRING,
key = "camera_facing",
value = options.cameraFacing.trim(),
includeWhen = isCameraSource && !hasCameraId,
),
ServerArg(
type = ServerArgType.STRING,
key = "camera_size",
value = options.cameraSize.trim(),
includeWhen = isCameraSource,
),
ServerArg(
type = ServerArgType.STRING,
key = "camera_ar",
value = options.cameraAr.trim(),
includeWhen = isCameraSource && !hasExplicitCameraSize,
),
ServerArg(
type = ServerArgType.NUMBER,
key = "camera_fps",
value = options.cameraFps,
includeWhen = isCameraSource,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "camera_high_speed",
value = options.cameraHighSpeed,
includeWhen = isCameraSource && options.cameraHighSpeed && hasValidCameraFps,
),
ServerArg(
type = ServerArgType.STRING,
key = "audio_codec",
value = options.audioCodec,
includeWhen = options.audio,
),
ServerArg(
type = ServerArgType.NUMBER,
key = "audio_bit_rate",
value = options.audioBitRate,
includeWhen = options.audio,
),
ServerArg(
type = ServerArgType.STRING,
key = "audio_source",
value = options.audioSource.trim(),
includeWhen = options.audio,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "audio_dup",
value = options.audioDup,
includeWhen = options.audioDup,
),
ServerArg(
type = ServerArgType.STRING,
key = "video_encoder",
value = options.videoEncoder.trim(),
),
ServerArg(
type = ServerArgType.STRING,
key = "video_codec_options",
value = options.videoCodecOptions.trim(),
),
ServerArg(
type = ServerArgType.STRING,
key = "audio_encoder",
value = options.audioEncoder.trim(),
),
ServerArg(
type = ServerArgType.STRING,
key = "audio_codec_options",
value = options.audioCodecOptions.trim(),
),
ServerArg(
type = ServerArgType.STRING,
key = "new_display",
value = options.newDisplay.trim(),
),
ServerArg(
type = ServerArgType.NUMBER,
key = "display_id",
value = options.displayId,
),
ServerArg(
type = ServerArgType.STRING,
key = "crop",
value = options.crop.trim(),
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "list_encoders",
value = options.listEncoders,
includeWhen = options.listEncoders,
),
ServerArg(
type = ServerArgType.BOOLEAN,
key = "list_camera_sizes",
value = options.listCameraSizes,
includeWhen = options.listCameraSizes,
),
// Reserved for future args that require repeated/list values.
// ServerArg(
// type = ServerArgType.LIST,
// key = "_unused_list",
// value = emptyList<String>(),
// includeWhen = false,
// ),
)
val rendered = args.mapNotNull { it.render() }
val header = rendered.firstOrNull()
val kv = rendered.drop(1)
return if (header == null) {
kv.joinToString(" ")
} else {
"$header ${kv.joinToString(" ")}".trim()
}
}
private enum class ServerArgType {
NUMBER,
STRING,
BOOLEAN,
LIST,
}
private enum class ZeroValueBehavior {
OMIT,
KEEP,
}
private data class ServerArg<T>(
val type: ServerArgType,
val key: String,
val value: T?,
val includeWhen: Boolean = true,
val zeroValueBehavior: ZeroValueBehavior = ZeroValueBehavior.OMIT,
) {
fun render(): String? {
if (!includeWhen) return null
return when (type) {
ServerArgType.STRING -> renderString(value as? String)
ServerArgType.BOOLEAN -> renderBoolean(value as? Boolean)
ServerArgType.NUMBER -> renderNumber(value as? Number)
ServerArgType.LIST -> renderList(value as? Iterable<*>)
}
}
private fun renderString(raw: String?): String? {
val normalized = raw?.trim().orEmpty()
if (normalized.isBlank()) return null
return "$key=$normalized"
}
private fun renderBoolean(raw: Boolean?): String? {
val normalized = raw ?: return null
return "$key=$normalized"
}
private fun renderNumber(raw: Number?): String? {
val normalized = raw ?: return null
val asDouble = normalized.toDouble()
if (asDouble == 0.0 && zeroValueBehavior == ZeroValueBehavior.OMIT) return null
val valueString = when (normalized) {
is Float -> normalized.toString()
is Double -> normalized.toString()
else -> normalized.toLong().toString()
}
return "$key=$valueString"
}
private fun renderList(raw: Iterable<*>?): String? {
val items = raw
?.mapNotNull { it?.toString()?.trim() }
?.filter { it.isNotBlank() }
.orEmpty()
if (items.isEmpty()) return null
return "$key=${items.joinToString(",")}"
}
val serverArgs = serverParams.build()
return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server $serverVersion $serverArgs"
}
private fun parseEncoderLists(output: String): EncoderLists {
@@ -836,7 +557,13 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
).use { reader ->
while (true) {
val line = reader.readLine() ?: break
appendServerLog(line)
// Use synchronized block for thread-safe log buffer access
synchronized(serverLogBuffer) {
if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) {
serverLogBuffer.removeFirst()
}
serverLogBuffer.addLast(line)
}
Log.i(TAG, "[server:$socketName] $line")
}
}
@@ -848,21 +575,15 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
}
}
@Synchronized
private fun appendServerLog(line: String) {
if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) {
serverLogBuffer.removeFirst()
}
serverLogBuffer.addLast(line)
}
@Synchronized
private fun snapshotServerLogs(maxLines: Int = 120): String {
if (serverLogBuffer.isEmpty()) {
return ""
val snapshot = synchronized(serverLogBuffer) {
if (serverLogBuffer.isEmpty()) {
return ""
}
val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES)
serverLogBuffer.toList().takeLast(take)
}
val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES)
return serverLogBuffer.toList().takeLast(take).joinToString("\n")
return snapshot.joinToString("\n")
}
/**
@@ -871,7 +592,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
* - Retries a number of times with a short delay (useful during server startup).
* - Optionally expects a dummy byte on the stream to validate the server handshake.
*/
private fun openAbstractSocketWithRetry(
private suspend fun openAbstractSocketWithRetry(
socketName: String,
expectDummyByte: Boolean
): AdbSocketStream {

View File

@@ -19,18 +19,29 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices
@@ -65,9 +76,6 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
import java.net.InetSocketAddress
import java.net.Socket
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
private const val ADB_CONNECT_TIMEOUT_MS = 3_000L
@@ -77,10 +85,9 @@ private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 1_200L
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 1_500L
private const val ADB_TCP_PROBE_TIMEOUT_MS = 600
private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F"
private const val LOG_TAG = "DevicePage"
private val DeviceShortcutStateListSaver =
listSaver<androidx.compose.runtime.snapshots.SnapshotStateList<DeviceShortcut>, String>(
listSaver<SnapshotStateList<DeviceShortcut>, String>(
save = { list ->
list.map { item ->
listOf(
@@ -109,7 +116,7 @@ private val DeviceShortcutStateListSaver =
)
private val StringStateListSaver =
listSaver<androidx.compose.runtime.snapshots.SnapshotStateList<String>, String>(
listSaver<SnapshotStateList<String>, String>(
save = { it.toList() },
restore = { it.toMutableStateList() },
)
@@ -118,15 +125,14 @@ private val StringStateListSaver =
fun DeviceTabScreen(
contentPadding: PaddingValues,
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
snack: SnackbarHostState,
scrollBehavior: ScrollBehavior,
virtualButtonsLayout: String,
showPreviewVirtualButtonText: Boolean,
previewCardHeightDp: Int,
themeBaseIndex: Int,
customServerUri: String?,
serverRemotePath: String,
onServerRemotePathChange: (String) -> Unit,
videoCodec: String,
onVideoCodecChange: (String) -> Unit,
audioEnabled: Boolean,
@@ -287,40 +293,12 @@ fun DeviceTabScreen(
currentTargetPort
) else null
val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() }
val quickDevices =
rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() }
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
LaunchedEffect(eventLog.size) {
onCanClearLogsChange(eventLog.isNotEmpty())
}
fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
val line = "[$timestamp] $message"
eventLog.add(0, line)
if (eventLog.size > AppDefaults.EVENT_LOG_LINES) {
eventLog.removeRange(AppDefaults.EVENT_LOG_LINES, eventLog.size)
}
when (level) {
Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error) else Log.e(
LOG_TAG,
message
)
Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error) else Log.w(
LOG_TAG,
message
)
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error) else Log.d(
LOG_TAG,
message
)
else -> if (error != null) Log.i(LOG_TAG, message, error) else Log.i(LOG_TAG, message)
}
LaunchedEffect(EventLogger.eventLog.size) {
onCanClearLogsChange(EventLogger.hasLogs())
}
/**
@@ -352,8 +330,8 @@ fun DeviceTabScreen(
) {
withContext(adbWorkerDispatcher) {
// Also stops scrcpy.
runCatching { nativeCore.scrcpyStop() }
runCatching { nativeCore.adbDisconnect() }
runCatching { scrcpy.stop() }
runCatching { adbService.disconnect() }
}
adbConnected = false
currentTargetHost = ""
@@ -383,7 +361,6 @@ fun DeviceTabScreen(
if (current.host == newHost && current.port == newPort) return
sessionReconnectBlacklistHosts += current.host
logEvent("切换连接目标,先断开当前设备: ${current.host}:${current.port}")
disconnectAdbConnection(clearQuickOnlineForTarget = current)
}
@@ -420,10 +397,10 @@ fun DeviceTabScreen(
* - Some adb endpoints can take long to accept TCP connects; the UI should not wait
* indefinitely. Use a small, caller-chosen timeout to keep UX snappy.
*/
suspend fun connectWithTimeout(host: String, port: Int): Boolean {
suspend fun connectWithTimeout(host: String, port: Int) {
return withContext(adbWorkerDispatcher) {
withTimeout(ADB_CONNECT_TIMEOUT_MS) {
nativeCore.adbConnect(host, port)
adbService.connect(host, port)
}
}
}
@@ -444,12 +421,12 @@ fun DeviceTabScreen(
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
return withContext(adbWorkerDispatcher) {
withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
val connected = nativeCore.adbIsConnected()
val connected = adbService.isConnected()
if (!connected) {
return@withTimeout false
}
runCatching {
nativeCore.adbShell("echo -n 1")
adbService.shell("echo -n 1")
true
}.getOrElse { false }
}
@@ -545,25 +522,21 @@ fun DeviceTabScreen(
}
}
suspend fun runAutoAdbConnect(host: String, port: Int): Boolean {
return runCatching {
suspend fun runAutoAdbConnect(host: String, port: Int) {
runCatching {
connectWithTimeout(host, port)
}.getOrElse { error ->
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
false
}
}
fun refreshEncoderLists() {
suspend fun refreshEncoderLists() {
if (!adbConnected) return
val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH }
runCatching {
nativeCore.scrcpyListEncoders(
customServerUri = customServerUri,
remotePath = remotePath,
)
}.onSuccess { lists ->
scrcpy.listOptions(list = ListOptions.ENCODERS)
}.onSuccess { result ->
val lists = result as Scrcpy.ListResult.Encoders
onVideoEncoderOptionsChange(lists.videoEncoders)
onAudioEncoderOptionsChange(lists.audioEncoders)
onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
@@ -574,12 +547,15 @@ fun DeviceTabScreen(
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
onAudioEncoderChange("")
}
logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) {
logEvent("提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN)
EventLogger.logEvent(
"提示: 编码器为空,请检查 server 路径/版本与设备系统日志",
Log.WARN
)
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
if (preview.isNotBlank()) {
logEvent("编码器原始输出: $preview", Log.DEBUG)
EventLogger.logEvent("编码器原始输出: $preview", Log.DEBUG)
}
}
}.onFailure { e ->
@@ -587,41 +563,46 @@ fun DeviceTabScreen(
onAudioEncoderOptionsChange(emptyList())
onVideoEncoderTypeMapChange(emptyMap())
onAudioEncoderTypeMapChange(emptyMap())
logEvent("读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e)
EventLogger.logEvent(
"读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR,
e
)
}
}
fun refreshCameraSizeLists() {
suspend fun refreshCameraSizeLists() {
if (!adbConnected) return
val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH }
runCatching {
nativeCore.scrcpyListCameraSizes(
customServerUri = customServerUri,
remotePath = remotePath,
)
}.onSuccess { lists ->
scrcpy.listOptions(ListOptions.CAMERA_SIZES)
}.onSuccess { result ->
val lists = result as Scrcpy.ListResult.CameraSizes
onCameraSizeOptionsChange(lists.sizes)
if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) {
onCameraSizePresetChange("")
}
logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
if (lists.sizes.isEmpty()) {
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
if (preview.isNotBlank()) {
logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
EventLogger.logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
}
}
}.onFailure { e ->
onCameraSizeOptionsChange(emptyList())
logEvent("读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e)
EventLogger.logEvent(
"读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR,
e
)
}
}
fun handleAdbConnected(host: String, port: Int) {
suspend fun handleAdbConnected(host: String, port: Int) {
currentTargetHost = host
currentTargetPort = port
val info = fetchConnectedDeviceInfo(nativeCore, host, port)
val info = fetchConnectedDeviceInfo(adbService, host, port)
val fullLabel = if (info.serial.isNotBlank()) {
"${info.model} (${info.serial})"
} else {
@@ -761,18 +742,18 @@ fun DeviceTabScreen(
if (alive) continue
logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
val reconnected = runCatching { connectWithTimeout(host, port) }.getOrElse { false }
adbConnected = reconnected
if (reconnected) {
try {
connectWithTimeout(host, port)
adbConnected = true
statusLine = "$host:$port"
logEvent("ADB 自动重连成功: $host:$port")
scope.launch {
snack.showSnackbar("ADB 自动重连成功")
}
} else {
} catch (e: Exception) {
disconnectAdbConnection()
statusLine = "ADB 连接断开"
logEvent("ADB 自动重连失败: $host:$port", Log.ERROR)
logEvent("ADB 自动重连失败: $e", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 自动重连失败")
}
@@ -806,26 +787,27 @@ fun DeviceTabScreen(
if (!portReachable) continue
quickConnectTriedOnce += targetKey
val ok = runAutoAdbConnect(target.host, target.port)
adbConnected = ok
upsertQuickDevice(
context,
quickDevices,
target.host,
target.port,
ok
)
if (ok) {
try {
runAutoAdbConnect(target.host, target.port)
adbConnected = true
upsertQuickDevice(
context,
quickDevices,
target.host,
target.port,
true,
)
handleAdbConnected(target.host, target.port)
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
break
} catch (_: Exception) {
}
break
}
if (adbConnected) break
}
val discovered = withContext(Dispatchers.IO) {
nativeCore.adbDiscoverConnectService(
adbService.discoverConnectService(
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
includeLanDevices = adbMdnsLanDiscoveryEnabled,
)
@@ -870,20 +852,19 @@ fun DeviceTabScreen(
continue
}
val ok = runAutoAdbConnect(discoveredHost, discoveredPort)
adbConnected = ok
upsertQuickDevice(
context,
quickDevices,
discoveredHost,
discoveredPort,
ok
)
if (ok) {
try {
runAutoAdbConnect(discoveredHost, discoveredPort)
adbConnected = true
upsertQuickDevice(
context,
quickDevices,
discoveredHost,
discoveredPort,
true
)
handleAdbConnected(discoveredHost, discoveredPort)
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
} else {
logEvent("ADB 自动重连失败: $discoveredHost:$discoveredPort", Log.WARN)
} catch (_: Exception) {
}
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
@@ -925,7 +906,7 @@ fun DeviceTabScreen(
runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() }
}
onClearLogsActionChange {
eventLog.clear()
EventLogger.clearLogs()
}
onOpenReorderDevicesActionChange {
showReorderSheet = true
@@ -973,8 +954,8 @@ fun DeviceTabScreen(
fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") {
nativeCore.scrcpyInjectKeycode(0, keycode)
nativeCore.scrcpyInjectKeycode(1, keycode)
nativeCore.sessionManager.injectKeycode(0, keycode)
nativeCore.sessionManager.injectKeycode(1, keycode)
}
}
@@ -1024,12 +1005,13 @@ fun DeviceTabScreen(
itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device ->
val host = device.host
val port = device.port
val isConnectedTarget =
adbConnected && currentTarget?.host == host && currentTarget.port == port
val isConnectedTarget = adbConnected
&& currentTarget?.host == host
&& currentTarget.port == port
DeviceTile(
device = device,
actionText = if (isConnectedTarget) "断开" else "连接",
actionText = if (!isConnectedTarget) "连接" else "断开",
actionEnabled = !busy && !adbConnecting,
actionInProgress = adbConnecting && activeDeviceActionId == device.id,
onLongPress = { editingDeviceId = device.id },
@@ -1040,7 +1022,24 @@ fun DeviceTabScreen(
},
onAction = {
haptics.contextClick()
if (isConnectedTarget) {
if (!isConnectedTarget) {
activeDeviceActionId = device.id
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
disconnectCurrentTargetBeforeConnecting(host, port)
try {
connectWithTimeout(host, port)
adbConnected = true
upsertQuickDevice(context, quickDevices, host, port, true)
handleAdbConnected(host, port)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 连接失败")
}
}
}
} else {
activeDeviceActionId = device.id
runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) {
sessionReconnectBlacklistHosts += host
@@ -1050,23 +1049,6 @@ fun DeviceTabScreen(
showSnackMessage = "ADB 已断开",
)
}
} else {
activeDeviceActionId = device.id
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
disconnectCurrentTargetBeforeConnecting(host, port)
val ok = connectWithTimeout(host, port)
adbConnected = ok
upsertQuickDevice(context, quickDevices, host, port, ok)
if (ok) {
handleAdbConnected(host, port)
} else {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $host:$port", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 连接失败")
}
}
}
}
},
)
@@ -1093,16 +1075,16 @@ fun DeviceTabScreen(
},
onConnect = {
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
runAdbConnect("连接 ADB") {
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
val ok = connectWithTimeout(target.host, target.port)
adbConnected = ok
upsertQuickDevice(context, quickDevices, target.host, target.port, ok)
if (ok) {
try {
connectWithTimeout(target.host, target.port)
adbConnected = true
upsertQuickDevice(context, quickDevices, target.host, target.port, true)
handleAdbConnected(target.host, target.port)
} else {
} catch (e: Exception) {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: ${target.host}:${target.port}", Log.ERROR)
logEvent("ADB 连接失败: $e", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 连接失败")
}
@@ -1116,7 +1098,7 @@ fun DeviceTabScreen(
busy = busy,
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = {
nativeCore.adbDiscoverPairingService(
adbService.discoverPairingService(
includeLanDevices = adbMdnsLanDiscoveryEnabled,
)
},
@@ -1125,7 +1107,7 @@ fun DeviceTabScreen(
val resolvedHost = host.trim()
val resolvedPort = port.toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim()
val ok = nativeCore.adbPair(
val ok = adbService.pair(
resolvedHost,
resolvedPort,
resolvedCode,
@@ -1222,52 +1204,55 @@ fun DeviceTabScreen(
cropY.filter(Char::isDigit),
)
val effectiveTurnScreenOff = turnScreenOff && !noControl
val session = nativeCore.scrcpyStart(
NativeCoreFacade.defaultStartRequest(
customServerUri = customServerUri,
maxSize = maxSize,
maxFps = maxFps,
videoBitRate = bitRateBps,
remotePath = serverRemotePath.trim(),
videoCodec = videoCodec,
audio = audioEnabled,
audioCodec = audioCodec,
audioBitRate = audioBitRateBps,
noControl = noControl,
videoEncoder = videoEncoder,
videoCodecOptions = videoCodecOptions,
audioEncoder = audioEncoder,
audioCodecOptions = audioCodecOptions,
audioDup = audioDup,
audioSource = resolvedAudioSource,
videoSource = resolvedVideoSource,
cameraId = resolvedCameraId,
cameraFacing = resolvedCameraFacing,
cameraSize = resolvedCameraSize,
cameraAr = resolvedCameraAr,
cameraFps = resolvedCameraFps,
cameraHighSpeed = cameraHighSpeed,
noAudioPlayback = noAudioPlayback,
noVideo = noVideo,
requireAudio = requireAudio,
turnScreenOff = effectiveTurnScreenOff,
newDisplay = newDisplayArg,
displayId = displayId,
crop = crop,
),
val options = ClientOptions(
crop = crop,
videoCodecOptions = videoCodecOptions,
audioCodecOptions = audioCodecOptions,
videoEncoder = videoEncoder,
audioEncoder = audioEncoder,
cameraId = resolvedCameraId,
cameraSize = resolvedCameraSize,
cameraAr = resolvedCameraAr,
cameraFps = resolvedCameraFps.toUShort(),
videoCodec = Codec.fromString(videoCodec),
audioCodec = Codec.fromString(audioCodec),
videoSource = if (resolvedVideoSource == "camera") VideoSource.CAMERA else VideoSource.DISPLAY,
audioSource = if (resolvedAudioSource.isNotBlank()) AudioSource.fromString(
resolvedAudioSource
) else AudioSource.AUTO,
cameraFacing = if (resolvedCameraFacing.isNotBlank()) CameraFacing.fromString(
resolvedCameraFacing
) else CameraFacing.ANY,
maxSize = maxSize.toUShort(),
videoBitRate = bitRateBps.toUInt(),
audioBitRate = audioBitRateBps.toUInt(),
maxFps = if (maxFps > 0f) maxFps.toString() else "",
displayId = (displayId ?: 0).toUInt(),
control = !noControl,
video = !noVideo,
audio = audioEnabled,
requireAudio = requireAudio,
audioPlayback = !noAudioPlayback,
turnScreenOff = effectiveTurnScreenOff,
audioDup = audioDup,
newDisplay = newDisplayArg,
cameraHighSpeed = cameraHighSpeed,
)
// Start scrcpy using Scrcpy class
val session = scrcpy.start(
options = options,
)
sessionInfo = session
statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale")
val videoDetail = if (noVideo) {
"off"
} else {
"${session.codec} ${session.width}x${session.height} @${
String.format(
"%.1f",
bitRateMbps
)
}Mbps"
"${session.codec} ${session.width}x${session.height} " +
"@${String.format("%.1f", bitRateMbps)}Mbps"
}
val audioDetail = if (!audioEnabled) {
"off"
@@ -1279,17 +1264,16 @@ fun DeviceTabScreen(
scope.launch {
snack.showSnackbar("scrcpy 已启动")
}
nativeCore.getLastScrcpyServerCommand()?.let { command ->
scrcpy.getLastServerCommand()?.let { command ->
logEvent("scrcpy-server args: $command")
}
}
},
onStop = {
runBusy("停止 scrcpy") {
nativeCore.scrcpyStop()
scrcpy.stop()
sessionInfo = null
statusLine =
currentTarget?.let { "${it.host}:${it.port}" } ?: "ADB 已连接"
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
logEvent("scrcpy 已停止")
scope.launch {
snack.showSnackbar("scrcpy 已停止")
@@ -1333,9 +1317,9 @@ fun DeviceTabScreen(
}
}
if (eventLog.isNotEmpty()) item {
if (EventLogger.hasLogs()) item {
Spacer(Modifier.height(UiSpacing.PageItem))
LogsPanel(lines = eventLog)
LogsPanel(lines = EventLogger.eventLog)
}
// TODO: 放进 [AppPageLazyColumn] 里

View File

@@ -20,7 +20,7 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -113,9 +113,9 @@ fun FullscreenControlPage(
}
}
fun sendKeycode(keycode: Int) {
nativeCore.scrcpyInjectKeycode(0, keycode)
nativeCore.scrcpyInjectKeycode(1, keycode)
suspend fun sendKeycode(keycode: Int) {
nativeCore.sessionManager.injectKeycode(0, keycode)
nativeCore.sessionManager.injectKeycode(1, keycode)
}
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
@@ -132,7 +132,7 @@ fun FullscreenControlPage(
currentFps = currentFps,
enableBackHandler = false,
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
nativeCore.scrcpyInjectTouch(
nativeCore.sessionManager.injectTouch(
action = action,
pointerId = pointerId,
x = x,
@@ -150,7 +150,9 @@ fun FullscreenControlPage(
bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter),
onAction = { action ->
action.keycode?.let(::sendKeycode)
action.keycode?.let {
sendKeycode(it)
}
},
)
}

View File

@@ -50,6 +50,8 @@ import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.MainSettings
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
@@ -82,7 +84,7 @@ private enum class MainTabDestination(
val icon: ImageVector,
) {
Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices),
Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings),
Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings);
}
private sealed interface RootScreen : NavKey {
@@ -99,7 +101,11 @@ fun MainPage() {
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
val adbService = remember(context) { NativeAdbService(context) }
val scrcpy = remember(context) { Scrcpy(context) }
val initialSettings = remember(context) { loadMainSettings(context) }
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
val snackHostState = remember { SnackbarHostState() }
@@ -272,7 +278,7 @@ fun MainPage() {
}
LaunchedEffect(adbKeyName) {
nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME })
adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME }
}
fun popRoot() {
@@ -308,8 +314,8 @@ fun MainPage() {
lastExitBackPressAtMs = 0L
scope.launch {
withContext(Dispatchers.IO) {
runCatching { nativeCore.scrcpyStop() }
runCatching { nativeCore.adbDisconnect() }
runCatching { scrcpy.stop() }
runCatching { adbService.disconnect() }
}
activity?.finish()
}
@@ -422,15 +428,14 @@ fun MainPage() {
DeviceTabScreen(
contentPadding = pagePadding,
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
snack = snackHostState,
scrollBehavior = deviceScrollBehavior,
virtualButtonsLayout = virtualButtonsLayout,
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
previewCardHeightDp = devicePreviewCardHeightDp,
themeBaseIndex = themeBaseIndex,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
onServerRemotePathChange = { serverRemotePath = it },
videoCodec = videoCodec,
onVideoCodecChange = { videoCodec = it },
audioEnabled = audioEnabled,

View File

@@ -0,0 +1,549 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
// TODO: 修改配置项时, validate() 判断是否合法, 非法则回退修改
// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项
// TODO: 禁用配置项验证, 包括不隐藏冲突配置项
// TODO: 配置切换
// TODO: 参数预览框可编辑
data class ClientOptions(
// var serial: String = "", // to server
// --crop=width:height:x:y
var crop: String = "", // to server
// TODO
// --record
var recordFilename: String = "",
// var windowTitle: String = "",
// var pushTarget: String = "",
// var renderDriver: String = "",
// --video-codec-options
var videoCodecOptions: String = "", // to server
// --audio-codec-options
var audioCodecOptions: String = "", // to server
// --video-encoder
var videoEncoder: String = "", // to server
// --audio-encoder
var audioEncoder: String = "", // to server
// --camera-id
var cameraId: String = "", // to server
// --camera-size
var cameraSize: String = "", // to server
// --camera-ar
var cameraAr: String = "", // to server
// --camera-fps
var cameraFps: UShort = 0u, // to server
var logLevel: LogLevel = LogLevel.INFO, // to server
// --video-codec
var videoCodec: Codec = Codec.H264, // to server
// --audio-codec
var audioCodec: Codec = Codec.OPUS, // to server
// --video-source
var videoSource: VideoSource = VideoSource.DISPLAY, // to server
// --audio-source
var audioSource: AudioSource = AudioSource.AUTO, // to server
// --record-format
var recordFormat: RecordFormat = RecordFormat.AUTO,
// var keyboardInputMode: KeyboardInputMode,
// var mouseInputMode: MouseInputMode,
// var gamepadInputMode: GamepadInputMode,
// sc_mouse_bindings(pri, sec)
// var mouseBindings: MouseBindings,
// --camera-facing
var cameraFacing: CameraFacing = CameraFacing.ANY, // to server
// var portRange: PortRange, // sc_port_range(first, last) // to server
// var tunnelHost: UInt, // to server
// var tunnelPort: UShort, // to server
// OR of enum sc_shortcut_mod values
// var shortcutMods: ShortcutMod,
// --max-size
var maxSize: UShort = 0u, // to server
// --video-bit-rate
var videoBitRate: UInt = 0u, // to server
// --audio-bit-rate
var audioBitRate: UInt = 0u, // to server
// --max-fps
var maxFps: String = "", // float to be parsed by the server
var angle: String = "", // float to be parsed by the server
// --capture-orientation=.*
var captureOrientation: Orientation = Orientation.ORIENT_0, // to server
// --capture-orientation=@.*
var captureOrientationLock: OrientationLock = OrientationLock.UNLOCKED, // to server
// --display-orientation
var displayOrientation: Orientation = Orientation.ORIENT_0,
// --record-orientation
var recordOrientation: Orientation = Orientation.ORIENT_0,
// --display-ime-policy
var displayImePolicy: DisplayImePolicy = DisplayImePolicy.UNDEFINED, // to server
// var windowX: Short,
// var windowY: Short,
// var windowWidth: UShort,
// var windowHeight: UShort,
// --display-id
var displayId: UInt = 0u, // to server
// var videoBuffer: Tick,
// var audioBuffer: Tick,
// var audioOutputBuffer: Tick,
// var timeLimit: Tick,
// --screen-off-timeout
var screenOffTimeout: Tick = Tick(-1), // to server
// var otg: Boolean,
// --show-touches
var showTouches: Boolean = false, // to server
// 开始后立即进入全屏
// --fullscreen
var fullscreen: Boolean = false,
// var alwaysOnTop: Boolean,
// --no-control
var control: Boolean = true, // to server
// --no-playback
// --no-video-playback
var videoPlayback: Boolean = true,
// --no-playback
// --no-audio-playback
var audioPlayback: Boolean = true,
// --turn-screen-off
var turnScreenOff: Boolean = false,
// [sc_key_inject_mode]
// var keyInjectMode: KeyInjectMode,
// var windowBorderless: Boolean,
// var mipmaps: Boolean,
// --stay-awake
var stayAwake: Boolean = false, // to server
// --force-adb-forward
// var forceAdbForward: Boolean, // to server
// --disable-screensaver
var disableScreensaver: Boolean = false,
// var forwardKeyRepeat: Boolean,
// var legacyPaste: Boolean,
// --power-off-on-close
var powerOffOnClose: Boolean = false, // to server
// --no-clipboard-autosync
// var clipboardAutosync: Boolean = true, // to server
// --no-downsize-on-error
// var downsizeOnError: Boolean = true, // to server
// var tcpip: Boolean, // to server
// var tcpipDst: String = "", // to server
// var selectUsb: Boolean, // to server
// var selectTcpip: Boolean, // to server
// --no-cleanup
var cleanUp: Boolean = true, // to server
// var startFpsCounter: Boolean,
// --no-power-on
var powerOn: Boolean = true, // to server
// --no-video
var video: Boolean = true, // to server
// --no-audio
var audio: Boolean = true, // to server
// --require-audio
var requireAudio: Boolean = false,
// 结束 scrcpy 后断开 adb 连接
// --kill-adb-on-close
var killAdbOnClose: Boolean = false, // to server but client side
// --camera-high-speed
var cameraHighSpeed: Boolean = false, // to server
var list: ListOptions = ListOptions.NULL, // to server
// --no-window
// var window: Boolean,
// --no-mouse-hover
// var mouseHover: Boolean = true,
// --audio-dup
var audioDup: Boolean = false, // to server
// --new-display=[<width>x<height>][/<dpi>]
var newDisplay: String = "", // to server
// --start-app
var startApp: String = "",
// --no-vd-destroy-content
var vdDestroyContent: Boolean = true, // to server
// --no-vd-system-decorations
var vdSystemDecorations: Boolean = true, // to server
) {
enum class RecordFormat(val string: String) {
AUTO("auto"), // ignore
MP4("mp4"),
MKV("mkv"),
M4A("m4a"),
MKA("mka"),
OPUS("opus"),
AAC("aac"),
FLAC("flac"),
WAV("wav");
fun isAudioOnly(): Boolean = when (this) {
M4A, MKA, OPUS, AAC, FLAC, WAV -> true
else -> false
}
}
fun validate() {
if (!video) {
videoPlayback = false
powerOn = false
}
if (!audio) {
audioPlayback = false
}
if (video && !videoPlayback && recordFilename.isBlank()) {
video = false
}
if (audio && !audioPlayback && recordFilename.isBlank()) {
audio = false
}
if (!video && !audio && !control) {
throw IllegalArgumentException(
"nothing to do"
)
}
if (!video) {
requireAudio = true
}
if (newDisplay.isNotBlank()) {
if (videoSource != VideoSource.DISPLAY) {
throw IllegalArgumentException(
"--new-display is only available with --video-source=display"
)
}
if (!video) {
throw IllegalArgumentException(
"--new-display is incompatible with --no-video"
)
}
}
if (videoSource == VideoSource.CAMERA) {
if (displayId > 0u) {
throw IllegalArgumentException(
"--display-id is only available with --video-source=display"
)
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
throw IllegalArgumentException(
"--display-ime-policy is only available with --video-source=display"
)
}
if (cameraId.isNotBlank() && cameraFacing != CameraFacing.ANY) {
throw IllegalArgumentException(
"Cannot specify both --camera-id and --camera-facing"
)
}
if (cameraSize.isNotBlank()) {
if (maxSize > 0u) {
throw IllegalArgumentException(
"Cannot specify both --camera-size and -m/--max-size"
)
}
if (cameraAr.isNotBlank()) {
throw IllegalArgumentException(
"--camera-high-speed requires an explicit --camera-fps value"
)
}
}
if (cameraHighSpeed && cameraFps > 0u) {
throw IllegalArgumentException(
"--camera-high-speed requires an explicit --camera-fps value"
)
}
if (control) {
control = false
}
} else if (cameraId.isNotBlank()
|| cameraAr.isNotBlank()
|| cameraFacing != CameraFacing.ANY
|| cameraFps > 0u
|| cameraHighSpeed
|| cameraSize.isNotBlank()
) {
throw IllegalArgumentException(
"Camera options are only available with --video-source=camera"
)
}
if (displayId > 0u && newDisplay.isNotBlank()) {
throw IllegalArgumentException(
"Cannot specify both --display-id and --new-display"
)
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED
&& displayId == 0u && newDisplay.isBlank()
) {
throw IllegalArgumentException(
"--display-ime-policy is only supported on a secondary display"
)
}
if (audio && audioSource == AudioSource.AUTO) {
// Select the audio source according to the video source
audioSource = if (videoSource == VideoSource.DISPLAY) {
if (audioDup) {
AudioSource.PLAYBACK
} else {
AudioSource.OUTPUT
}
} else {
AudioSource.MIC
}
}
if (audioDup) {
if (!audio) {
throw IllegalArgumentException(
"--audio-dup not supported if audio is disabled"
)
}
if (audioSource != AudioSource.PLAYBACK) {
throw IllegalArgumentException(
"--audio-dup is specific to --audio-source=playback"
)
}
}
if (recordFormat != RecordFormat.AUTO && recordFilename.isNotBlank()) {
throw IllegalArgumentException(
"Record format specified without recording"
)
}
if (recordFilename.isNotBlank()) {
if (!video && !audio) {
throw IllegalArgumentException(
"Video and audio disabled, nothing to record"
)
}
if (recordFormat == RecordFormat.AUTO) {
// TODO: guess from recordFilename
recordFormat = RecordFormat.MP4
}
if (recordOrientation != Orientation.ORIENT_0) {
if (recordOrientation.isMirror()) {
throw IllegalArgumentException(
"Record orientation only supports rotation, " +
"not flipping: ${recordOrientation.string}"
)
}
}
if (video && recordFormat.isAudioOnly()) {
throw IllegalArgumentException(
"Audio container does not support video stream"
)
}
if (recordFormat == RecordFormat.OPUS && audioCodec != Codec.OPUS) {
throw IllegalArgumentException(
"Recording to OPUS file requires an OPUS audio stream " +
"(try with --audio-codec=opus)"
)
}
if (recordFormat == RecordFormat.AAC && audioCodec != Codec.AAC) {
throw IllegalArgumentException(
"Recording to AAC file requires an AAC audio stream " +
"(try with --audio-codec=aac)"
)
}
if (recordFormat == RecordFormat.FLAC && audioCodec != Codec.FLAC) {
throw IllegalArgumentException(
"Recording to FLAC file requires an FLAC audio stream " +
"(try with --audio-codec=flac)"
)
}
if (recordFormat == RecordFormat.WAV && audioCodec != Codec.RAW) {
throw IllegalArgumentException(
"Recording to WAV file requires an RAW audio stream " +
"(try with --audio-codec=raw)"
)
}
if ((recordFormat == RecordFormat.MP4 || recordFormat == RecordFormat.M4A)
&& audioCodec == Codec.RAW
) {
throw IllegalArgumentException(
"Recording to MP4 container does not support RAW audio"
)
}
}
/*
if (audioCodec == Codec.FLAC && audioBitRate > 0u) {
// "--audio-bit-rate is ignored for FLAC audio codec"
// audioBitRate
}
*/
/*
if (audioCodec == Codec.RAW) {
if (audioBitRate > 0u) {
// "--audio-bit-rate is ignored for raw audio codec"
// audioBitRate
}
if (audioCodecOptions.isNotBlank()) {
// "--audio-codec-options is ignored for raw audio codec"
// audioCodecOptions
}
if (audioEncoder.isNotBlank()) {
// "--audio-encoder is ignored for raw audio codec"
// audioEncoder
}
}
*/
if (control) {
if (turnScreenOff) {
throw IllegalArgumentException(
"Cannot request to turn screen off if control is disabled"
)
}
if (stayAwake) {
throw IllegalArgumentException(
"Cannot request to stay awake if control is disabled"
)
}
if (showTouches) {
throw IllegalArgumentException(
"Cannot request to show touches if control is disabled"
)
}
if (powerOffOnClose) {
throw IllegalArgumentException(
"Cannot request power off on close if control is disabled"
)
}
if (startApp.isNotBlank()) {
throw IllegalArgumentException(
"Cannot start an Android app if control is disabled"
)
}
}
}
fun toServerParams(scid: UInt): ServerParams {
return ServerParams(
scid = scid,
logLevel = logLevel,
videoCodec = videoCodec,
audioCodec = audioCodec,
videoSource = videoSource,
audioSource = audioSource,
cameraFacing = cameraFacing,
crop = crop,
maxSize = maxSize,
videoBitRate = videoBitRate,
audioBitRate = audioBitRate,
maxFps = maxFps,
angle = angle,
screenOffTimeout = screenOffTimeout,
captureOrientation = captureOrientation,
captureOrientationLock = captureOrientationLock,
control = control,
displayId = displayId,
newDisplay = newDisplay,
displayImePolicy = displayImePolicy,
video = video,
audio = audio,
audioDup = audioDup,
showTouches = showTouches,
stayAwake = stayAwake,
videoCodecOptions = videoCodecOptions,
audioCodecOptions = audioCodecOptions,
videoEncoder = videoEncoder,
audioEncoder = audioEncoder,
cameraId = cameraId,
cameraSize = cameraSize,
cameraAr = cameraAr,
cameraFps = cameraFps,
powerOffOnClose = powerOffOnClose,
cleanUp = cleanUp,
powerOn = powerOn,
// killAdbOnClose == killAdbOnClose, // client side
cameraHighSpeed = cameraHighSpeed,
vdDestroyContent = vdDestroyContent,
vdSystemDecorations = vdSystemDecorations,
list = list,
)
}
}

View File

@@ -0,0 +1,424 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import java.io.File
import kotlin.random.Random
import kotlin.random.nextUInt
/**
* High-level scrcpy client API.
*
* Manages scrcpy sessions including:
* - Server jar extraction and deployment
* - Session lifecycle (start/stop)
* - Audio playback
* - Screen control
*
* @param context Android context
* @param serverAsset Asset path for the default server jar
* @param customServerUri Optional custom server URI (overrides serverAsset)
* @param serverVersion Server version string
* @param serverRemotePath Remote path where server jar will be pushed on device
*/
class Scrcpy(
private val context: Context,
private val serverAsset: String = DEFAULT_SERVER_ASSET,
private val customServerUri: String? = null,
private val serverVersion: String = "3.3.4",
private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
) {
private val adbService = NativeAdbService(context)
private val sessionManager = ScrcpySessionManager(adbService)
private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(context)
@Volatile
private var currentSession: ScrcpySessionInfo? = null
@Volatile
private var isRunning: Boolean = false
@Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null
companion object {
private const val TAG = "Scrcpy"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
// Regex patterns for parsing server output
private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)")
private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)")
private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""")
private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""")
private val VIDEO_ENCODER_TYPE_REGEX =
Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""")
private val AUDIO_ENCODER_TYPE_REGEX =
Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""")
private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)")
private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b")
private const val PREVIEW_LINES = 32
fun generateScid(): UInt {
// Only use 31 bits to avoid issues with signed values on the Java-side
return (Random.nextUInt() and 0x7FFFFFFFu)
}
}
suspend fun start(
options: ClientOptions,
): ScrcpySessionInfo {
if (isRunning) {
throw IllegalStateException("Scrcpy session is already running")
}
Log.i(TAG, "Initializing scrcpy session")
try {
// Validate options
options.validate()
// Generate session ID
val scid = generateScid()
Log.d(TAG, "scid=0x${scid.toString(16)}")
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(serverAsset)
} else {
extractUriToCache(customServerUri.toUri())
}
// Execute server
val info = executeServer(
serverJar = serverJar,
options = options,
scid = scid,
)
// Turn screen off if requested
if (options.turnScreenOff) {
if (!options.control) {
Log.w(TAG, "start(): turnScreenOff ignored because control is disabled")
} else {
runCatching { sessionManager.setDisplayPower(on = false) }
.onFailure { e -> Log.w(TAG, "start(): set display power failed", e) }
}
}
// Create session info
val session = ScrcpySessionInfo(
width = info.width,
height = info.height,
deviceName = info.deviceName,
codec = info.codecName,
controlEnabled = info.controlEnabled,
)
currentSession = session
isRunning = true
// Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) {
nativeCore.onScrcpySessionStarted(session, sessionManager)
}
// Setup audio player
audioPlayer?.release()
audioPlayer = null
if (info.audioCodecId != 0 && options.audioPlayback) {
Log.i(
TAG,
"start(): create audio player codecId=0x${
info.audioCodecId.toUInt().toString(16)
}"
)
val player = ScrcpyAudioPlayer(info.audioCodecId)
audioPlayer = player
sessionManager.attachAudioConsumer { packet ->
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
}
} else {
Log.i(TAG, "start(): audio playback disabled for this session")
}
Log.i(
TAG, "start(): Session started successfully - device=${session.deviceName}, " +
"video=${if (options.video) "${session.codec} ${session.width}x${session.height}" else "off"}, " +
"audio=${if (options.audio) options.audioCodec.string else "off"}, " +
"control=${options.control}"
)
return session
} catch (e: Exception) {
Log.e(TAG, "start(): Failed to start scrcpy session", e)
isRunning = false
currentSession = null
throw e
}
}
suspend fun stop(): Boolean {
if (!isRunning) {
Log.w(TAG, "stop(): No active session to stop")
return false
}
Log.i(TAG, "stop(): Stopping scrcpy session")
return try {
nativeCore.onScrcpySessionStopped()
sessionManager.clearVideoConsumer()
sessionManager.clearAudioConsumer()
sessionManager.stop()
audioPlayer?.release()
audioPlayer = null
isRunning = false
currentSession = null
Log.i(TAG, "stop(): Session stopped successfully")
true
} catch (e: Exception) {
Log.e(TAG, "stop(): Failed to stop session", e)
false
}
}
suspend fun close() {
stop()
adbService.close()
}
fun isStarted(): Boolean = isRunning && sessionManager.isStarted()
fun getCurrentSession(): ScrcpySessionInfo? = currentSession
fun getLastServerCommand(): String? = sessionManager.getLastServerCommand()
sealed class ListResult {
data class Encoders(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String> = emptyMap(),
val audioEncoderTypes: Map<String, String> = emptyMap(),
val rawOutput: String = "",
) : ListResult()
data class CameraSizes(
val sizes: List<String>,
val rawOutput: String = "",
) : ListResult()
}
/**
* List various options from the scrcpy server.
*
* @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.)
* @return ListResult containing the requested information
*/
suspend fun listOptions(list: ListOptions): ListResult {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(serverAsset)
} else {
extractUriToCache(customServerUri.toUri())
}
// Push server jar to device
adbService.push(serverJar.toPath(), serverRemotePath)
val scid = generateScid()
// Create ClientOptions for listing
val options = ClientOptions(
video = false,
audio = false,
control = false,
cleanUp = false,
list = list,
)
val serverParams = options.toServerParams(scid)
// Build server command
val serverCommand = serverParams.build(
"CLASSPATH=$serverRemotePath",
"app_process",
"/",
"com.genymobile.scrcpy.Server",
serverVersion,
)
Log.i(TAG, "listOptions(): cmd=$serverCommand")
// Execute shell command and capture output (merge stderr into stdout)
val output = adbService.shell("$serverCommand 2>&1")
// Parse output based on list option
return when (list) {
ListOptions.NULL -> {
throw IllegalArgumentException("Nothing to do with ListOptions.NULL")
}
ListOptions.ENCODERS -> {
val parsed = parseEncoderLists(output)
val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n")
Log.i(
TAG,
"listOptions(ENCODERS): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview",
)
ListResult.Encoders(
videoEncoders = parsed.videoEncoders,
audioEncoders = parsed.audioEncoders,
videoEncoderTypes = parsed.videoEncoderTypes,
audioEncoderTypes = parsed.audioEncoderTypes,
rawOutput = output,
)
}
ListOptions.DISPLAYS -> {
throw Exception("TODO")
}
ListOptions.CAMERAS -> {
throw Exception("TODO")
}
ListOptions.CAMERA_SIZES -> {
val parsed = parseCameraSizeLists(output)
val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n")
Log.i(
TAG,
"listOptions(CAMERA_SIZES): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview",
)
ListResult.CameraSizes(
sizes = parsed.sizes,
rawOutput = output,
)
}
else -> {
throw IllegalArgumentException("Unsupported list option: $list")
}
}
}
private fun parseEncoderLists(output: String): ParsedEncoders {
val video = LinkedHashSet<String>()
val audio = LinkedHashSet<String>()
val videoTypes = linkedMapOf<String, String>()
val audioTypes = linkedMapOf<String, String>()
VIDEO_ENCODER_REGEX.findAll(output).forEach { match ->
video.add(match.groupValues[1])
}
AUDIO_ENCODER_REGEX.findAll(output).forEach { match ->
audio.add(match.groupValues[1])
}
// Fallback for log formats that include codec+encoder in one line.
VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
video.add(match.groupValues[1])
}
AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
audio.add(match.groupValues[1])
}
VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
val name = match.groupValues[1]
val type = match.groupValues[2]
if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) {
videoTypes[name] = type
}
}
AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
val name = match.groupValues[1]
val type = match.groupValues[2]
if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) {
audioTypes[name] = type
}
}
return ParsedEncoders(
videoEncoders = video.toList(),
audioEncoders = audio.toList(),
videoEncoderTypes = videoTypes,
audioEncoderTypes = audioTypes,
)
}
private fun parseCameraSizeLists(output: String): ParsedCameraSizes {
val sizes = LinkedHashSet<String>()
CAMERA_SIZE_REGEX.findAll(output).forEach { match ->
sizes.add(match.groupValues[1])
}
CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match ->
sizes.add(match.groupValues[1])
}
return ParsedCameraSizes(sizes = sizes.toList())
}
private data class ParsedEncoders(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String>,
val audioEncoderTypes: Map<String, String>,
)
private data class ParsedCameraSizes(
val sizes: List<String>,
)
private suspend fun executeServer(
serverJar: File,
options: ClientOptions,
scid: UInt,
): ScrcpySessionManager.SessionInfo {
adbService.push(serverJar.toPath(), serverRemotePath)
val serverParams = options.toServerParams(scid)
val serverCommand = serverParams.build(
"CLASSPATH=$serverRemotePath",
"app_process",
"/",
"com.genymobile.scrcpy.Server",
serverVersion,
)
Log.d(TAG, "Server command: $serverCommand")
// Execute server (equivalent to sc_adb_execute in C)
Log.i(TAG, "executeServer(): Starting scrcpy server")
logEvent("scrcpy-server args: $serverCommand")
return sessionManager.start(
serverJarPath = serverJar.toPath(),
serverCommand = serverCommand,
scid = scid,
options = options,
)
}
private fun extractAssetToCache(assetPath: String): File {
val clean = assetPath.removePrefix("/")
val source = context.assets.open(clean)
val outputFile = File(context.cacheDir, File(clean).name)
source.use { input ->
outputFile.outputStream().use { output -> input.copyTo(output) }
}
return outputFile
}
private fun extractUriToCache(uri: Uri): File {
val fileName = "custom-scrcpy-server.jar"
val outputFile = File(context.cacheDir, fileName)
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Unable to open selected server URI" }
outputFile.outputStream().use { output -> input.copyTo(output) }
}
return outputFile
}
}

View File

@@ -0,0 +1,283 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
// 启动 scrcpy 前直接继承自 [ClientOptions],
// 不需要默认值
data class ServerParams(
var scid: UInt, // (random uint32) & 0x7FFFFFFF
// var reqSerial: String,
var logLevel: LogLevel,
var videoCodec: Codec,
var audioCodec: Codec,
var videoSource: VideoSource,
var audioSource: AudioSource,
var cameraFacing: CameraFacing,
var crop: String,
var videoCodecOptions: String,
var audioCodecOptions: String,
var videoEncoder: String,
var audioEncoder: String,
var cameraId: String,
var cameraSize: String,
var cameraAr: String, // aspect ratio
var cameraFps: UShort,
// var portRange: PortRange, // sc_port_range(first, last)
// var tunnelHost: UInt,
// var tunnelPort: UShort,
var maxSize: UShort,
var videoBitRate: UInt,
var audioBitRate: UInt,
var maxFps: String, // float to be parsed by the server
var angle: String, // float to be parsed by the server
var screenOffTimeout: Tick,
var captureOrientation: Orientation,
var captureOrientationLock: OrientationLock,
var control: Boolean,
var displayId: UInt,
var newDisplay: String,
var displayImePolicy: DisplayImePolicy,
var video: Boolean,
var audio: Boolean,
var audioDup: Boolean,
var showTouches: Boolean,
var stayAwake: Boolean,
// var forceAdbForward: Boolean,
var powerOffOnClose: Boolean,
// var downsizeOnError: Boolean,
// var tcpip: Boolean,
// var tcpipDst: String,
// var selectUsb: Boolean,
// var selectTcpip: Boolean,
var cleanUp: Boolean,
var powerOn: Boolean,
// var killAdbOnClose: Boolean,
var cameraHighSpeed: Boolean,
var vdDestroyContent: Boolean,
var vdSystemDecorations: Boolean,
var list: ListOptions,
) {
companion object {
const val SEPARATOR: String = " "
const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n"
}
private fun validate(str: String): Unit {
// forbid special shell characters
if (str.any { it in ILLEGAL_CHARACTER_SET }) {
throw IllegalArgumentException("Invalid server param: [$str]")
}
}
fun build(vararg extraArgs: String): String {
return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR)
}
fun toList(): MutableList<String> {
val cmd = mutableListOf<String>()
cmd.add("scid=${scid.toString(16)}")
cmd.add("log_level=${logLevel.string}")
if (!video) {
cmd.add("video=false")
}
if (videoBitRate > 0u) {
cmd.add("video_bit_rate=$videoBitRate")
}
if (!audio) {
cmd.add("audio=false")
}
if (audioBitRate > 0u) {
cmd.add("audio_bit_rate=$audioBitRate")
}
if (videoCodec != Codec.H264) {
cmd.add("video_codec=${videoCodec.string}")
}
if (audioCodec != Codec.OPUS) {
cmd.add("audio_codec=${audioCodec.string}")
}
if (videoSource != VideoSource.DISPLAY) {
cmd.add("video_source=${videoSource.string}")
}
// audio_source (default: output, only add if different and audio is enabled)
// Note: AUTO should have been resolved by ClientOptions.validate()
if (audioSource != AudioSource.OUTPUT && audio) {
cmd.add("audio_source=${audioSource.string}")
}
if (audioDup) {
cmd.add("audio_dup=true")
}
if (maxSize > 0u) {
cmd.add("max_size=$maxSize")
}
if (maxFps.isNotBlank()) {
validate(maxFps)
cmd.add("max_fps=${maxFps.trim()}")
}
if (angle.isNotBlank()) {
validate(angle)
cmd.add("angle=${angle.trim()}")
}
if (captureOrientationLock != OrientationLock.UNLOCKED
|| captureOrientation != Orientation.ORIENT_0
) {
when (captureOrientationLock) {
OrientationLock.LOCKED_INITIAL ->
cmd.add("capture_orientation=@")
OrientationLock.LOCKED_VALUE ->
cmd.add("capture_orientation=@${captureOrientation.string}")
OrientationLock.UNLOCKED ->
cmd.add("capture_orientation=${captureOrientation.string}")
}
}
// always true cause we are using adb wireless
cmd.add("tunnel_forward=true")
if (crop.isNotBlank()) {
validate(crop)
cmd.add("crop=${crop.trim()}")
}
if (!control) {
// By default, control is true
cmd.add("control=false")
}
if (displayId > 0u) {
cmd.add("display_id=$displayId")
}
if (cameraId.isNotBlank()) {
validate(cameraId)
cmd.add("camera_id=${cameraId.trim()}")
}
if (cameraSize.isNotBlank()) {
validate(cameraSize)
cmd.add("camera_size=${cameraSize.trim()}")
}
if (cameraFacing != CameraFacing.ANY) {
cmd.add("camera_facing=${cameraFacing.string}")
}
if (cameraAr.isNotBlank()) {
validate(cameraAr)
cmd.add("camera_ar=${cameraAr.trim()}")
}
if (cameraFps > 0u) {
cmd.add("camera_fps=$cameraFps")
}
if (cameraHighSpeed) {
cmd.add("camera_high_speed=true")
}
if (showTouches) {
cmd.add("show_touches=true")
}
if (stayAwake) {
cmd.add("stay_awake=true")
}
if (screenOffTimeout.value != -1L) {
require(screenOffTimeout >= 0) {
"screen_off_timeout must be >= 0"
}
cmd.add("screen_off_timeout=${screenOffTimeout.toMs()}")
}
if (videoCodecOptions.isNotBlank()) {
validate(videoCodecOptions)
cmd.add("video_codec_options=${videoCodecOptions.trim()}")
}
if (audioCodecOptions.isNotBlank()) {
validate(audioCodecOptions)
cmd.add("audio_codec_options=${audioCodecOptions.trim()}")
}
if (videoEncoder.isNotBlank()) {
validate(videoEncoder)
cmd.add("video_encoder=${videoEncoder.trim()}")
}
if (audioEncoder.isNotBlank()) {
validate(audioEncoder)
cmd.add("audio_encoder=${audioEncoder.trim()}")
}
if (powerOffOnClose) {
cmd.add("power_off_on_close=true")
}
// not implemented
val clipBoardAutosync = false
if (!clipBoardAutosync) {
cmd.add("clipboard_autosync=false")
}
// not implemented
val downsizeOnError = false
if (!downsizeOnError) {
cmd.add("downsize_on_error=false")
}
if (!cleanUp) {
// By default, cleanup is true
cmd.add("cleanup=false")
}
if (!powerOn) {
// By default, power_on is true
cmd.add("power_on=false")
}
if (newDisplay.isNotBlank()) {
validate(newDisplay)
cmd.add("new_display=${newDisplay.trim()}")
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
cmd.add("display_ime_policy=${displayImePolicy.string}")
}
if (!vdDestroyContent) {
cmd.add("vd_destroy_content=false")
}
if (!vdSystemDecorations) {
cmd.add("vd_system_decorations=false")
}
if (list has ListOptions.ENCODERS) {
cmd.add("list_encoders=true")
}
if (list has ListOptions.DISPLAYS) {
cmd.add("list_displays=true")
}
if (list has ListOptions.CAMERAS) {
cmd.add("list_cameras=true")
}
if (list has ListOptions.CAMERA_SIZES) {
cmd.add("list_camera_sizes=true")
}
if (list has ListOptions.APPS) {
cmd.add("list_apps=true")
}
return cmd
}
}

View File

@@ -0,0 +1,159 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
/*
参考官方实现尽量统一行为
*/
class Shared {
@JvmInline
value class Tick(val value: Long) {
fun toNs(): Long = value * 1000
fun toUs(): Long = value
fun toMs(): Long = value / 1000
fun toSec(): Long = value / 1_000_000
fun toSecDouble(): Double = value / 1_000_000.0
operator fun plus(other: Tick): Tick = Tick(value + other.value)
operator fun minus(other: Tick): Tick = Tick(value - other.value)
operator fun times(scale: Long): Tick = Tick(value * scale)
operator fun div(scale: Long): Tick = Tick(value / scale)
operator fun compareTo(other: Tick): Int = value.compareTo(other.value)
operator fun compareTo(other: Long): Int = value.compareTo(other)
operator fun compareTo(other: Int): Int = value.compareTo(other.toLong())
companion object {
const val FREQ = 1_000_000 // 微秒/秒
fun fromNs(ns: Long): Tick = Tick(ns / 1000)
fun fromUs(us: Long): Tick = Tick(us)
fun fromMs(ms: Long): Tick = Tick(ms * 1000)
fun fromSec(sec: Long): Tick = Tick(sec * 1_000_000)
fun fromSecDouble(sec: Double): Tick = Tick((sec * 1_000_000).toLong())
fun fromDuration(duration: Duration): Tick = Tick(duration.inWholeMicroseconds)
}
fun toDuration(): Duration = value.microseconds
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
}
enum class ListOptions(val value: Int) {
NULL(0x0),
ENCODERS(0x1), // --list-encoders
DISPLAYS(0x2), // --list-displays
CAMERAS(0x4), // --list-cameras
CAMERA_SIZES(0x8), // --list-camera-sizes
APPS(0x10); // --list-apps
infix fun or(other: ListOptions) = value or other.value
infix fun and(other: ListOptions) = value and other.value
infix fun has(other: ListOptions) = this and other != 0
}
enum class LogLevel(val string: String) {
VERBOSE("verbose"),
DEBUG("debug"),
INFO("info"),
WARN("warn"),
ERROR("error");
}
enum class Codec(val string: String) {
H264("h264"), // default, ignore when passing
H265("h265"),
AV1("av1"),
OPUS("opus"), // default, ignore when passing
AAC("aac"),
FLAC("flac"),
RAW("raw"); // wav raw
companion object {
fun fromString(value: String): Codec {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264
}
}
}
enum class VideoSource(val string: String) {
DISPLAY("display"), // default, ignore when passing
CAMERA("camera");
companion object {
fun fromString(value: String): VideoSource {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY
}
}
}
enum class AudioSource(val string: String) {
AUTO("auto"), // OUTPUT for video DISPLAY, MIC for video CAMERA
OUTPUT("output"),
MIC("mic"),
PLAYBACK("playback"),
MIC_UNPROCESSED("mic-unprocessed"),
MIC_CAMCORDER("mic-camcorder"),
MIC_VOICE_RECOGNITION("mic-voice-recognition"),
MIC_VOICE_COMMUNICATION("mic-voice-communication"),
VOICE_CALL("voice-call"),
VOICE_CALL_UPLINK("voice-call-uplink"),
VOICE_CALL_DOWNLINK("voice-call-downlink"),
VOICE_PERFORMANCE("voice-performance");
companion object {
fun fromString(value: String): AudioSource {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO
}
}
}
enum class CameraFacing(val string: String) {
ANY("any"), // default, ignore when passing
FRONT("front"),
BACK("back"),
EXTERNAL("external");
companion object {
fun fromString(value: String): CameraFacing {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY
}
}
}
enum class Orientation(val string: String) {
ORIENT_0("0"),
ORIENT_90("90"),
ORIENT_180("180"),
ORIENT_270("270"),
FLIP_0("flip0"),
FLIP_90("flip90"),
FLIP_180("flip180"),
FLIP_270("flip270");
fun isMirror(): Boolean = ordinal and 4 != 0
fun isSwap(): Boolean = ordinal and 1 != 0
fun getRotation(): Orientation {
return when (ordinal and 3) {
0 -> ORIENT_0
1 -> ORIENT_90
2 -> ORIENT_180
3 -> ORIENT_270
else -> error("Invalid rotation value")
}
}
}
enum class OrientationLock(val string: String) {
UNLOCKED("unlocked"), // ignore
LOCKED_VALUE("locked_value"), // "@${orientation.string}"
LOCKED_INITIAL("locked_initial"); // "@"
}
enum class DisplayImePolicy(val string: String) {
UNDEFINED("undefined"), // default, ignore when passing
LOCAL("local"),
FALLBACK("fallback"),
HIDE("hide");
}
}

View File

@@ -1,6 +1,8 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import kotlinx.coroutines.runBlocking
internal data class ConnectedDeviceInfo(
val model: String,
@@ -22,28 +24,21 @@ internal data class ConnectedDeviceInfo(
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
*/
internal fun fetchConnectedDeviceInfo(
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
host: String,
port: Int
): ConnectedDeviceInfo {
fun prop(name: String): String =
runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("")
): ConnectedDeviceInfo = runBlocking {
suspend fun prop(name: String): String = runCatching {
adbService.shell("getprop $name").trim()
}.getOrDefault("")
val model = prop("ro.product.model")
val serial = prop("ro.serialno")
val manufacturer = prop("ro.product.manufacturer")
val brand = prop("ro.product.brand")
val device = prop("ro.product.device")
val androidRelease = prop("ro.build.version.release")
val sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1
return ConnectedDeviceInfo(
model = model.ifBlank { "$host:$port" },
serial = serial,
manufacturer = manufacturer,
brand = brand,
device = device,
androidRelease = androidRelease,
sdkInt = sdkInt,
ConnectedDeviceInfo(
model = prop("ro.product.model").ifBlank { "$host:$port" },
serial = prop("ro.serialno"),
manufacturer = prop("ro.product.manufacturer"),
brand = prop("ro.product.brand"),
device = prop("ro.product.device"),
androidRelease = prop("ro.build.version.release"),
sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1,
)
}

View File

@@ -0,0 +1,70 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Global singleton for event logging.
*
* Manages event logs with timestamp formatting and automatic log rotation.
* Logs are stored in a thread-safe SnapshotStateList for Compose integration.
*/
object EventLogger {
private const val LOG_TAG = "EventLogger"
private val _eventLog: SnapshotStateList<String> = mutableStateListOf()
/**
* Read-only access to the event log list.
*/
val eventLog: List<String> get() = _eventLog
/**
* Log an event with timestamp and optional error.
*
* @param message The log message
* @param level Log level (Log.INFO, Log.ERROR, Log.WARN, Log.DEBUG)
* @param error Optional throwable for error logging
*/
fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
_eventLog.add(0, "[$timestamp] $message")
// Rotate logs if exceeds max size
if (_eventLog.size > AppDefaults.EVENT_LOG_LINES) {
_eventLog.removeRange(AppDefaults.EVENT_LOG_LINES, _eventLog.size)
}
// Log to Android logcat
when (level) {
Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error)
else Log.e(LOG_TAG, message)
Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error)
else Log.w(LOG_TAG, message)
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error)
else Log.d(LOG_TAG, message)
else -> if (error != null) Log.i(LOG_TAG, message, error)
else Log.i(LOG_TAG, message)
}
}
/**
* Clear all event logs.
*/
fun clearLogs() {
_eventLog.clear()
}
/**
* Check if there are any logs.
*/
fun hasLogs(): Boolean = _eventLog.isNotEmpty()
}

View File

@@ -63,13 +63,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -221,7 +222,7 @@ internal fun StatusCard(
internal fun PairingCard(
busy: Boolean,
autoDiscoverOnDialogOpen: Boolean,
onDiscoverTarget: (() -> Pair<String, Int>?)? = null,
onDiscoverTarget: (suspend () -> Pair<String, Int>?)? = null,
onPair: (host: String, port: String, code: String) -> Unit,
) {
val showPairDialog = remember { mutableStateOf(false) }
@@ -516,7 +517,7 @@ private fun PairingDialog(
showDialog: Boolean,
enabled: Boolean,
autoDiscoverOnDialogOpen: Boolean,
onDiscoverTarget: (() -> Pair<String, Int>?)?,
onDiscoverTarget: (suspend () -> Pair<String, Int>?)?,
onDismissRequest: () -> Unit,
onDismissFinished: () -> Unit,
onConfirm: (host: String, port: String, code: String) -> Unit,
@@ -661,6 +662,244 @@ internal fun LogsPanel(lines: List<String>) {
}
}
/**
* TouchEventHandler
*
* Purpose:
* - Handles touch event processing for fullscreen control screen
* - Manages pointer tracking, coordinate mapping, and touch injection
*/
class TouchEventHandler(
private val coroutineScope: CoroutineScope,
private val session: ScrcpySessionInfo,
private val touchAreaSize: IntSize,
private val activePointerIds: LinkedHashSet<Int>,
private val activePointerPositions: LinkedHashMap<Int, Offset>,
private val activePointerDevicePositions: LinkedHashMap<Int, Pair<Int, Int>>,
private val pointerLabels: LinkedHashMap<Int, Int>,
private var nextPointerLabel: Int,
private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
private val onActiveTouchCountChanged: (Int) -> Unit,
private val onActiveTouchDebugChanged: (String) -> Unit,
private val onNextPointerLabelChanged: (Int) -> Unit,
) {
private val eventPointerIds = HashSet<Int>(10)
private val eventPositions = HashMap<Int, Offset>(10)
private val eventPressures = HashMap<Int, Float>(10)
private val justPressedPointerIds = HashSet<Int>(10)
fun handleMotionEvent(event: MotionEvent): Boolean {
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
return true
}
val bounds = calculateContentBounds()
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return handleCancelAction(bounds)
}
extractEventData(event)
handleDisappearedPointers(eventPointerIds, bounds)
val endedPointerId = getEndedPointerId(event)
handlePointerDown(event, endedPointerId, bounds)
handlePointerMove(event, endedPointerId, bounds)
handlePointerUp(endedPointerId, bounds)
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private data class ContentBounds(
val width: Float,
val height: Float,
val left: Float,
val top: Float,
)
private fun calculateContentBounds(): ContentBounds {
val sessionAspect = if (session.height == 0) {
16f / 9f
} else {
session.width.toFloat() / session.height.toFloat()
}
val containerWidth = touchAreaSize.width.toFloat()
val containerHeight = touchAreaSize.height.toFloat()
val containerAspect = containerWidth / containerHeight
val contentWidth: Float
val contentHeight: Float
if (sessionAspect > containerAspect) {
contentWidth = containerWidth
contentHeight = containerWidth / sessionAspect
} else {
contentHeight = containerHeight
contentWidth = containerHeight * sessionAspect
}
val contentLeft = (containerWidth - contentWidth) / 2f
val contentTop = (containerHeight - contentHeight) / 2f
return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop)
}
private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean {
return rawX in bounds.left..(bounds.left + bounds.width) &&
rawY in bounds.top..(bounds.top + bounds.height)
}
private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair<Int, Int> {
val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f)
val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f)
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
return x to y
}
private fun getPointerLabel(pointerId: Int): Int {
val existing = pointerLabels[pointerId]
if (existing != null) {
return existing
}
val assigned = nextPointerLabel
nextPointerLabel += 1
onNextPointerLabelChanged(nextPointerLabel)
pointerLabels[pointerId] = assigned
return assigned
}
private fun refreshTouchDebug() {
if (activePointerIds.isEmpty()) {
onActiveTouchDebugChanged("")
return
}
val debug = activePointerIds
.sortedBy { getPointerLabel(it) }
.joinToString(separator = "\n") { pointerId ->
val label = getPointerLabel(pointerId)
val pos = activePointerDevicePositions[pointerId]
if (pos == null) {
"#$label(id=$pointerId):?"
} else {
"#$label(id=$pointerId):${pos.first},${pos.second}"
}
}
onActiveTouchDebugChanged(debug)
}
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
if (!activePointerIds.contains(pointerId)) return
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch {
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
}
activePointerIds -= pointerId
activePointerPositions.remove(pointerId)
activePointerDevicePositions.remove(pointerId)
pointerLabels.remove(pointerId)
}
private fun handleCancelAction(bounds: ContentBounds): Boolean {
val toCancel = activePointerIds.toList()
for (pointerId in toCancel) {
releasePointer(pointerId, bounds)
}
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private fun extractEventData(event: MotionEvent) {
eventPointerIds.clear()
eventPositions.clear()
eventPressures.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
eventPointerIds += pointerId
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
}
}
private fun handleDisappearedPointers(eventPointerIds: Set<Int>, bounds: ContentBounds) {
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
for (pointerId in disappearedPointers) {
releasePointer(pointerId, bounds)
}
}
private fun getEndedPointerId(event: MotionEvent): Int? {
return when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
else -> null
}
}
private fun handlePointerDown(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
justPressedPointerIds.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (pointerId == endedPointerId) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
if (!activePointerIds.contains(pointerId)) {
if (!isInsideContent(raw.x, raw.y, bounds)) continue
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerIds += pointerId
activePointerPositions[pointerId] = raw
activePointerDevicePositions[pointerId] = x to y
justPressedPointerIds += pointerId
coroutineScope.launch {
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
}
}
}
}
private fun handlePointerMove(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId)) continue
if (pointerId == endedPointerId) continue
if (pointerId in justPressedPointerIds) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
activePointerPositions[pointerId] = raw
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch {
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
}
}
}
private fun handlePointerUp(
endedPointerId: Int?,
bounds: ContentBounds,
) {
if (endedPointerId != null) {
val endPos = eventPositions[endedPointerId]
if (endPos != null) {
activePointerPositions[endedPointerId] = endPos
}
releasePointer(endedPointerId, bounds)
}
}
}
/**
* FullscreenControlScreen
*
@@ -685,8 +924,10 @@ fun FullscreenControlScreen(
showDebugInfo: Boolean,
currentFps: Float,
enableBackHandler: Boolean = true,
onInjectTouch: (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
val coroutineScope = rememberCoroutineScope()
var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
val activePointerIds = remember { linkedSetOf<Int>() }
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
@@ -695,173 +936,30 @@ fun FullscreenControlScreen(
var nextPointerLabel by remember { mutableIntStateOf(1) }
var activeTouchCount by remember { mutableIntStateOf(0) }
var activeTouchDebug by remember { mutableStateOf("") }
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
val touchEventHandler = remember(session, touchAreaSize) {
TouchEventHandler(
coroutineScope = coroutineScope,
session = session,
touchAreaSize = touchAreaSize,
activePointerIds = activePointerIds,
activePointerPositions = activePointerPositions,
activePointerDevicePositions = activePointerDevicePositions,
pointerLabels = pointerLabels,
nextPointerLabel = nextPointerLabel,
onInjectTouch = onInjectTouch,
onActiveTouchCountChanged = { activeTouchCount = it },
onActiveTouchDebugChanged = { activeTouchDebug = it },
onNextPointerLabelChanged = { nextPointerLabel = it },
)
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInteropFilter { event ->
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
return@pointerInteropFilter true
}
val sessionAspect = if (session.height == 0) {
16f / 9f
} else {
session.width.toFloat() / session.height.toFloat()
}
val containerWidth = touchAreaSize.width.toFloat()
val containerHeight = touchAreaSize.height.toFloat()
val containerAspect = containerWidth / containerHeight
val contentWidth: Float
val contentHeight: Float
if (sessionAspect > containerAspect) {
contentWidth = containerWidth
contentHeight = containerWidth / sessionAspect
} else {
contentHeight = containerHeight
contentWidth = containerHeight * sessionAspect
}
val contentLeft = (containerWidth - contentWidth) / 2f
val contentTop = (containerHeight - contentHeight) / 2f
fun isInsideContent(rawX: Float, rawY: Float): Boolean {
return rawX in contentLeft..(contentLeft + contentWidth) &&
rawY in contentTop..(contentTop + contentHeight)
}
fun mapToDevice(rawX: Float, rawY: Float): Pair<Int, Int> {
val normalizedX = ((rawX - contentLeft) / contentWidth).coerceIn(0f, 1f)
val normalizedY = ((rawY - contentTop) / contentHeight).coerceIn(0f, 1f)
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
return x to y
}
fun pointerLabel(pointerId: Int): Int {
val existing = pointerLabels[pointerId]
if (existing != null) {
return existing
}
val assigned = nextPointerLabel
nextPointerLabel += 1
pointerLabels[pointerId] = assigned
return assigned
}
fun refreshTouchDebug() {
if (activePointerIds.isEmpty()) {
activeTouchDebug = ""
return
}
activeTouchDebug = activePointerIds
.sortedBy { pointerLabel(it) }
.joinToString(separator = "\n") { pointerId ->
val label = pointerLabel(pointerId)
val pos = activePointerDevicePositions[pointerId]
if (pos == null) {
"#$label(id=$pointerId):?"
} else {
"#$label(id=$pointerId):${pos.first},${pos.second}"
}
}
}
fun releasePointer(pointerId: Int, reason: String) {
if (!activePointerIds.contains(pointerId)) return
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y)
// val label = pointerLabel(pointerId)
// Log.d(
// FULLSCREEN_TOUCH_LOG_TAG,
// "抬起($reason): pointer#$label(id=$pointerId) x=$x y=$y"
// )
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
activePointerIds -= pointerId
activePointerPositions.remove(pointerId)
activePointerDevicePositions.remove(pointerId)
pointerLabels.remove(pointerId)
}
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
val toCancel = activePointerIds.toList()
for (pointerId in toCancel) {
releasePointer(pointerId, reason = "cancel")
}
activeTouchCount = activePointerIds.size
refreshTouchDebug()
return@pointerInteropFilter true
}
val eventPointerIds = HashSet<Int>(event.pointerCount)
val eventPositions = HashMap<Int, Offset>(event.pointerCount)
val eventPressures = HashMap<Int, Float>(event.pointerCount)
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
eventPointerIds += pointerId
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
}
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
for (pointerId in disappearedPointers) {
releasePointer(pointerId, reason = "missing")
}
val endedPointerId = when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
else -> null
}
val justPressed = HashSet<Int>()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (pointerId == endedPointerId) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
if (!activePointerIds.contains(pointerId)) {
if (!isInsideContent(raw.x, raw.y)) continue
val (x, y) = mapToDevice(raw.x, raw.y)
// val label = pointerLabel(pointerId)
// Log.d(
// FULLSCREEN_TOUCH_LOG_TAG,
// "按下: pointer#$label(id=$pointerId) x=$x y=$y"
// )
activePointerIds += pointerId
activePointerPositions[pointerId] = raw
activePointerDevicePositions[pointerId] = x to y
justPressed += pointerId
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
}
}
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId)) continue
if (pointerId == endedPointerId) continue
if (pointerId in justPressed) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
activePointerPositions[pointerId] = raw
val (x, y) = mapToDevice(raw.x, raw.y)
activePointerDevicePositions[pointerId] = x to y
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
}
if (endedPointerId != null) {
val endPos = eventPositions[endedPointerId]
if (endPos != null) {
activePointerPositions[endedPointerId] = endPos
}
releasePointer(endedPointerId, reason = "event")
}
activeTouchCount = activePointerIds.size
refreshTouchDebug()
true
touchEventHandler.handleMotionEvent(event)
}
.onSizeChanged { touchAreaSize = it },
) {
@@ -958,6 +1056,7 @@ private fun ScrcpyVideoSurface(
) {
val surfaceTag = "video-main"
var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(session, currentSurface) {
if (session != null && currentSurface != null) {
@@ -970,7 +1069,9 @@ private fun ScrcpyVideoSurface(
onDispose {
val released = currentSurface
if (released != null) {
nativeCore.unregisterVideoSurface(surfaceTag, released)
scope.launch {
nativeCore.unregisterVideoSurface(surfaceTag, released)
}
released.release()
currentSurface = null
}
@@ -1003,7 +1104,9 @@ private fun ScrcpyVideoSurface(
val released = currentSurface
currentSurface = null
if (released != null) {
nativeCore.unregisterVideoSurface(surfaceTag, released)
scope.launch {
nativeCore.unregisterVideoSurface(surfaceTag, released)
}
released.release()
}
return true

View File

@@ -37,7 +37,7 @@ class ReorderableList(
private val showCheckbox: Boolean = false,
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
) {
enum class Orientation { Column, Row }
enum class Orientation { Column, Row; }
data class Item(
val id: String,

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -34,6 +35,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Icon
@@ -118,7 +120,7 @@ enum class VirtualButtonAction(
"截图",
Icons.Rounded.Screenshot,
UiAndroidKeycodes.SYSRQ
),
);
}
data class VirtualButtonItem(
@@ -230,9 +232,10 @@ class VirtualButtonBar(
@Composable
fun Fullscreen(
onAction: (VirtualButtonAction) -> Unit,
onAction: suspend (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val haptics = rememberAppHaptics()
var showMorePopup by remember { mutableStateOf(false) }
@@ -248,7 +251,9 @@ class VirtualButtonBar(
if (action == VirtualButtonAction.MORE) {
showMorePopup = true
} else {
onAction(action)
scope.launch {
onAction(action)
}
}
},
modifier = Modifier.fillMaxWidth(),
@@ -284,9 +289,10 @@ class VirtualButtonBar(
show: Boolean,
moreActions: List<VirtualButtonAction>,
onDismiss: () -> Unit,
onAction: (VirtualButtonAction) -> Unit,
onAction: suspend (VirtualButtonAction) -> Unit,
renderInRootScaffold: Boolean,
) {
val scope = rememberCoroutineScope()
val haptics = rememberAppHaptics()
val spinnerItems = remember(moreActions) {
moreActions.map { action ->
@@ -323,7 +329,9 @@ class VirtualButtonBar(
dialogMode = false,
onSelectedIndexChange = { selectedIdx ->
haptics.confirm()
onAction(moreActions[selectedIdx])
scope.launch {
onAction(moreActions[selectedIdx])
}
},
)
}