doc: improved comments

This commit is contained in:
Miuzarte
2026-03-21 23:15:56 +08:00
parent 07fa81dbb9
commit 4c7cf64b98
21 changed files with 504 additions and 10 deletions

View File

@@ -27,6 +27,8 @@
- 组件排序动画一坨
- 退出全屏时的横竖屏状态可能不对,断开 scrcpy 重连就好
- 如果受控机在锁屏时处理网络连接较慢,会导致应用界面无响应过长时间被系统杀掉(点名你米)
- 虚拟按键的截图实现方式为发送 `keycode 120`,安卓官方的定义为 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
- I18N
## 建议搭配模块

View File

@@ -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 <T> ioCall(task: () -> T): T {
return try {
executor.submit<T> { 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)
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -20,4 +20,4 @@ object UiSpacing {
val CardTitle = 16.dp
val BottomContent = 64.dp
val BottomSheetBottom = 16.dp
}
}

View File

@@ -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,

View File

@@ -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<String, Int>? {
return discoverService(TLS_PAIRING, timeoutMs, includeLanDevices)
}
/**
* Discover a device that advertises the ADB connect service via mDNS.
*/
fun discoverConnectService(timeoutMs: Long, includeLanDevices: Boolean): Pair<String, Int>? {
return discoverService(TLS_CONNECT, timeoutMs, includeLanDevices)
}
/**
* Discover a device that advertises the ADB pairing service via mDNS.
*/
fun discoverPairingService(timeoutMs: Long, includeLanDevices: Boolean): Pair<String, Int>? {
return discoverService(TLS_PAIRING, timeoutMs, includeLanDevices)
}
private fun discoverService(
serviceType: String,
timeoutMs: Long,

View File

@@ -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()

View File

@@ -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

View File

@@ -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<PrivateKey, ByteArray> 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<PrivateKey, ByteArray> {
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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -34,6 +34,21 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
private var lastServerCommand: String? = null
private val serverLogBuffer = ArrayDeque<String>()
/**
* 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

View File

@@ -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<String>()
while (!adbConnected && adbAutoReconnectPairedDevice) {
if (busy || adbConnecting || sessionInfo != null) {

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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<String>) {
}
}
/**
* 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,

View File

@@ -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,