fix: implement flow control for adb

This commit is contained in:
Miuzarte
2026-07-07 17:56:25 +08:00
parent c61522ae14
commit 6040d0e2f4
9 changed files with 135 additions and 50 deletions

View File

@@ -660,9 +660,10 @@ internal class DirectAdbConnection(
*/
fun openStream(service: String): AdbSocketStream {
val localId = nextLocalId.getAndIncrement()
val stream = AdbSocketStream(localId) { command, arg0, arg1, data ->
val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow
val stream = AdbSocketStream(localId, { command, arg0, arg1, data ->
sendMsg(command, arg0, arg1, data)
}
}, flowControlWindow)
streams[localId] = stream
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
try {
@@ -959,6 +960,7 @@ internal class DirectAdbConnection(
class AdbSocketStream(
val localId: Int,
private val sender: (cmd: Int, arg0: Int, arg1: Int, `data`: ByteArray) -> Unit,
private val flowControlWindow: Int = 0,
): Closeable {
companion object {
@@ -976,6 +978,10 @@ class AdbSocketStream(
private val latchOk = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<Any>()
// need notifyAll() / wait()
private val writeLock = java.lang.Object()
@Volatile private var inflightWrites = 0
private object EndOfStreamMarker
val inputStream: InputStream = InStream()
@@ -987,6 +993,12 @@ class AdbSocketStream(
latchOk.set(true)
latch.countDown()
}
if (flowControlWindow > 0) {
synchronized(writeLock) {
if (inflightWrites > 0) inflightWrites--
writeLock.notifyAll()
}
}
}
internal fun onData(data: ByteArray) {
@@ -997,6 +1009,9 @@ class AdbSocketStream(
closed = true
queue.offer(EndOfStreamMarker)
latch.countDown()
if (flowControlWindow > 0) {
synchronized(writeLock) { writeLock.notifyAll() }
}
}
fun awaitOpen(timeoutMs: Long) {
@@ -1016,6 +1031,9 @@ class AdbSocketStream(
sender(A_CLSE, localId, remoteId, ByteArray(0))
}
queue.offer(EndOfStreamMarker)
if (flowControlWindow > 0) {
synchronized(writeLock) { writeLock.notifyAll() }
}
}
private inner class InStream: InputStream() {
@@ -1054,6 +1072,15 @@ class AdbSocketStream(
override fun write(b: ByteArray, off: Int, len: Int) {
if (closed) throw IOException("ADB stream closed")
if (len == 0) return
if (flowControlWindow > 0) {
synchronized(writeLock) {
while (inflightWrites >= flowControlWindow && !closed) {
writeLock.wait()
}
if (closed) throw IOException("ADB stream closed")
inflightWrites++
}
}
sender(A_WRTE, localId, remoteId, b.copyOfRange(off, off + len))
}

View File

@@ -629,6 +629,7 @@ private fun PathJumpDialog(
onConfirm()
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}

View File

@@ -71,6 +71,10 @@ fun FullscreenControlScreen(
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
LaunchedEffect(currentSession) {
if (currentSession == null) onBack()
}
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }

View File

@@ -1121,6 +1121,30 @@ fun SettingsPage(
)
},
)
ArrowSlider(
title = stringResource(R.string.pref_title_adb_flow_control),
summary = stringResource(R.string.pref_summary_adb_flow_control),
value = asBundle.adbFlowControlWindow.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
adbFlowControlWindow = it.roundToInt(),
)
},
valueRange = 0f..4f,
steps = 4 - 0 - 1,
zeroStateText = stringResource(R.string.pref_adb_flow_control_disabled),
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = asBundle.adbFlowControlWindow.toString(),
inputFilter = { input -> input.filter(Char::isDigit) },
inputValueRange = 0f..4f,
onInputConfirm = { input ->
input.toIntOrNull()?.let {
asBundle = asBundle.copy(
adbFlowControlWindow = it.coerceIn(0, 4),
)
}
},
)
}
}

View File

@@ -8,6 +8,7 @@ import android.view.KeyEvent
import androidx.core.net.toUri
import com.github.promeg.pinyinhelper.Pinyin
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
@@ -58,11 +59,13 @@ class Scrcpy(
private val lowLatency: Boolean = false,
) {
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val session = Session(
::handleRemoteClipboardText,
::updateCurrentSessionSize,
)
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) {
backgroundScope.launch { stop(StopReason.REMOTE_DISCONNECTED) }
}
private val clipboardSyncLock = Any()
private val clipboardManager by lazy {
appContext.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
@@ -86,6 +89,12 @@ class Scrcpy(
@Volatile
private var isRunning: Boolean = false
enum class StopReason { USER, REMOTE_DISCONNECTED }
@Volatile
@JvmField
var lastStopReason: StopReason = StopReason.USER
@Volatile
@JvmField
var flexDisplay: Boolean = false
@@ -310,13 +319,14 @@ class Scrcpy(
}
}
suspend fun stop(): Boolean = withContext(Dispatchers.IO) {
suspend fun stop(reason: StopReason = StopReason.USER): Boolean = withContext(Dispatchers.IO) {
if (!isRunning) {
Log.w(TAG, "stop(): No active session to stop")
return@withContext false
}
Log.i(TAG, "stop(): Stopping scrcpy session")
lastStopReason = reason
Log.i(TAG, "stop(): Stopping scrcpy session (reason=$reason)")
return@withContext try {
session.clearVideoConsumer()
@@ -335,6 +345,10 @@ class Scrcpy(
flexDisplay = false
_currentSessionState.value = null
stopClipboardSync()
if (reason == StopReason.REMOTE_DISCONNECTED) {
logEvent(R.string.vm_session_disconnected, level = Log.WARN)
AppRuntime.snackbar(R.string.vm_session_disconnected)
}
Log.i(TAG, "stop(): Session stopped successfully")
true
} catch (e: Exception) {
@@ -979,12 +993,16 @@ class Scrcpy(
class Session(
private val onRemoteClipboardText: (String) -> Unit,
private val onVideoSessionSize: (Int, Int) -> Unit,
private val onSessionDied: () -> Unit,
) {
private val mutex = Mutex()
@Volatile
private var activeSession: ActiveSession? = null
@Volatile
private var controlChannelAlive: Boolean = false
private val videoConsumers = linkedSetOf<(VideoPacket) -> Unit>()
@Volatile
@@ -1138,6 +1156,7 @@ class Scrcpy(
controlWriter = controlWriter,
)
activeSession = newSession
controlChannelAlive = options.control
if (options.control && options.clipboardAutosync)
startControlReaderThread(newSession)
@@ -1220,6 +1239,7 @@ class Scrcpy(
}
}
} finally {
if (activeSession === session) onSessionDied()
}
}
}
@@ -1265,6 +1285,7 @@ class Scrcpy(
}
}
} finally {
if (activeSession === session) onSessionDied()
}
}
}
@@ -1272,19 +1293,11 @@ class Scrcpy(
suspend fun clearAudioConsumer() = mutex.withLock { audioConsumers.clear() }
suspend fun startApp(name: String) = mutex.withLock {
try {
requireControlWriter().startApp(name)
} catch (e: IllegalStateException) {
Log.w(TAG, "startApp(): control channel not available", e)
}
withControlWriter("startApp") { startApp(name) }
}
suspend fun resizeDisplay(width: Int, height: Int) = mutex.withLock {
try {
requireControlWriter().resizeDisplay(width, height)
} catch (e: IllegalStateException) {
Log.w(TAG, "resizeDisplay(): control channel not available", e)
}
withControlWriter("resizeDisplay") { resizeDisplay(width, height) }
}
suspend fun injectKeycode(
@@ -1293,27 +1306,15 @@ class Scrcpy(
repeat: Int = 0,
metaState: Int = 0,
) = mutex.withLock {
try {
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectKeycode(): control channel not available", e)
}
withControlWriter("injectKeycode") { injectKeycode(action, keycode, repeat, metaState) }
}
suspend fun injectText(text: String) = mutex.withLock {
try {
requireControlWriter().injectText(text)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectText(): control channel not available", e)
}
withControlWriter("injectText") { injectText(text) }
}
suspend fun setClipboard(text: String, paste: Boolean) = mutex.withLock {
try {
requireControlWriter().setClipboard(text, paste)
} catch (e: IllegalStateException) {
Log.w(TAG, "setClipboard(): control channel not available", e)
}
withControlWriter("setClipboard") { setClipboard(text, paste) }
}
suspend fun injectTouch(
@@ -1327,8 +1328,8 @@ class Scrcpy(
actionButton: Int,
buttons: Int,
) = mutex.withLock {
try {
requireControlWriter().injectTouch(
withControlWriter("injectTouch") {
injectTouch(
action,
pointerId,
x,
@@ -1339,8 +1340,6 @@ class Scrcpy(
actionButton,
buttons,
)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectTouch(): control channel not available", e)
}
}
@@ -1353,8 +1352,8 @@ class Scrcpy(
vScroll: Float,
buttons: Int,
) = mutex.withLock {
try {
requireControlWriter().injectScroll(
withControlWriter("injectScroll") {
injectScroll(
x,
y,
screenWidth,
@@ -1363,25 +1362,15 @@ class Scrcpy(
vScroll,
buttons,
)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectScroll(): control channel not available", e)
}
}
suspend fun pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
try {
requireControlWriter().pressBackOrTurnScreenOn(action)
} catch (e: IllegalStateException) {
Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e)
}
withControlWriter("pressBackOrTurnScreenOn") { pressBackOrTurnScreenOn(action) }
}
suspend fun setDisplayPower(on: Boolean) = mutex.withLock {
try {
requireControlWriter().setDisplayPower(on)
} catch (e: IllegalStateException) {
Log.w(TAG, "setDisplayPower(): control channel not available", e)
}
withControlWriter("setDisplayPower") { setDisplayPower(on) }
}
suspend fun stop() = mutex.withLock {
@@ -1391,6 +1380,7 @@ class Scrcpy(
private fun stopInternal() {
val session = activeSession ?: return
activeSession = null
controlChannelAlive = false
videoConsumers.clear()
audioConsumers.clear()
@@ -1431,6 +1421,23 @@ class Scrcpy(
?: throw IllegalStateException("scrcpy control channel not available")
}
private inline fun withControlWriter(tag: String, block: ControlWriter.() -> Unit) {
if (!controlChannelAlive) {
Log.w(TAG, "$tag(): control channel not alive")
return
}
try {
requireControlWriter().block()
} catch (e: IllegalStateException) {
controlChannelAlive = false
Log.w(TAG, "$tag(): control channel not available", e)
} catch (e: IOException) {
controlChannelAlive = false
Log.w(TAG, "$tag(): control channel I/O failed, marking dead", e)
onSessionDied()
}
}
private fun startControlReaderThread(session: ActiveSession) {
val controlInput = session.controlInput ?: return
val controlStream = session.controlStream ?: return
@@ -1478,6 +1485,7 @@ class Scrcpy(
}
}
} finally {
if (activeSession === session) onSessionDied()
}
}
}

View File

@@ -5,7 +5,9 @@ import android.view.InputDevice
import android.view.MotionEvent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@@ -58,6 +60,7 @@ class TouchEventHandler(
private val eventPositions = HashMap<Int, Offset>(10)
private val eventPressures = HashMap<Int, Float>(10)
private val justPressedPointerIds = HashSet<Int>(10)
private val pendingMoveJobs = HashMap<Int, Job>(10)
fun handleMotionEvent(event: MotionEvent): Boolean {
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
@@ -263,6 +266,7 @@ class TouchEventHandler(
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
if (!activePointerIds.contains(pointerId)) return
pendingMoveJobs.remove(pointerId)?.cancel()
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch {
@@ -370,10 +374,12 @@ class TouchEventHandler(
activePointerPositions[pointerId] = raw
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch {
pendingMoveJobs[pointerId]?.cancel()
pendingMoveJobs[pointerId] = coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0, 0)
}.onFailure { e ->
if (e is CancellationException) throw e
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerMove failed for pointerId=$pointerId",

View File

@@ -261,6 +261,10 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
booleanPreferencesKey("adb_auto_load_app_list_on_connect"),
false,
)
val ADB_FLOW_CONTROL_WINDOW = Pair(
intPreferencesKey("adb_flow_control_window"),
0,
)
// Terminal
val TERMINAL_FONT_SIZE_SP = Pair(
@@ -349,6 +353,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
val adbAutoReconnectPairedDevice: Boolean,
val adbMdnsLanDiscovery: Boolean,
val adbAutoLoadAppListOnConnect: Boolean,
val adbFlowControlWindow: Int,
// Terminal
val terminalFontSizeSp: Float,
@@ -413,6 +418,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { it.adbAutoReconnectPairedDevice },
bundleField(ADB_MDNS_LAN_DISCOVERY) { it.adbMdnsLanDiscovery },
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { it.adbAutoLoadAppListOnConnect },
bundleField(ADB_FLOW_CONTROL_WINDOW) { it.adbFlowControlWindow },
// Terminal
bundleField(TERMINAL_FONT_SIZE_SP) { it.terminalFontSizeSp },
@@ -482,6 +488,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
adbFlowControlWindow = preferences.read(ADB_FLOW_CONTROL_WINDOW),
// Terminal
terminalFontSizeSp = preferences.read(TERMINAL_FONT_SIZE_SP),

View File

@@ -239,6 +239,9 @@
<string name="cd_export_adb_key">导出</string>
<string name="pref_adb_key_reset_confirm_title">重置 ADB 密钥</string>
<string name="pref_adb_key_reset_confirm_summary">重置后,所有已授权的设备都需要重新授权。\n是否继续</string>
<string name="pref_title_adb_flow_control">ADB 流控窗口</string>
<string name="pref_summary_adb_flow_control">1 = 严格流控\n数值越大吞吐越高但可能触发 adbd 流控保护</string>
<string name="pref_adb_flow_control_disabled">禁用</string>
<string name="pref_title_auto_discovery">配对时自动启用发现服务</string>
<string name="pref_summary_auto_discovery">打开配对弹窗后自动搜索可用配对端口</string>
<string name="pref_title_auto_reconnect">自动重连已配对设备</string>
@@ -570,6 +573,7 @@
<string name="vm_legacy_paste_injected">已使用 legacy paste 注入本机剪贴板文本</string>
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string>
<string name="vm_session_disconnected">Scrcpy 连接已断开</string>
<string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string>
<string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string>
<string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string>

View File

@@ -239,6 +239,9 @@
<string name="cd_export_adb_key">Export</string>
<string name="pref_adb_key_reset_confirm_title">Reset ADB key</string>
<string name="pref_adb_key_reset_confirm_summary">After resetting, all previously authorized devices will need to be re-authorized.\nContinue?</string>
<string name="pref_title_adb_flow_control">ADB flow control window</string>
<string name="pref_summary_adb_flow_control">1 = strict flow control\nHigher values increase throughput but may trigger adbd flow control protection</string>
<string name="pref_adb_flow_control_disabled">Disabled</string>
<string name="pref_title_auto_discovery">Auto enable discovery service when pairing</string>
<string name="pref_summary_auto_discovery">Automatically search for available pairing ports after opening the pairing dialog</string>
<string name="pref_title_auto_reconnect">Auto reconnect paired devices</string>
@@ -570,6 +573,7 @@
<string name="vm_legacy_paste_injected">Used legacy paste to inject local clipboard text</string>
<string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string>
<string name="vm_clipboard_paste_failed">Local clipboard paste failed</string>
<string name="vm_session_disconnected">Scrcpy session disconnected</string>
<string name="vm_ime_text_failed">IME text submission failed: %1$s</string>
<string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string>
<string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string>