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