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

2
.gitignore vendored
View File

@@ -32,3 +32,5 @@ google-services.json
# Android Profiling
*.hprof
.kiro/

View File

@@ -31,6 +31,7 @@
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
- I18N
- More: [TODO.md](TODO.md)
## 建议搭配模块

38
TODO.md Normal file
View File

@@ -0,0 +1,38 @@
# TODO
## CURRENT
- Refactoring
## BUGS
- Figure out is virtual display destroyed at the end of scrcpy session
## WIDGETS
- `SuperTextField` Click to pop a dialog with custom notes/summary
## PARAMS
- orientation locking
- `-r, --record=file.mp4` 投屏时录制到文件 Record screen to file. The format is determined by the --record-format option if set, or by the file extension.
- `--record-format` 录制格式 Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav).
- `-t, --show-touches` 显示受控机的物理触控 Enable "show touches" on start, restore the initial value on exit. It only shows physical touches (not clicks from scrcpy).
- `--no-power-on` 开始投屏时不唤醒屏幕 Do not power on the device on start.
- `--power-off-on-close` 结束投屏时息屏 Turn the device screen off when closing scrcpy.
- `--disable-screensaver` 投屏时禁用自动息屏 Disable screensaver while scrcpy is running.
- `--screen-off-timeout=seconds` 投屏过程中的息屏时间 Set the screen off timeout while scrcpy is running (restore the initial value on exit).
- `--require-audio` This option makes scrcpy fail if audio is enabled but does not work.
- `--no-vd-destroy-content` 投屏结束关闭虚拟显示器时不结束进程 Disable virtual display "destroy content on removal" flag. With this option, when the virtual display is closed, the running apps are moved to the main display rather than being destroyed.
- `--no-vd-system-decorations` Disable virtual display system decorations flag.
## FEATURES
- 设置项连接设备后马上启用scrcpy会话
### LOWER PRIORITY
顺序无关
- 横屏布局
- 原生悬浮窗

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])
}
},
)
}