diff --git a/.gitignore b/.gitignore index e5cbb64..a77f58c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ google-services.json # Android Profiling *.hprof + +.kiro/ diff --git a/README.md b/README.md index 029ba02..d2d0faf 100644 --- a/README.md +++ b/README.md @@ -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) ## 建议搭配模块 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6847934 --- /dev/null +++ b/TODO.md @@ -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 + +顺序无关 + +- 横屏布局 +- 原生悬浮窗 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index f69196a..8842f36 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -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() private val surfaceIdentityMap = ConcurrentHashMap() private val decoderMap = ConcurrentHashMap() @@ -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? { - 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? { - 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 ioCall(task: () -> T): T { - return try { - executor.submit { 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, - val audioEncoders: List, - val videoEncoderTypes: Map = emptyMap(), - val audioEncoderTypes: Map = emptyMap(), - val rawOutput: String = "", -) - -data class ScrcpyCameraSizeLists( - val sizes: List, - val rawOutput: String = "", -) - -data class ScrcpySessionInfo( - val width: Int, - val height: Int, - val deviceName: String, - val codec: String, - val controlEnabled: Boolean, -) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index ed0806c..e19972c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt index c3690d1..4cd8c9f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt @@ -143,7 +143,7 @@ internal class DirectAdbPairingClient( READY, EXCHANGING_MSGS, EXCHANGING_PEER_INFO, - STOPPED, + STOPPED; } private lateinit var socket: Socket diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt index b8270e8..a3b9510 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -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? { - return try { + ): Pair? = 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? { - return try { + ): Pair? = 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") diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt index 62930c6..b23287d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt @@ -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(), - // 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( - 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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index b343bb6..6e2ed6d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -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, String>( + listSaver, String>( save = { list -> list.map { item -> listOf( @@ -109,7 +116,7 @@ private val DeviceShortcutStateListSaver = ) private val StringStateListSaver = - listSaver, String>( + listSaver, 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() } - 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] 里 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt index b0b301b..2c65564 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt @@ -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) + } }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index 3b3fd80..75b2b10 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt new file mode 100644 index 0000000..9e841ac --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -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=[x][/] + 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt new file mode 100644 index 0000000..b12d514 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -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, + val audioEncoders: List, + val videoEncoderTypes: Map = emptyMap(), + val audioEncoderTypes: Map = emptyMap(), + val rawOutput: String = "", + ) : ListResult() + + data class CameraSizes( + val sizes: List, + 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() + val audio = LinkedHashSet() + val videoTypes = linkedMapOf() + val audioTypes = linkedMapOf() + + 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() + 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, + val audioEncoders: List, + val videoEncoderTypes: Map, + val audioEncoderTypes: Map, + ) + + private data class ParsedCameraSizes( + val sizes: List, + ) + + 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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ScrcpyUsageExample.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ScrcpyUsageExample.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt new file mode 100644 index 0000000..0402b79 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -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 { + val cmd = mutableListOf() + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt new file mode 100644 index 0000000..010a58f --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -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"); + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt index 28efac5..610deff 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt @@ -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, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt new file mode 100644 index 0000000..5f42a8c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt @@ -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 = mutableStateListOf() + + /** + * Read-only access to the event log list. + */ + val eventLog: List 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() +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 0a28d54..3996703 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -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?)? = null, + onDiscoverTarget: (suspend () -> Pair?)? = 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?)?, + onDiscoverTarget: (suspend () -> Pair?)?, onDismissRequest: () -> Unit, onDismissFinished: () -> Unit, onConfirm: (host: String, port: String, code: String) -> Unit, @@ -661,6 +662,244 @@ internal fun LogsPanel(lines: List) { } } +/** + * 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, + private val activePointerPositions: LinkedHashMap, + private val activePointerDevicePositions: LinkedHashMap>, + private val pointerLabels: LinkedHashMap, + 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(10) + private val eventPositions = HashMap(10) + private val eventPressures = HashMap(10) + private val justPressedPointerIds = HashSet(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 { + 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, 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() } val activePointerPositions = remember { linkedMapOf() } @@ -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 { - 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(event.pointerCount) - val eventPositions = HashMap(event.pointerCount) - val eventPressures = HashMap(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() - 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(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 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt index 6e0c1b8..86c0fb6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index 9756d19..dcea852 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -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, 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]) + } }, ) }