diff --git a/README.md b/README.md index 4502fd4..0c56b31 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ - 组件排序动画一坨 - 退出全屏时的横竖屏状态可能不对,断开 scrcpy 重连就好 - 如果受控机在锁屏时处理网络连接较慢,会导致应用界面无响应过长时间被系统杀掉(点名你米) +- 虚拟按键的截图实现方式为发送 `keycode 120`,安卓官方的定义为 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 +- I18N ## 建议搭配模块 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 9ba0d51..f69196a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -20,6 +20,14 @@ import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.ExecutionException import java.util.concurrent.Executors +/** + * Facade that centralizes ADB and scrcpy native 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. + */ class NativeCoreFacade(private val appContext: Context) { private val adbService = NativeAdbService(appContext) @@ -51,6 +59,16 @@ class NativeCoreFacade(private val appContext: Context) { executor.shutdown() } + /** + * Register a rendering Surface for a given `tag`. + * + * - If the surface is already known and the decoder is active, this is a no-op. + * - If a decoder exists but cannot switch output surface, a new decoder is created + * and bound to the supplied surface. + * - This method must be called from the UI thread (it is called by the composable + * 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] @@ -73,6 +91,14 @@ class NativeCoreFacade(private val appContext: Context) { createOrReplaceDecoder(tag, surface, session) } + + /** + * Unregister the rendering Surface previously bound to `tag`. + * + * - If a stale surface reference is supplied (identity mismatch), the request is ignored. + * - 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) } @@ -91,10 +117,18 @@ class NativeCoreFacade(private val appContext: Context) { } } + /** + * 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, @@ -102,6 +136,9 @@ class NativeCoreFacade(private val appContext: Context) { return ioCall { adbService.discoverPairingService(timeoutMs, includeLanDevices) } } + /** + * Discover an ADB connect service for direct connection (mDNS). + */ fun adbDiscoverConnectService( timeoutMs: Long = 12_000, includeLanDevices: Boolean = true, @@ -109,21 +146,47 @@ class NativeCoreFacade(private val appContext: Context) { 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}") @@ -220,6 +283,13 @@ class NativeCoreFacade(private val appContext: Context) { } } + /** + * 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() @@ -517,6 +587,15 @@ class NativeCoreFacade(private val appContext: Context) { } } + /** + * Create or replace a decoder bound to `surface` for `session`. + * + * - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder]. + * - The decoder's `onOutputSizeChanged` callback publishes size changes to + * registered listeners on the main thread. + * - Newly created decoders are fed with any cached bootstrap packets to allow + * faster playback startup. + */ private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) { decoderMap.remove(tag)?.release() val mime = when (session.codec.lowercase()) { @@ -604,6 +683,15 @@ class NativeCoreFacade(private val appContext: Context) { } } + /** + * Attach a single consumer to the session manager to deliver incoming video packets + * to all active decoders. + * + * - Called when a session is active and at least one decoder exists. Packets are + * cached into [bootstrapPackets] to allow late-attaching decoders to catch up. + * - 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 -> cacheBootstrapPacket(packet) @@ -630,6 +718,7 @@ class NativeCoreFacade(private val appContext: Context) { } } + private fun releaseAllDecoders() { decoderMap.values.forEach { decoder -> runCatching { decoder.release() } @@ -637,6 +726,12 @@ 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() @@ -649,6 +744,12 @@ class NativeCoreFacade(private val appContext: Context) { } } + /** + * 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) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt index 67443f4..944d3e7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt @@ -78,4 +78,4 @@ object AppPreferenceKeys { const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = "adb_pairing_auto_discover_on_dialog_open" const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device" const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery" -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt index b3f2653..3dc7e40 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt @@ -1,5 +1,6 @@ package io.github.miuzarte.scrcpyforandroid.constants +// https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h internal object UiAndroidKeycodes { const val HOME = 3 const val BACK = 4 @@ -9,6 +10,7 @@ internal object UiAndroidKeycodes { const val VOLUME_MUTE = 164 const val MENU = 82 const val NOTIFICATION = 83 - const val SYSRQ = 120 + const val SYSRQ = 120 // System Request / Print Screen const val APP_SWITCH = 187 } +// TODO: customizable virtual keys with any keycode diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt index d9ec651..58f5973 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt @@ -3,4 +3,4 @@ package io.github.miuzarte.scrcpyforandroid.constants object UiMotion { const val PAGE_SWITCH_DAMPING_RATIO = 0.9f const val PAGE_SWITCH_STIFFNESS = 700f -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt index 4337411..c7ee1de 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt @@ -20,4 +20,4 @@ object UiSpacing { val CardTitle = 16.dp val BottomContent = 64.dp val BottomSheetBottom = 16.dp -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 6d0fdd1..18b099a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -5,6 +5,10 @@ internal data class ConnectionTarget( val port: Int, ) +/** + * A compact shortcut entry for quick-connect lists shown in the UI. + * `online` indicates whether the device was reachable when last probed. + */ internal data class DeviceShortcut( val id: String, val name: String, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt index c9d8baa..6681095 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt @@ -17,18 +17,30 @@ import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference @RequiresApi(Build.VERSION_CODES.R) +/** + * Performs mDNS discovery for ADB TLS pairing/connect services on the local network. + * + * Uses Android's `NsdManager` to resolve services and returns a host:port pair + * when a suitable service is found within the provided timeout. + */ internal class AdbMdnsDiscoverer(context: Context) { private val nsdManager = context.getSystemService(NsdManager::class.java) - fun discoverPairingService(timeoutMs: Long, includeLanDevices: Boolean): Pair? { - return discoverService(TLS_PAIRING, timeoutMs, includeLanDevices) - } - + /** + * Discover a device that advertises the ADB connect service via mDNS. + */ fun discoverConnectService(timeoutMs: Long, includeLanDevices: Boolean): Pair? { return discoverService(TLS_CONNECT, timeoutMs, includeLanDevices) } + /** + * Discover a device that advertises the ADB pairing service via mDNS. + */ + fun discoverPairingService(timeoutMs: Long, includeLanDevices: Boolean): Pair? { + return discoverService(TLS_PAIRING, timeoutMs, includeLanDevices) + } + private fun discoverService( serviceType: String, timeoutMs: Long, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt index 8f0e6e6..f577722 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt @@ -26,6 +26,10 @@ import javax.net.ssl.SSLEngine import javax.net.ssl.X509ExtendedKeyManager import javax.net.ssl.X509ExtendedTrustManager +/** + * Helper that wraps a generated/private RSA key and exposes ADB-compatible + * public key bytes and an `SSLContext` configured for the pairing handshake. + */ internal class AdbPairingKey( private val privateKey: PrivateKey, private val alias: String, @@ -165,6 +169,10 @@ private const val ANDROID_PUBKEY_MODULUS_SIZE = 2048 / 8 // 256 private const val ANDROID_PUBKEY_MODULUS_SIZE_WORDS = ANDROID_PUBKEY_MODULUS_SIZE / 4 // 64 private const val RSA_PUBLIC_KEY_SIZE = 524 +/** + * Convert a BigInteger modulus into the little-endian int-array encoding + * expected by the ADB public key format. + */ private fun BigInteger.toAdbEncoded(): IntArray { val encoded = IntArray(ANDROID_PUBKEY_MODULUS_SIZE_WORDS) val r32 = BigInteger.ZERO.setBit(32) @@ -177,6 +185,10 @@ private fun BigInteger.toAdbEncoded(): IntArray { return encoded } +/** + * Encode an RSA public key into the ADB public-key blob format with a UTF-8 + * name suffix. + */ private fun RSAPublicKey.adbEncoded(name: String): ByteArray { val r32 = BigInteger.ZERO.setBit(32) val n0inv = modulus.remainder(r32).modInverse(r32).negate() @@ -198,4 +210,7 @@ private fun RSAPublicKey.adbEncoded(name: String): ByteArray { } } +/** + * Thrown when the supplied pairing code is invalid during the pairing flow. + */ internal class AdbInvalidPairingCodeException : Exception() diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt index 6bb143f..7ba5b7e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt @@ -5,6 +5,19 @@ import android.media.MediaFormat import android.util.Log import android.view.Surface +/** + * AnnexBDecoder + * + * Purpose: + * - Wraps Android MediaCodec for Annex-B framed codecs (H.264/H.265/AV1). + * - Handles critical startup packets (config/keyframes) and provides callbacks + * for output size changes and FPS updates. + * + * Threading / safety: + * - Public methods are synchronized to allow calls from multiple threads + * (packet producer vs. teardown). Internally, MediaCodec callbacks and + * buffer queues are used on the calling thread. + */ class AnnexBDecoder( width: Int, height: Int, @@ -40,6 +53,16 @@ class AnnexBDecoder( codec.start() } + /** + * Feed an Annex-B framed packet into the decoder. + * + * - `isConfig` indicates codec configuration packets (SPS/PPS) and are handled with + * higher priority: they can clear bootstrap buffering and are retried for input. + * - `isKeyFrame` is used to mark access units that allow decoder restarts. + * - The method attempts to dequeue an input buffer and queue the data; if no + * input buffer is available for critical packets, it drains output and retries + * to reduce startup stalls. + */ @Synchronized fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) { if (released) { @@ -89,6 +112,12 @@ class AnnexBDecoder( } } + /** + * Switch the codec's output surface. + * + * - Returns true when the codec accepted the new surface, false otherwise. + * - Safe to call during playback to move rendering between preview/fullscreen surfaces. + */ @Synchronized fun switchOutputSurface(surface: Surface): Boolean { if (released) { @@ -100,6 +129,12 @@ class AnnexBDecoder( }.getOrElse { false } } + /** + * Release the codec resources. + * + * - Stops decoding, releases the MediaCodec instance, and marks this decoder as released + * so subsequent calls are no-ops. + */ @Synchronized fun release() { released = true 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 ed0deff..ed0806c 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 @@ -36,6 +36,13 @@ import java.util.concurrent.atomic.AtomicInteger import javax.net.ssl.SSLSocket import kotlin.concurrent.thread +/** + * Low-level transport helper that manages local RSA keys and creates + * `DirectAdbConnection` instances for direct TCP/TLS ADB connections. + * + * This type is responsible for persisting the private key and performing + * pairing/connect discovery helpers. + */ internal class DirectAdbTransport(private val context: Context) { private val keys: Pair by lazy { loadOrCreate() } @@ -94,6 +101,10 @@ internal class DirectAdbTransport(private val context: Context) { return AdbMdnsDiscoverer(context).discoverConnectService(timeoutMs, includeLanDevices) } + /** + * Load persisted RSA keypair from shared preferences, or generate a new one. + * Returns (privateKey, publicX509Bytes). + */ private fun loadOrCreate(): Pair { val prefs = context.getSharedPreferences( AppPreferenceKeys.NATIVE_ADB_KEY_PREFS_NAME, @@ -181,6 +192,12 @@ internal class DirectAdbTransport(private val context: Context) { } } +/** + * Represents a single direct ADB connection over TCP (optionally upgraded to TLS). + * + * Exposes framed ADB streams via `openStream` and handles the protocol handshake, + * reader thread and stream routing. + */ internal class DirectAdbConnection( val host: String, val port: Int, @@ -222,6 +239,14 @@ internal class DirectAdbConnection( private const val MAX_PAYLOAD = 256 * 1024 } + /** + * Perform the ADB protocol handshake over TCP. + * + * - Establishes TCP, negotiates optional TLS (STLS), and performs the ADB + * authentication exchange (TOKEN -> SIGNATURE or PUBKEY flow). + * - After a successful handshake, starts a reader thread to process incoming + * ADB frames and dispatch them to logical streams. + */ fun handshake() { Log.i(TAG, "handshake(): tcp connect -> $host:$port") socket.connect(InetSocketAddress(host, port), 10_000) @@ -290,6 +315,12 @@ internal class DirectAdbConnection( rawOut = sslSocket.outputStream } + /** + * Open a logical ADB stream for `service` and return an [AdbSocketStream]. + * + * - Sends an A_OPEN message and waits for remote acknowledgment. The returned + * stream exposes blocking InputStream/OutputStream wrappers usable by callers. + */ fun openStream(service: String): AdbSocketStream { val localId = nextLocalId.getAndIncrement() val stream = AdbSocketStream(localId) { cmd, a0, a1, d -> sendMsg(cmd, a0, a1, d) } @@ -307,6 +338,11 @@ internal class DirectAdbConnection( fun shell(command: String): String = openStream("shell:$command").use { it.inputStream.readBytes().toString(Charsets.UTF_8) } + /** + * Push raw bytes to a remote path using the minimal ADB "sync" protocol. + * + * - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure. + */ fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) { openStream("sync:").use { stream -> val out = stream.outputStream @@ -385,6 +421,11 @@ internal class DirectAdbConnection( } } + /** + * Send a framed ADB message (header + optional payload). + * + * - Header fields are little-endian as required by the ADB protocol. + */ @Synchronized private fun sendMsg( command: Int, @@ -402,6 +443,11 @@ internal class DirectAdbConnection( rawOut.flush() } + /** + * Receive and parse a single ADB framed message from the socket. + * + * - Blocks until the full 24-byte header is read and then reads the payload. + */ private fun recvMsg(): AdbMsg { val h = ByteArray(24) rawIn.readExact(h) @@ -498,6 +544,10 @@ 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( val localId: Int, private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit, 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 9b4885d..c3690d1 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 @@ -24,6 +24,9 @@ private const val EXPORTED_KEY_LABEL = "adb-label\u0000" private const val EXPORTED_KEY_SIZE = 64 private const val PAIRING_PACKET_HEADER_SIZE = 6 +/** + * PeerInfo container used to pack peer metadata for the pairing exchange. + */ private class PeerInfo(val type: Byte, rawData: ByteArray) { val data = ByteArray(MAX_PEER_INFO_SIZE - 1) @@ -48,6 +51,9 @@ private class PeerInfo(val type: Byte, rawData: ByteArray) { } } +/** + * Small header framing structure used by the ADB pairing packet format. + */ private class PairingPacketHeader(val version: Byte, val type: Byte, val payload: Int) { object Type { const val SPAKE2_MSG: Byte = 0 @@ -83,6 +89,10 @@ private class PairingPacketHeader(val version: Byte, val type: Byte, val payload } } +/** + * Wrapper around native SPAKE2 pairing context used to perform the cryptographic + * exchanges. The native implementation is provided by the `adbpairing` library. + */ private class PairingContext private constructor(private val nativePtr: Long) { val msg: ByteArray = nativeMsg(nativePtr) @@ -114,6 +124,13 @@ private class PairingContext private constructor(private val nativePtr: Long) { } } +/** + * Client-side implementation of the ADB pairing protocol. + * + * Connects to the device's pairing port, performs the TLS handshake, derives + * keying material using the user-supplied pairing code, and exchanges peer + * information to complete pairing. + */ @RequiresApi(Build.VERSION_CODES.R) internal class DirectAdbPairingClient( private val host: String, 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 0c31c7a..b8270e8 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 @@ -4,6 +4,13 @@ import android.content.Context import android.util.Log import java.nio.file.Path +/** + * Higher-level ADB service that wraps `DirectAdbTransport` and provides + * synchronized 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. + */ class NativeAdbService(appContext: Context) { private val transport = DirectAdbTransport(appContext) @@ -65,6 +72,11 @@ class NativeAdbService(appContext: Context) { } } + /** + * Connect to a remote ADB endpoint. If an existing connection points to the + * 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 { Log.i(TAG, "connect(): host=$host port=$port") @@ -86,6 +98,9 @@ class NativeAdbService(appContext: Context) { } } + /** + * Close the current ADB connection immediately. + */ @Synchronized fun disconnect() { runCatching { connection?.close() } @@ -97,6 +112,9 @@ class NativeAdbService(appContext: Context) { @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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt index 799a062..e5d07fa 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt @@ -1,5 +1,7 @@ package io.github.miuzarte.scrcpyforandroid.nativecore +// Go reader note: Audio output helper for scrcpy stream: decodes/plays PCM or codec audio frames. + import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioManager @@ -118,6 +120,12 @@ class ScrcpyAudioPlayer(private val codecId: Int) { }.onFailure { Log.w(TAG, "prepareFlac failed", it) } } + /** + * Initialize MediaCodec decoder and an AudioTrack for playback. + * + * - Configures codec with provided format and starts an AudioTrack in streaming mode. + * - Called once when a config packet is received for codec formats. + */ private fun startCodecAndTrack(format: MediaFormat) { val mime = format.getString(MediaFormat.KEY_MIME)!! val codec = MediaCodec.createDecoderByType(mime) @@ -162,6 +170,11 @@ class ScrcpyAudioPlayer(private val codecId: Int) { ) } + /** + * Drain decoder output and write PCM frames to the AudioTrack. + * + * - Non-blocking writes are used so audio does not stall the decoder thread. + */ private fun drainOutput(codec: MediaCodec) { val track = audioTrack ?: return var idx = codec.dequeueOutputBuffer(bufferInfo, 0L) @@ -179,6 +192,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) { } } + /** + * Release media and audio resources. Safe to call from any thread. + */ fun release() { if (released) return released = true 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 deb2af6..62930c6 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 @@ -34,6 +34,21 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { private var lastServerCommand: String? = null private val serverLogBuffer = ArrayDeque() + /** + * Start a scrcpy session. + * + * Responsibilities: + * - Pushes the server artifact to the device, constructs the server command, + * and opens the server shell stream. + * - Opens the required abstract sockets (video/audio/control) with retries and + * reads initial session metadata (device name, codec, resolution). + * - Initializes an [ActiveSession] which holds socket streams and reader threads. + * + * Threading notes: + * - This is synchronized 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. + */ @Synchronized fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo { stop() @@ -152,6 +167,14 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { } } + /** + * Attach a video consumer callback. + * + * - Spawns a dedicated `scrcpy-video-reader` thread that reads framed Annex B + * packets from the video socket and delivers `VideoPacket` instances to [consumer]. + * - 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) { val session = activeSession ?: throw IllegalStateException("scrcpy session not started") @@ -206,6 +229,14 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { videoConsumer = null } + /** + * Attach an audio consumer callback. + * + * - Similar to the video consumer, this starts a `scrcpy-audio-reader` thread + * which reads audio packets and dispatches `AudioPacket` to the provided callback. + * - The function reads the audio stream header to determine whether audio is + * available and exits early if disabled. + */ @Synchronized fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) { val session = activeSession ?: throw IllegalStateException("scrcpy session not started") @@ -281,6 +312,12 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { audioConsumer = null } + /** + * Inject a keycode event to the control channel. + * + * - Requires an active control channel; throws if absent. + * - Synchronized to serialize control writes. + */ @Synchronized fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) { requireControlWriter().injectKeycode(action, keycode, repeat, metaState) @@ -291,6 +328,13 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { requireControlWriter().injectText(text) } + /** + * Inject a touch event to the control channel. + * + * - 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. + */ @Synchronized fun injectTouch( action: Int, @@ -347,6 +391,12 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { requireControlWriter().setDisplayPower(on) } + /** + * Stop the active session and clean up reader threads and streams. + * + * - Interrupts and joins reader threads with short timeouts, closes sockets, + * and clears state. It is safe to call from any thread. + */ @Synchronized fun stop() { val session = activeSession ?: return @@ -815,6 +865,12 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { return serverLogBuffer.toList().takeLast(take).joinToString("\n") } + /** + * Open an abstract adb socket with retry. + * + * - 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( socketName: String, expectDummyByte: Boolean 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 c993de2..6bcf2ea 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 @@ -229,6 +229,9 @@ fun DeviceTabScreen( }.asCoroutineDispatcher() } + // Run adb operations on a dedicated single thread. + // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. + DisposableEffect(adbWorkerDispatcher) { onDispose { adbWorkerDispatcher.close() @@ -324,12 +327,35 @@ fun DeviceTabScreen( } } + /** + * Disconnect the current ADB connection and stop any running scrcpy session. + * + * Concurrency / thread boundary: + * - Native calls that may block are executed on [adbWorkerDispatcher] using [withContext]. + * - This ensures UI coroutines are never blocked by synchronous native I/O. + * + * Side effects: + * - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort). + * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`, + * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. + * - Updates the saved quick-device list via [upsertQuickDevice] when a target is provided. + * - Logs an optional [logMessage] to the local event log. + * - Shows an optional snackbar message asynchronously (launched on the composition scope) + * so callers don't get blocked by `snack.showSnackbar` (it is suspending). + * + * Usage notes: + * - Prefer calling this from UI code wrapped by `runAdbConnect`/`runBusy` where appropriate + * so the UI busy/connect gates are respected. + * - This function is idempotent from the UI state perspective: calling it when already + * disconnected will simply reset UI fields and not throw. + */ suspend fun disconnectAdbConnection( clearQuickOnlineForTarget: ConnectionTarget? = currentTarget, logMessage: String? = null, showSnackMessage: String? = null, ) { withContext(adbWorkerDispatcher) { + // Also stops scrcpy. runCatching { nativeCore.scrcpyStop() } runCatching { nativeCore.adbDisconnect() } } @@ -355,6 +381,7 @@ fun DeviceTabScreen( } suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) { + // Force old target cleanup before switching to another endpoint. val current = currentTarget if (!adbConnected || current == null) return if (current.host == newHost && current.port == newPort) return @@ -385,6 +412,18 @@ fun DeviceTabScreen( } } + /** + * Attempt to connect to an adb endpoint within a short timeout. + * + * Execution: + * - Runs `nativeCore.adbConnect(host, port)` on [adbWorkerDispatcher] and wraps it with + * [withTimeout] to avoid hanging forever. Returns true on success, false / throws on failure + * depending on the underlying native behavior. + * + * Why this exists: + * - 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 { return withContext(adbWorkerDispatcher) { withTimeout(ADB_CONNECT_TIMEOUT_MS) { @@ -393,6 +432,19 @@ fun DeviceTabScreen( } } + /** + * Validate that the current ADB connection is still alive. + * + * Behavior: + * - Runs on [adbWorkerDispatcher] with a short timeout. + * - First checks `nativeCore.adbIsConnected()` to avoid unnecessary shell calls. + * - Executes a lightweight `adb shell` command (`echo -n 1`) to verify the remote side is + * responsive. Returns true only when both checks succeed. + * + * Notes for reliability: + * - Some devices may accept TCP connections but have a hung adb-server process; the shell + * echo check helps detect that state. + */ suspend fun keepAliveCheck(host: String, port: Int): Boolean { return withContext(adbWorkerDispatcher) { withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { @@ -408,6 +460,13 @@ fun DeviceTabScreen( } } + /** + * Quickly test TCP reachability to an endpoint. + * + * - Uses a plain Socket connect on [Dispatchers.IO] with a very short timeout. + * - This is useful before attempting an adb connect to avoid long native timeouts. + * - Returns true when TCP handshake succeeds within [ADB_TCP_PROBE_TIMEOUT_MS]. + */ suspend fun probeTcpReachable(host: String, port: Int): Boolean { return withContext(Dispatchers.IO) { runCatching { @@ -419,7 +478,17 @@ fun DeviceTabScreen( } } + /** + * Execute a suspend [block] while toggling the `busy` UI gate. + * + * - Intended for non-adb user actions (UI-level operations) that should disable + * certain controls while active (e.g. scrcpy start/stop, pairing, listing). + * - Errors are logged and surfaced via a snackbar where appropriate. The snackbar + * is launched asynchronously so the outer coroutine can continue to unwind. + * - Ensures `busy` is reset in `finally` so the UI recovers even on exceptions. + */ fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { + // For non-adb actions (start/stop/pair/list refresh...). if (busy) return scope.launch { busy = true @@ -443,7 +512,20 @@ fun DeviceTabScreen( } } + /** + * Execute a manual ADB-related suspend [block] while toggling `adbConnecting`. + * + * Purpose: + * - Called from explicit user actions (pressing "connect" / "disconnect"). + * - Keeps the UI responsive by marking only user-initiated connect operations as in-progress. + * + * Concurrency notes: + * - Background auto-reconnect attempts deliberately DO NOT set `adbConnecting` so that + * UI controls remain actionable while background retries occur. + * - Errors and timeouts are logged and surfaced similarly to `runBusy`. + */ fun runAdbConnect(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { + // For manual adb operations from user actions. if (adbConnecting) return scope.launch { adbConnecting = true @@ -673,6 +755,8 @@ fun DeviceTabScreen( LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect + // Keep-alive loop for current target. + // On failure: try to reconnect once; if failed, fully disconnect and reset UI state. val host = currentTargetHost val port = currentTargetPort while (adbConnected && currentTargetHost == host && currentTargetPort == port) { @@ -704,6 +788,9 @@ fun DeviceTabScreen( LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscoveryEnabled) { if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect + // Background auto reconnect pipeline: + // 1) try quick list targets with reachable TCP ports + // 2) fallback to mDNS discovery val quickConnectTriedOnce = mutableSetOf() while (!adbConnected && adbAutoReconnectPairedDevice) { if (busy || adbConnecting || sessionInfo != null) { 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 6d8474e..5d12f2c 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 @@ -45,8 +45,7 @@ fun FullscreenControlPage( onVideoSizeChanged: (width: Int, height: Int) -> Unit, onDismiss: () -> Unit, ) { - // 全屏控制页不使用预测性返回手势, - // 省得处理解码的问题 + // Disable predictive back handler temporarily to avoid decoding issues. BackHandler(enabled = true, onBack = onDismiss) val context = LocalContext.current 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 d2cf3b9..a4410ff 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 @@ -203,12 +203,14 @@ fun MainPage() { val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled) val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + // Restore system orientation when MainPage leaves composition. DisposableEffect(activity) { onDispose { activity?.requestedOrientation = initialOrientation } } + // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { val window = activity?.window val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionStarted @@ -222,6 +224,7 @@ fun MainPage() { } } + // Fullscreen route can force orientation based on stream ratio; all other routes are portrait. LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) { val targetOrientation = when (currentRootScreen) { is RootScreen.Fullscreen -> fullscreenOrientation @@ -283,6 +286,10 @@ fun MainPage() { } } + // Unified back behavior: + // 1) pop inner route + // 2) switch tab back to Device + // 3) double-back to exit and disconnect adb/scrcpy fun handleBackNavigation() { if (rootBackStack.size > 1) { popRoot() 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 d7c3d3a..28efac5 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 @@ -12,6 +12,15 @@ internal data class ConnectedDeviceInfo( val sdkInt: Int, ) +/** + * Fetch basic device properties from an already-connected device. + * + * Notes: + * - This function issues multiple `adb shell getprop` commands via [nativeCore.adbShell]. + * Each call may block on native I/O, so callers should execute this on the dedicated + * ADB worker dispatcher rather than the UI thread. + * - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties. + */ internal fun fetchConnectedDeviceInfo( nativeCore: NativeCoreFacade, host: String, 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 2083ad3..c9cfc26 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 @@ -485,6 +485,19 @@ internal fun ConfigPanel( } } +/** + * PairingDialog + * + * Purpose: + * - A small helper dialog UI that optionally performs an asynchronous discovery + * step (`onDiscoverTarget`) to pre-fill host/port fields. + * + * Behavior: + * - Discovery runs on [Dispatchers.IO] to avoid blocking the UI and updates + * local `host`/`port` state on success. + * - Input validation is simple (non-empty checks) and the final `onConfirm` callback + * receives trimmed values. + */ @Composable private fun PairingDialog( showDialog: Boolean, @@ -635,6 +648,22 @@ internal fun LogsPanel(lines: List) { } } +/** + * FullscreenControlScreen + * + * Purpose: + * - Presents a fullscreen interactive touch surface that maps Compose touch events + * to device coordinates and injects them via [onInjectTouch]. + * - Responsible for pointer tracking, multi-touch mapping, coordinate normalization, + * and lifetime of synthetic touch events sent to the device. + * + * Concurrency and side-effects: + * - All heavy computations are local to the UI thread; injection itself is a quick + * callback (`onInjectTouch`) which delegates to native code elsewhere — keep that + * callback lightweight. + * - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise + * multi-touch handling and to map Android pointer IDs to device pointers. + */ @Composable fun FullscreenControlScreen( session: ScrcpySessionInfo, @@ -888,6 +917,26 @@ fun FullscreenControlScreen( } } +/** + * ScrcpyVideoSurface + * + * Purpose: + * - Hosts a `TextureView` and bridges its `Surface` to `nativeCore` for video rendering. + * - Ensures only a single `Surface` instance is registered at any time under the + * stable `surfaceTag` ("video-main"). This reduces surface recreation bugs seen + * when preview/fullscreen used separate tags. + * + * Concurrency / lifecycle: + * - `currentSurface` is only mutated on the UI thread via TextureView callbacks. + * - Registration to `nativeCore` is triggered from a [LaunchedEffect] when both + * `session` and `currentSurface` are available. Unregistration happens in + * `onSurfaceTextureDestroyed` and `DisposableEffect.onDispose` to guarantee + * cleanup even if the composable leaves composition. + * + * Reliability notes: + * - Always release old surfaces before assigning new ones to avoid native renderer + * referencing stale surfaces. + */ @Composable private fun ScrcpyVideoSurface( modifier: Modifier, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt index 365d5f1..247b39f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt @@ -45,6 +45,21 @@ data class SortDropPayload( val transferDirection: SortTransferDirection, ) +/** + * Displays a titled list of sortable cards. + * + * Each item can be long-pressed and dragged. When a drag ends the + * `onDrop` callback receives a payload describing the item id and + * the drag delta which callers can use to perform reordering or + * transfer actions. + * + * @param title The section title displayed above the list. + * @param items The list of items to render as sortable cards. + * @param modifier Optional [Modifier] applied to the outer column. + * @param transferDirection Hint for transfer direction used by callers. + * @param onLongPressHaptic Optional callback invoked when an item is long-pressed. + * @param onDrop Callback invoked when a dragged item is dropped. + */ @Composable fun SortableCardList( title: String,