first commit

This commit is contained in:
Miuzarte
2026-03-19 08:53:26 +08:00
parent 62b60bcf71
commit 45f2df661b
69 changed files with 9281 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
package io.github.miuzarte.scrcpyforandroid
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.github.miuzarte.scrcpyforandroid", appContext.packageName)
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ScrcpyForAndroid">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

View File

@@ -0,0 +1,18 @@
package io.github.miuzarte.scrcpyforandroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import io.github.miuzarte.scrcpyforandroid.pages.MainPage
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MainPage()
}
}
}

View File

@@ -0,0 +1,654 @@
package io.github.miuzarte.scrcpyforandroid
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
import java.io.File
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.ArrayDeque
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
class NativeCoreFacade(private val appContext: Context) {
private val adbService = NativeAdbService(appContext)
private val sessionManager = ScrcpySessionManager(adbService)
private val executor = Executors.newSingleThreadExecutor()
private val surfaceMap = ConcurrentHashMap<String, Surface>()
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
private val mainHandler = Handler(Looper.getMainLooper())
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
private var packetCount: Long = 0
@Volatile private var audioPlayer: ScrcpyAudioPlayer? = null
@Volatile
private var currentSessionInfo: ScrcpySessionInfo? = null
fun close() {
releaseAllDecoders()
runCatching { sessionManager.stop() }
runCatching { adbService.close() }
executor.shutdown()
}
fun registerVideoSurface(tag: String, surface: Surface) {
val newId = System.identityHashCode(surface)
val oldId = surfaceIdentityMap[tag]
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
return
}
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
surfaceMap[tag] = surface
surfaceIdentityMap[tag] = newId
val session = currentSessionInfo ?: return
ensureVideoConsumerAttached()
val decoder = decoderMap[tag]
if (decoder != null) {
val switched = decoder.switchOutputSurface(surface)
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
if (switched) {
return
}
}
createOrReplaceDecoder(tag, surface, session)
}
fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
val currentId = surfaceIdentityMap[tag]
val requestId = surface?.let { System.identityHashCode(it) }
if (requestId != null && currentId != null && requestId != currentId) {
Log.i(TAG, "unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId")
return
}
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
surfaceMap.remove(tag)
surfaceIdentityMap.remove(tag)
if (currentSessionInfo == null) {
decoderMap.remove(tag)?.release()
}
}
fun adbPair(host: String, port: Int, pairingCode: String): Boolean {
return ioCall { adbService.pair(host, port, pairingCode) }
}
fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
fun adbDisconnect(): Boolean {
ioCall { adbService.disconnect() }
return true
}
fun adbIsConnected(): Boolean = ioCall { adbService.isConnected() }
fun adbShell(command: String): String = ioCall { adbService.shell(command) }
fun setAdbKeyName(name: String) {
adbService.keyName = name
}
fun scrcpyStart(request: ScrcpyStartRequest): ScrcpySessionInfo {
return ioCall {
Log.i(TAG, "scrcpyStart(): request codec=${request.videoCodec} audio=${request.audio}")
val serverJar = if (request.customServerUri.isNullOrBlank()) {
extractAssetToCache(request.serverAsset)
} else {
extractUriToCache(request.customServerUri.toUri())
}
val info = sessionManager.start(
serverJar.toPath(),
ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = request.serverVersion,
serverRemotePath = request.serverRemotePath,
video = !request.noVideo,
audio = request.audio,
control = !request.noControl,
maxSize = request.maxSize,
maxFps = request.maxFps,
videoBitRate = request.videoBitRate,
videoCodec = request.videoCodec,
audioBitRate = request.audioBitRate,
audioCodec = request.audioCodec,
videoEncoder = request.videoEncoder,
videoCodecOptions = request.videoCodecOptions,
audioEncoder = request.audioEncoder,
audioCodecOptions = request.audioCodecOptions,
audioDup = request.audioDup,
audioSource = request.audioSource,
videoSource = request.videoSource,
cameraId = request.cameraId,
cameraFacing = request.cameraFacing,
cameraSize = request.cameraSize,
cameraAr = request.cameraAr,
cameraFps = request.cameraFps,
cameraHighSpeed = request.cameraHighSpeed,
newDisplay = request.newDisplay,
displayId = request.displayId,
crop = request.crop,
),
)
if (request.turnScreenOff) {
if (request.noControl) {
Log.w(TAG, "scrcpyStart(): turnScreenOff ignored because control is disabled")
} else {
runCatching { sessionManager.setDisplayPower(on = false) }
.onFailure { e -> Log.w(TAG, "scrcpyStart(): set display power failed", e) }
}
}
val session = ScrcpySessionInfo(
width = info.width,
height = info.height,
deviceName = info.deviceName,
codec = info.codecName,
controlEnabled = info.controlEnabled,
)
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
}
if (!request.noVideo) {
surfaceMap.forEach { (tag, surface) ->
Log.i(TAG, "scrcpyStart(): bind decoder to tag=$tag")
createOrReplaceDecoder(tag, surface, session)
}
}
packetCount = 0
if (!request.noVideo) {
ensureVideoConsumerAttached()
}
// Audio player
audioPlayer?.release()
audioPlayer = null
if (info.audioCodecId != 0 && !request.noAudioPlayback) {
Log.i(TAG, "scrcpyStart(): create audio player codecId=0x${info.audioCodecId.toUInt().toString(16)}")
val player = ScrcpyAudioPlayer(info.audioCodecId)
audioPlayer = player
sessionManager.attachAudioConsumer { packet ->
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
}
} else {
Log.i(TAG, "scrcpyStart(): audio playback disabled for this session")
}
session
}
}
fun scrcpyStop(): Boolean {
ioCall {
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
}
currentSessionInfo = null
sessionManager.clearVideoConsumer()
sessionManager.clearAudioConsumer()
sessionManager.stop()
audioPlayer?.release()
audioPlayer = null
}
return true
}
fun scrcpyListEncoders(customServerUri: String?, remotePath: String, serverVersion: String = "3.3.4"): ScrcpyEncoderLists {
return ioCall {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(DEFAULT_SERVER_ASSET)
} else {
extractUriToCache(customServerUri.toUri())
}
val result = sessionManager.listEncoders(
serverJarPath = serverJar.toPath(),
options = ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = serverVersion,
serverRemotePath = remotePath,
),
)
ScrcpyEncoderLists(
videoEncoders = result.videoEncoders,
audioEncoders = result.audioEncoders,
videoEncoderTypes = result.videoEncoderTypes,
audioEncoderTypes = result.audioEncoderTypes,
rawOutput = result.rawOutput,
)
}
}
fun scrcpyListCameraSizes(customServerUri: String?, remotePath: String, serverVersion: String = "3.3.4"): ScrcpyCameraSizeLists {
return ioCall {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(DEFAULT_SERVER_ASSET)
} else {
extractUriToCache(customServerUri.toUri())
}
val result = sessionManager.listCameraSizes(
serverJarPath = serverJar.toPath(),
options = ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = serverVersion,
serverRemotePath = remotePath,
),
)
ScrcpyCameraSizeLists(
sizes = result.sizes,
rawOutput = result.rawOutput,
)
}
}
fun scrcpyIsStarted(): Boolean = ioCall { sessionManager.isStarted() }
fun getLastScrcpyServerCommand(): String? = ioCall { sessionManager.getLastServerCommand() }
fun scrcpyInjectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
ioExecute {
runCatching { sessionManager.injectKeycode(action, keycode, repeat, metaState) }
}
}
fun scrcpyInjectText(text: String) {
ioExecute {
runCatching { sessionManager.injectText(text) }
}
}
fun scrcpyInjectTouch(
action: Int,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
pointerId: Long = 0L,
actionButton: Int = 1,
buttons: Int = 1,
) {
ioExecute {
runCatching {
sessionManager.injectTouch(
action = action,
pointerId = pointerId,
x = x,
y = y,
screenWidth = screenWidth,
screenHeight = screenHeight,
pressure = pressure,
actionButton = actionButton,
buttons = buttons,
)
}
}
}
fun scrcpyInjectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int = 0,
) {
ioExecute {
runCatching { sessionManager.injectScroll(x, y, screenWidth, screenHeight, hScroll, vScroll, buttons) }
}
}
fun addVideoSizeListener(listener: (Int, Int) -> Unit) {
videoSizeListeners.add(listener)
}
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) {
videoSizeListeners.remove(listener)
}
fun addVideoFpsListener(listener: (Float) -> Unit) {
videoFpsListeners.add(listener)
}
fun removeVideoFpsListener(listener: (Float) -> Unit) {
videoFpsListeners.remove(listener)
}
fun scrcpyBackOrScreenOn(action: Int = 0) {
ioExecute {
runCatching { sessionManager.pressBackOrScreenOn(action) }
}
}
private fun extractAssetToCache(assetPath: String): File {
val clean = assetPath.removePrefix("/")
val source = appContext.assets.open(clean)
val outputFile = File(appContext.cacheDir, File(clean).name)
source.use { input ->
outputFile.outputStream().use { output -> input.copyTo(output) }
}
return outputFile
}
private fun extractUriToCache(uri: Uri): File {
val fileName = "custom-scrcpy-server.jar"
val outputFile = File(appContext.cacheDir, fileName)
appContext.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Unable to open selected server URI" }
outputFile.outputStream().use { output -> input.copyTo(output) }
}
return outputFile
}
companion object {
private const val TAG = "NativeCoreFacade"
private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
private const val MAX_BOOTSTRAP_PACKETS = 90
@Volatile
private var instance: NativeCoreFacade? = null
fun get(context: Context): NativeCoreFacade {
return instance ?: synchronized(this) {
instance ?: NativeCoreFacade(context.applicationContext).also { instance = it }
}
}
fun defaultStartRequest(
customServerUri: String?,
maxSize: Int,
videoBitRate: Int,
remotePath: String,
videoCodec: String = "h264",
audio: Boolean = true,
audioCodec: String = "opus",
audioBitRate: Int = 128_000,
maxFps: Float = 0f,
noControl: Boolean = false,
videoEncoder: String = "",
videoCodecOptions: String = "",
audioEncoder: String = "",
audioCodecOptions: String = "",
audioDup: Boolean = false,
audioSource: String = "",
videoSource: String = "display",
cameraId: String = "",
cameraFacing: String = "",
cameraSize: String = "",
cameraAr: String = "",
cameraFps: Int = 0,
cameraHighSpeed: Boolean = false,
noAudioPlayback: Boolean = false,
requireAudio: Boolean = false,
turnScreenOff: Boolean = false,
noVideo: Boolean = false,
newDisplay: String = "",
displayId: Int? = null,
crop: String = "",
): ScrcpyStartRequest {
return ScrcpyStartRequest(
serverAsset = DEFAULT_SERVER_ASSET,
customServerUri = customServerUri,
serverVersion = "3.3.4",
serverRemotePath = remotePath,
maxSize = maxSize,
videoBitRate = videoBitRate,
videoCodec = videoCodec,
audio = audio,
audioCodec = audioCodec,
audioBitRate = audioBitRate,
maxFps = maxFps,
noControl = noControl,
videoEncoder = videoEncoder,
videoCodecOptions = videoCodecOptions,
audioEncoder = audioEncoder,
audioCodecOptions = audioCodecOptions,
audioDup = audioDup,
audioSource = audioSource,
videoSource = videoSource,
cameraId = cameraId,
cameraFacing = cameraFacing,
cameraSize = cameraSize,
cameraAr = cameraAr,
cameraFps = cameraFps,
cameraHighSpeed = cameraHighSpeed,
noAudioPlayback = noAudioPlayback,
requireAudio = requireAudio,
turnScreenOff = turnScreenOff,
noVideo = noVideo,
newDisplay = newDisplay,
displayId = displayId,
crop = crop,
)
}
fun nowLogPrefix(): String {
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
return "[$stamp]"
}
}
private data class CachedPacket(
val data: ByteArray,
val ptsUs: Long,
val isConfig: Boolean,
val isKeyFrame: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CachedPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (isKeyFrame != other.isKeyFrame) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = ptsUs.hashCode()
result = 31 * result + isConfig.hashCode()
result = 31 * result + isKeyFrame.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) {
decoderMap.remove(tag)?.release()
val mime = when (session.codec.lowercase()) {
"h264" -> "video/avc"
"h265" -> "video/hevc"
"av1" -> "video/av01"
else -> "video/avc"
}
Log.i(TAG, "createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}")
val decoder = AnnexBDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = mime,
onOutputSizeChanged = { width, height ->
val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) {
return@AnnexBDecoder
}
Log.i(TAG, "videoSizeChanged(): ${current.width}x${current.height} -> ${width}x${height}")
currentSessionInfo = current.copy(width = width, height = height)
mainHandler.post {
videoSizeListeners.forEach { listener ->
runCatching { listener(width, height) }
}
}
},
onFpsUpdated = { fps ->
mainHandler.post {
videoFpsListeners.forEach { listener ->
runCatching { listener(fps) }
}
}
},
)
decoderMap[tag] = decoder
replayBootstrapPackets(decoder)
}
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
if (snapshot.isEmpty()) {
return
}
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}")
snapshot.forEach { packet ->
runCatching {
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
}
}
}
private fun cacheBootstrapPacket(packet: ScrcpySessionManager.VideoPacket) {
val cached = CachedPacket(
data = packet.data.copyOf(),
ptsUs = packet.ptsUs,
isConfig = packet.isConfig,
isKeyFrame = packet.isKeyFrame,
)
synchronized(bootstrapLock) {
while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) {
bootstrapPackets.removeFirst()
}
bootstrapPackets.addLast(cached)
}
}
private fun ensureVideoConsumerAttached() {
sessionManager.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(TAG, "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}")
}
decoderMap.forEach { (tag, decoder) ->
if (!surfaceIdentityMap.containsKey(tag)) {
return@forEach
}
runCatching {
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
}
}
}
}
private fun releaseAllDecoders() {
decoderMap.values.forEach { decoder ->
runCatching { decoder.release() }
}
decoderMap.clear()
}
private fun <T> ioCall(task: () -> T): T {
return try {
executor.submit<T> { task() }.get()
} catch (e: ExecutionException) {
val cause = e.cause
if (cause is Exception) {
throw cause
}
throw RuntimeException(cause ?: e)
}
}
private fun ioExecute(task: () -> Unit) {
executor.execute(task)
}
}
data class ScrcpyStartRequest(
val serverAsset: String,
val customServerUri: String?,
val serverVersion: String,
val serverRemotePath: String,
val maxSize: Int,
val videoBitRate: Int,
val videoCodec: String = "h264",
val audio: Boolean = true,
val audioCodec: String = "opus",
val audioBitRate: Int = 128_000,
val maxFps: Float = 0f,
val noControl: Boolean = false,
val videoEncoder: String = "",
val videoCodecOptions: String = "",
val audioEncoder: String = "",
val audioCodecOptions: String = "",
val audioDup: Boolean = false,
val audioSource: String = "",
val videoSource: String = "display",
val cameraId: String = "",
val cameraFacing: String = "",
val cameraSize: String = "",
val cameraAr: String = "",
val cameraFps: Int = 0,
val cameraHighSpeed: Boolean = false,
val noAudioPlayback: Boolean = false,
val requireAudio: Boolean = false,
val turnScreenOff: Boolean = false,
val noVideo: Boolean = false,
val newDisplay: String = "",
val displayId: Int? = null,
val crop: String = "",
)
data class ScrcpyEncoderLists(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String> = emptyMap(),
val audioEncoderTypes: Map<String, String> = emptyMap(),
val rawOutput: String = "",
)
data class ScrcpyCameraSizeLists(
val sizes: List<String>,
val rawOutput: String = "",
)
data class ScrcpySessionInfo(
val width: Int,
val height: Int,
val deviceName: String,
val codec: String,
val controlEnabled: Boolean,
)
object AndroidKeycodes {
const val HOME = 3
const val BACK = 4
const val POWER = 26
const val APP_SWITCH = 187
const val VOLUME_UP = 24
const val VOLUME_DOWN = 25
}
object MotionActions {
const val DOWN = 0
const val UP = 1
const val MOVE = 2
const val CANCEL = 3
const val POINTER_DOWN = 5
const val POINTER_UP = 6
}

View File

@@ -0,0 +1,74 @@
package io.github.miuzarte.scrcpyforandroid.constants
object AppDefaults {
const val MaxEventLogLines = 512
const val DefaultAdbPort = 5555
// Devices
const val DefaultQuickConnectInput = ""
const val DefaultPairHost = ""
const val DefaultServerRemotePathInput = ""
const val DefaultAdbKeyNameInput = ""
const val DefaultPairPort = ""
const val DefaultPairCode = ""
const val DefaultAudioEnabled = true
const val DefaultAudioCodec = "opus"
const val DefaultAudioBitRateKbps = 128
const val DefaultVideoCodec = "h264"
const val DefaultBitRateMbps = 8f
const val DefaultBitRateInput = "8.0"
const val DefaultTurnScreenOff = false
const val DefaultNoControl = false
const val DefaultNoVideo = false
const val DefaultVideoSourcePreset = "display"
const val DefaultDisplayIdInput = ""
const val DefaultCameraIdInput = ""
const val DefaultCameraFacingPreset = ""
const val DefaultCameraSizePreset = ""
const val DefaultCameraSizeCustom = ""
const val DefaultCameraArInput = ""
const val DefaultCameraFpsInput = ""
const val DefaultCameraHighSpeed = false
const val DefaultAudioSourcePreset = "output"
const val DefaultAudioSourceCustom = ""
const val DefaultAudioDup = false
const val DefaultNoAudioPlayback = false
const val DefaultRequireAudio = false
const val DefaultMaxSizeInput = ""
const val DefaultMaxFpsInput = ""
const val DefaultVideoEncoder = ""
const val DefaultVideoCodecOptions = ""
const val DefaultAudioEncoder = ""
const val DefaultAudioCodecOptions = ""
const val DefaultNewDisplayWidth = ""
const val DefaultNewDisplayHeight = ""
const val DefaultNewDisplayDpi = ""
const val DefaultCropWidth = ""
const val DefaultCropHeight = ""
const val DefaultCropX = ""
const val DefaultCropY = ""
// Settings
const val DefaultThemeBaseIndex = 0
const val DefaultMonetEnabled = false
const val DefaultFullscreenDebugInfoEnabled = false
const val DefaultDevicePreviewCardHeightDp = 320
const val DefaultKeepScreenOnWhenStreamingEnabled = false
const val DefaultVirtualButtonsOutside = "more,home,back"
const val DefaultVirtualButtonsInMore = "app_switch,menu,notification,volume_up,volume_down,volume_mute,power,screenshot"
const val DefaultServerRemotePath = "/data/local/tmp/scrcpy-server.jar"
const val DefaultAdbKeyName = "scrcpy"
}

View File

@@ -0,0 +1,58 @@
package io.github.miuzarte.scrcpyforandroid.constants
object AppPreferenceKeys {
const val PrefsName = "scrcpy_app_prefs"
const val NativeAdbKeyPrefsName = "nativecore_adb_rsa"
const val NativeAdbPrivateKey = "priv"
const val ThemeBaseIndex = "theme_base_index"
const val MonetEnabled = "monet_enabled"
const val FullscreenDebugInfoEnabled = "fullscreen_debug_info_enabled"
const val DevicePreviewCardHeightDp = "device_preview_card_height_dp"
const val KeepScreenOnWhenStreamingEnabled = "keep_screen_on_when_streaming_enabled"
const val VirtualButtonsOutside = "virtual_buttons_outside"
const val VirtualButtonsInMore = "virtual_buttons_in_more"
const val CustomServerUri = "custom_server_uri"
const val ServerRemotePath = "server_remote_path"
const val VideoCodec = "video_codec"
const val AudioEnabled = "audio_enabled"
const val AudioCodec = "audio_codec"
const val PairHost = "pair_host"
const val PairPort = "pair_port"
const val PairCode = "pair_code"
const val QuickConnectInput = "quick_connect_input"
const val BitRateMbps = "bit_rate_mbps"
const val BitRateInput = "bit_rate_input"
const val AudioBitRateKbps = "audio_bit_rate_kbps"
const val MaxSizeInput = "max_size_input"
const val MaxFpsInput = "max_fps_input"
const val NoControl = "no_control"
const val VideoEncoder = "video_encoder"
const val VideoCodecOptions = "video_codec_options"
const val AudioEncoder = "audio_encoder"
const val AudioCodecOptions = "audio_codec_options"
const val AudioDup = "audio_dup"
const val AudioSourcePreset = "audio_source_preset"
const val AudioSourceCustom = "audio_source_custom"
const val VideoSourcePreset = "video_source_preset"
const val CameraIdInput = "camera_id_input"
const val CameraFacingPreset = "camera_facing_preset"
const val CameraSizePreset = "camera_size_preset"
const val CameraSizeCustom = "camera_size_custom"
const val CameraArInput = "camera_ar_input"
const val CameraFpsInput = "camera_fps_input"
const val CameraHighSpeed = "camera_high_speed"
const val NoAudioPlayback = "no_audio_playback"
const val NoVideo = "no_video"
const val RequireAudio = "require_audio"
const val TurnScreenOff = "turn_screen_off"
const val NewDisplayWidth = "new_display_width"
const val NewDisplayHeight = "new_display_height"
const val NewDisplayDpi = "new_display_dpi"
const val DisplayIdInput = "display_id_input"
const val CropWidth = "crop_width"
const val CropHeight = "crop_height"
const val CropX = "crop_x"
const val CropY = "crop_y"
const val AdbKeyName = "adb_key_name"
const val QuickDevices = "quick_devices"
}

View File

@@ -0,0 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.constants
object ScrcpyPresets {
val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)
val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120)
val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)
}

View File

@@ -0,0 +1,14 @@
package io.github.miuzarte.scrcpyforandroid.constants
internal object UiAndroidKeycodes {
const val HOME = 3
const val BACK = 4
const val POWER = 26
const val VOLUME_UP = 24
const val VOLUME_DOWN = 25
const val VOLUME_MUTE = 164
const val MENU = 82
const val NOTIFICATION = 83
const val SYSRQ = 120
const val APP_SWITCH = 187
}

View File

@@ -0,0 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.constants
object UiMotion {
const val PageSwitchDampingRatio = 0.9f
const val PageSwitchStiffness = 700f
}

View File

@@ -0,0 +1,23 @@
package io.github.miuzarte.scrcpyforandroid.constants
import androidx.compose.ui.unit.dp
object UiSpacing {
val Tiny = 2.dp
val Small = 4.dp
val Medium = 8.dp
val PageItem = 12.dp
val Large = 16.dp
val PopupHorizontal = 20.dp
val PageHorizontal = 12.dp
val PageVertical = 12.dp
val SectionTitleLeadingGap = 8.dp
val SectionTitleStart = 12.dp
val SectionTitleTop = 12.dp
val SectionTitleBottom = 6.dp
val FieldLabelBottom = 4.dp
val CardContent = 12.dp
val CardTitle = 16.dp
val BottomContent = 64.dp
val BottomSheetBottom = 16.dp
}

View File

@@ -0,0 +1,32 @@
package io.github.miuzarte.scrcpyforandroid.haptics
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Immutable
data class AppHaptics(
val press: () -> Unit,
val confirm: () -> Unit,
)
@Composable
fun rememberAppHaptics(): AppHaptics {
val hapticFeedback = LocalHapticFeedback.current
val pressAction = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
}
val confirmAction = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
}
return remember {
AppHaptics(
press = { pressAction.value.invoke() },
confirm = { confirmAction.value.invoke() },
)
}
}

View File

@@ -0,0 +1,14 @@
package io.github.miuzarte.scrcpyforandroid.models
internal data class ConnectionTarget(
val host: String,
val port: Int,
)
internal data class DeviceShortcut(
val id: String,
val name: String,
val host: String,
val port: Int,
val online: Boolean,
)

View File

@@ -0,0 +1,161 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import android.view.Surface
class AnnexBDecoder(
width: Int,
height: Int,
outputSurface: Surface,
mimeType: String = MIME_AVC,
sps: ByteArray? = null,
pps: ByteArray? = null,
onOutputSizeChanged: ((width: Int, height: Int) -> Unit)? = null,
onFpsUpdated: ((fps: Float) -> Unit)? = null,
) {
private val codec: MediaCodec = MediaCodec.createDecoderByType(mimeType)
private val bufferInfo = MediaCodec.BufferInfo()
private val outputSizeCallback = onOutputSizeChanged
private val fpsUpdatedCallback = onFpsUpdated
private val decoderMime = mimeType
private var inCount = 0L
private var outCount = 0L
private var fpsWindowStartNs = System.nanoTime()
private var fpsWindowFrameCount = 0
@Volatile
private var released = false
init {
val format = MediaFormat.createVideoFormat(mimeType, width, height)
if (sps != null) {
format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps))
}
if (pps != null) {
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
}
codec.configure(format, outputSurface, null, 0)
codec.start()
}
@Synchronized
fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
if (released) {
return
}
runCatching {
var inputIndex = codec.dequeueInputBuffer(INPUT_TIMEOUT_US)
if (inputIndex < 0 && (isConfig || isKeyFrame)) {
// Retry for critical packets to reduce startup stalls on av1/hevc.
drainOutput()
inputIndex = codec.dequeueInputBuffer(CRITICAL_INPUT_TIMEOUT_US)
}
if (inputIndex >= 0) {
val inBuf = codec.getInputBuffer(inputIndex)
inBuf?.clear()
inBuf?.put(data)
var flags = 0
if (isKeyFrame) {
flags = flags or MediaCodec.BUFFER_FLAG_KEY_FRAME
}
if (isConfig) {
flags = flags or MediaCodec.BUFFER_FLAG_CODEC_CONFIG
}
codec.queueInputBuffer(inputIndex, 0, data.size, ptsUs, flags)
inCount += 1
if (inCount == 1L || inCount % 180L == 0L || isConfig) {
Log.i(TAG, "feed(): mime=$decoderMime in=$inCount size=${data.size} key=$isKeyFrame cfg=$isConfig pts=$ptsUs")
}
} else {
if (isConfig || isKeyFrame) {
Log.w(TAG, "drop critical packet: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig")
}
}
drainOutput()
}.onFailure {
Log.w(TAG, "feed failed: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig", it)
}
}
@Synchronized
fun switchOutputSurface(surface: Surface): Boolean {
if (released) {
return false
}
return runCatching {
codec.setOutputSurface(surface)
true
}.getOrElse { false }
}
@Synchronized
fun release() {
released = true
runCatching { codec.stop() }
runCatching { codec.release() }
}
private fun drainOutput() {
while (true) {
val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
when {
outIndex >= 0 -> {
codec.releaseOutputBuffer(outIndex, true)
outCount += 1
recordOutputFrame()
if (outCount == 1L || outCount % 180L == 0L) {
Log.i(TAG, "drain(): mime=$decoderMime out=$outCount")
}
}
outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
notifyOutputSize(codec.outputFormat)
continue
}
else -> return
}
}
}
private fun notifyOutputSize(format: MediaFormat) {
val rawWidth = format.getInteger(MediaFormat.KEY_WIDTH)
val rawHeight = format.getInteger(MediaFormat.KEY_HEIGHT)
val width = if (format.containsKey("crop-right") && format.containsKey("crop-left")) {
format.getInteger("crop-right") - format.getInteger("crop-left") + 1
} else {
rawWidth
}
val height = if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) {
format.getInteger("crop-bottom") - format.getInteger("crop-top") + 1
} else {
rawHeight
}
if (width > 0 && height > 0) {
outputSizeCallback?.invoke(width, height)
}
}
private fun recordOutputFrame() {
fpsWindowFrameCount += 1
val nowNs = System.nanoTime()
val elapsedNs = nowNs - fpsWindowStartNs
if (elapsedNs < FPS_WINDOW_NS) {
return
}
val fps = (fpsWindowFrameCount * 1_000_000_000f) / elapsedNs.toFloat()
fpsUpdatedCallback?.invoke(fps)
fpsWindowStartNs = nowNs
fpsWindowFrameCount = 0
}
companion object {
private const val TAG = "AnnexBDecoder"
private const val MIME_AVC = "video/avc"
private const val INPUT_TIMEOUT_US = 10_000L
private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L
private const val OUTPUT_TIMEOUT_US = 0L
private const val FPS_WINDOW_NS = 1_000_000_000L
}
}

View File

@@ -0,0 +1,506 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.util.Base64
import android.util.Log
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.InetSocketAddress
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.Signature
import java.security.interfaces.RSAPrivateCrtKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPublicKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
internal class DirectAdbTransport(private val context: Context) {
private val keys: Pair<PrivateKey, ByteArray> by lazy { loadOrCreate() }
val privateKey: PrivateKey get() = keys.first
val publicKeyX509: ByteArray get() = keys.second
@Volatile var keyName: String = AppDefaults.DefaultAdbKeyName
fun connect(host: String, port: Int): DirectAdbConnection {
Log.i(TAG, "connect(): opening direct adbd transport to $host:$port")
val conn = DirectAdbConnection(host, port, privateKey, publicKeyX509, keyName.ifBlank { AppDefaults.DefaultAdbKeyName })
conn.handshake()
Log.i(TAG, "connect(): handshake success for $host:$port")
return conn
}
private fun loadOrCreate(): Pair<PrivateKey, ByteArray> {
val prefs = context.getSharedPreferences(AppPreferenceKeys.NativeAdbKeyPrefsName, Context.MODE_PRIVATE)
val privB64 = prefs.getString(AppPreferenceKeys.NativeAdbPrivateKey, null)
if (privB64 != null) {
try {
val kf = KeyFactory.getInstance("RSA")
val priv = kf.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(privB64, Base64.DEFAULT)))
val pub = derivePublicX509(priv)
Log.i(TAG, "loadOrCreate(): loaded persisted RSA key pair, fp=${fingerprint(pub)}")
return Pair(priv, pub)
} catch (e: Exception) {
Log.w(TAG, "loadOrCreate(): failed to load persisted key, regenerating", e)
}
}
val kpg = KeyPairGenerator.getInstance("RSA")
kpg.initialize(2048)
val kp = kpg.generateKeyPair()
prefs.edit { putString(AppPreferenceKeys.NativeAdbPrivateKey, Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)) }
Log.i(TAG, "loadOrCreate(): generated new RSA key pair, fp=${fingerprint(kp.public.encoded)}")
return Pair(kp.private, kp.public.encoded)
}
private fun derivePublicX509(privateKey: PrivateKey): ByteArray {
val rsa = privateKey as? RSAPrivateCrtKey
?: throw IllegalStateException("Expected RSAPrivateCrtKey but was ${privateKey.javaClass.name}")
val kf = KeyFactory.getInstance("RSA")
val public = kf.generatePublic(RSAPublicKeySpec(rsa.modulus, rsa.publicExponent))
return public.encoded
}
private fun fingerprint(publicX509: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(publicX509)
return digest.joinToString(":") { b -> "%02x".format(b) }
}
companion object {
private const val TAG = "DirectAdbTransport"
}
}
internal class DirectAdbConnection(
val host: String,
val port: Int,
private val privateKey: PrivateKey,
private val publicKeyX509: ByteArray,
private val keyName: String = AppDefaults.DefaultAdbKeyName,
) : AutoCloseable {
private val sha1DigestInfoPrefix = byteArrayOf(
0x30,
0x21,
0x30,
0x09,
0x06,
0x05,
0x2B,
0x0E,
0x03,
0x02,
0x1A,
0x05,
0x00,
0x04,
0x14,
)
private val socket = Socket()
private lateinit var rawIn: BufferedInputStream
private lateinit var rawOut: OutputStream
private val nextLocalId = AtomicInteger(1)
private val streams = ConcurrentHashMap<Int, AdbSocketStream>()
@Volatile private var closed = false
private var readerThread: Thread? = null
companion object {
private const val TAG = "DirectAdbConnection"
private const val A_CNXN = 0x4e584e43
private const val A_AUTH = 0x48545541
private const val A_OPEN = 0x4e45504f
private const val A_OKAY = 0x59414b4f
private const val A_CLSE = 0x45534c43
private const val A_WRTE = 0x45545257
private const val AUTH_TOKEN = 1
private const val AUTH_SIGNATURE = 2
private const val AUTH_RSAPUBLICKEY = 3
private const val VERSION = 0x01000001
private const val MAX_PAYLOAD = 256 * 1024
}
fun handshake() {
Log.i(TAG, "handshake(): tcp connect -> $host:$port")
socket.connect(InetSocketAddress(host, port), 10_000)
socket.tcpNoDelay = true
socket.soTimeout = 60_000
rawIn = BufferedInputStream(socket.getInputStream(), 65_536)
rawOut = socket.getOutputStream()
sendMsg(A_CNXN, VERSION, MAX_PAYLOAD, "host::\u0000".toByteArray(Charsets.UTF_8))
val first = recvMsg()
when (first.command) {
A_CNXN -> Unit
A_AUTH -> {
if (first.arg0 != AUTH_TOKEN) {
throw IOException("ADB: expected AUTH_TOKEN, got type=${first.arg0}")
}
sendMsg(A_AUTH, AUTH_SIGNATURE, 0, signToken(first.data))
val afterSign = recvMsg()
when (afterSign.command) {
A_CNXN -> Unit
A_AUTH -> {
if (afterSign.arg0 != AUTH_TOKEN) {
throw IOException("ADB: expected AUTH_TOKEN after rejected signature, got type=${afterSign.arg0}")
}
sendMsg(A_AUTH, AUTH_RSAPUBLICKEY, 0, buildAdbPubKey())
val cnxn = recvMsg()
if (cnxn.command != A_CNXN) {
throw IOException("ADB: connection rejected. Please accept the authorisation dialog on the target device.")
}
}
else -> throw IOException("ADB: unexpected message 0x${afterSign.command.toString(16)} after AUTH_SIGNATURE")
}
}
else -> throw IOException("ADB: unexpected initial message 0x${first.command.toString(16)}")
}
socket.soTimeout = 0
readerThread = thread(isDaemon = true, name = "adb-reader-$host:$port") { readLoop() }
}
fun openStream(service: String): AdbSocketStream {
val localId = nextLocalId.getAndIncrement()
val stream = AdbSocketStream(localId) { cmd, a0, a1, d -> sendMsg(cmd, a0, a1, d) }
streams[localId] = stream
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
try {
stream.awaitOpen(15_000)
} catch (e: Exception) {
streams.remove(localId)
throw e
}
return stream
}
fun shell(command: String): String =
openStream("shell:$command").use { it.inputStream.readBytes().toString(Charsets.UTF_8) }
fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) {
openStream("sync:").use { stream ->
val out = stream.outputStream
val inp = stream.inputStream
val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8)
out.write("SEND".toByteArray(Charsets.US_ASCII))
out.writeIntLE(pathMode.size)
out.write(pathMode)
val chunkBuf = ByteArray(64 * 1024)
var offset = 0
while (offset < data.size) {
val len = minOf(chunkBuf.size, data.size - offset)
out.write("DATA".toByteArray(Charsets.US_ASCII))
out.writeIntLE(len)
out.write(data, offset, len)
offset += len
}
out.write("DONE".toByteArray(Charsets.US_ASCII))
out.writeIntLE((System.currentTimeMillis() / 1000).toInt())
out.flush()
val idBuf = ByteArray(4).also { inp.readExact(it) }
val msgLen = inp.readIntLE()
val id = String(idBuf, Charsets.US_ASCII)
if (id != "OKAY") {
val msg = if (msgLen > 0) ByteArray(msgLen).also { inp.readExact(it) }
.toString(Charsets.UTF_8) else id
throw IOException("ADB push failed: $msg")
} else if (msgLen > 0) {
inp.skip(msgLen.toLong())
}
}
}
fun isAlive(): Boolean = !closed && !socket.isClosed && socket.isConnected
override fun close() {
if (!closed) {
closed = true
streams.values.forEach { runCatching { it.forceClose() } }
streams.clear()
runCatching { socket.close() }
runCatching { readerThread?.interrupt() }
}
}
private fun readLoop() {
try {
while (!closed) {
val msg = recvMsg()
when (msg.command) {
A_OKAY -> streams[msg.arg1]?.onRemoteOkay(msg.arg0)
A_WRTE -> {
val s = streams[msg.arg1]
if (s != null) {
s.onData(msg.data)
sendMsg(A_OKAY, msg.arg1, msg.arg0)
} else {
sendMsg(A_CLSE, 0, msg.arg0)
}
}
A_CLSE -> streams.remove(msg.arg1)?.forceClose()
A_OPEN -> sendMsg(A_CLSE, 0, msg.arg0)
}
}
} catch (_: Exception) {
if (!closed) {
closed = true
streams.values.forEach { runCatching { it.forceClose() } }
}
}
}
@Synchronized
private fun sendMsg(command: Int, arg0: Int = 0, arg1: Int = 0, data: ByteArray = ByteArray(0)) {
val crc = data.fold(0L) { acc, b -> acc + (b.toLong() and 0xFF) }.toInt()
val header = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN)
.putInt(command).putInt(arg0).putInt(arg1)
.putInt(data.size).putInt(crc).putInt(command xor -1)
.array()
rawOut.write(header)
if (data.isNotEmpty()) rawOut.write(data)
rawOut.flush()
}
private fun recvMsg(): AdbMsg {
val h = ByteArray(24)
rawIn.readExact(h)
val buf = ByteBuffer.wrap(h).order(ByteOrder.LITTLE_ENDIAN)
val command = buf.int
val arg0 = buf.int
val arg1 = buf.int
val dataLen = buf.int
buf.int
buf.int
val data = if (dataLen > 0) ByteArray(dataLen).also { rawIn.readExact(it) } else ByteArray(0)
return AdbMsg(command, arg0, arg1, data)
}
private data class AdbMsg(val command: Int, val arg0: Int, val arg1: Int, val data: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AdbMsg
if (command != other.command) return false
if (arg0 != other.arg0) return false
if (arg1 != other.arg1) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = command
result = 31 * result + arg0
result = 31 * result + arg1
result = 31 * result + data.contentHashCode()
return result
}
}
private fun signToken(token: ByteArray): ByteArray {
// adbd expects RSA signature over SHA-1 digest info where token is the digest payload.
val payload = ByteArray(sha1DigestInfoPrefix.size + token.size)
sha1DigestInfoPrefix.copyInto(payload, destinationOffset = 0)
token.copyInto(payload, destinationOffset = sha1DigestInfoPrefix.size)
return Signature.getInstance("NONEwithRSA").apply {
initSign(privateKey)
update(payload)
}.sign()
}
private fun buildAdbPubKey(): ByteArray {
val kf = KeyFactory.getInstance("RSA")
val pub = kf.generatePublic(X509EncodedKeySpec(publicKeyX509))
val spec = kf.getKeySpec(pub, RSAPublicKeySpec::class.java)
val adbKeyBytes = encodeAdbPublicKey(spec.modulus, spec.publicExponent.toInt())
return "${Base64.encodeToString(adbKeyBytes, Base64.NO_WRAP)} $keyName\u0000"
.toByteArray(Charsets.UTF_8)
}
private fun encodeAdbPublicKey(modulus: BigInteger, exponent: Int): ByteArray {
val words = 64
val bytes = 256
val two32 = BigInteger.ONE.shiftLeft(32)
val mask32 = two32.subtract(BigInteger.ONE)
fun toBigEndianPadded(n: BigInteger): ByteArray {
val raw = n.toByteArray()
val arr = ByteArray(bytes)
val src = if (raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw
src.copyInto(arr, destinationOffset = bytes - src.size)
return arr
}
val modBE = toBigEndianPadded(modulus)
// n0 is the least-significant 32 bits of modulus; for RSA modulus this must be odd.
val n0 = modulus.and(mask32)
val n0inv = n0.modInverse(two32).negate().mod(two32).toInt()
val r = BigInteger.ONE.shiftLeft(bytes * 8)
val rrBE = toBigEndianPadded(r.multiply(r).mod(modulus))
val buf = ByteBuffer.allocate(4 + 4 + bytes + bytes + 4).order(ByteOrder.LITTLE_ENDIAN)
buf.putInt(words)
buf.putInt(n0inv)
for (i in words - 1 downTo 0) {
val o = i * 4
buf.put(modBE[o + 3]); buf.put(modBE[o + 2]); buf.put(modBE[o + 1]); buf.put(modBE[o])
}
for (i in words - 1 downTo 0) {
val o = i * 4
buf.put(rrBE[o + 3]); buf.put(rrBE[o + 2]); buf.put(rrBE[o + 1]); buf.put(rrBE[o])
}
buf.putInt(exponent)
return buf.array()
}
}
internal class AdbSocketStream(
val localId: Int,
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
) : Closeable {
companion object {
private const val A_WRTE = 0x45545257
private const val A_CLSE = 0x45534c43
}
@Volatile var remoteId: Int = 0
@Volatile var closed: Boolean = false
private val latch = CountDownLatch(1)
private val latchOk = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<Any>()
private object EndOfStreamMarker
val inputStream: InputStream = InStream()
val outputStream: OutputStream = OutStream()
internal fun onRemoteOkay(remote: Int) {
if (remoteId == 0) {
remoteId = remote
latchOk.set(true)
latch.countDown()
}
}
internal fun onData(data: ByteArray) {
if (!closed) queue.offer(data)
}
internal fun forceClose() {
closed = true
queue.offer(EndOfStreamMarker)
latch.countDown()
}
fun awaitOpen(timeoutMs: Long) {
if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) {
throw IOException("ADB stream open timed out (localId=$localId)")
}
if (!latchOk.get()) {
throw IOException("ADB stream rejected by device (localId=$localId)")
}
}
override fun close() {
if (!closed) {
closed = true
val r = remoteId
if (r != 0) runCatching { sender(A_CLSE, localId, r, ByteArray(0)) }
queue.offer(EndOfStreamMarker)
}
}
private inner class InStream : InputStream() {
private var chunk: ByteArray? = null
private var off = 0
override fun read(): Int {
val b = ByteArray(1)
return if (read(b, 0, 1) == -1) -1 else (b[0].toInt() and 0xFF)
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
while (true) {
val c = chunk
if (c != null && this.off < c.size) {
val n = minOf(len, c.size - this.off)
c.copyInto(b, off, this.off, this.off + n)
this.off += n
return n
}
chunk = null
this.off = 0
val next = queue.take()
if (next === EndOfStreamMarker) {
return -1
}
chunk = next as ByteArray
}
}
override fun available(): Int = chunk?.let { it.size - off } ?: 0
}
private inner class OutStream : OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()))
override fun write(b: ByteArray, off: Int, len: Int) {
if (closed) throw IOException("ADB stream closed")
if (len == 0) return
sender(A_WRTE, localId, remoteId, b.copyOfRange(off, off + len))
}
override fun flush() {}
}
}
private fun InputStream.readExact(buf: ByteArray) {
var off = 0
while (off < buf.size) {
val n = read(buf, off, buf.size - off)
if (n < 0) throw EOFException("readExact: expected ${buf.size} bytes, got $off")
off += n
}
}
private fun InputStream.readIntLE(): Int {
val b0 = read()
val b1 = read()
val b2 = read()
val b3 = read()
if (b3 < 0) throw EOFException("readIntLE: EOF")
return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
}
private fun OutputStream.writeIntLE(v: Int) {
write(v and 0xFF)
write(v shr 8 and 0xFF)
write(v shr 16 and 0xFF)
write(v shr 24 and 0xFF)
}

View File

@@ -0,0 +1,84 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.util.Log
import java.nio.file.Path
class NativeAdbService(appContext: Context) {
private val transport = DirectAdbTransport(appContext)
@Volatile private var connection: DirectAdbConnection? = null
@Volatile private var connectedHost: String? = null
@Volatile private var connectedPort: Int? = null
var keyName: String
get() = transport.keyName
set(value) { transport.keyName = value }
@Synchronized
fun pair(host: String, port: Int, pairingCode: String): Boolean {
throw UnsupportedOperationException(
"Wireless pairing is not yet implemented. Please enable TCP ADB via USB first.",
)
}
@Synchronized
fun connect(host: String, port: Int): Boolean {
Log.i(TAG, "connect(): host=$host port=$port")
val existing = connection
if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) {
return true
}
disconnect()
try {
val conn = transport.connect(host, port)
connection = conn
connectedHost = host
connectedPort = port
return true
} catch (e: Exception) {
Log.e(TAG, "connect(): failed host=$host port=$port", e)
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e)
}
}
@Synchronized
fun disconnect() {
runCatching { connection?.close() }
connection = null
connectedHost = null
connectedPort = null
}
@Synchronized
fun isConnected(): Boolean = connection?.isAlive() == true
@Synchronized
fun shell(command: String): String = requireConnection().shell(command)
@Synchronized
internal fun openShellStream(command: String): AdbSocketStream =
requireConnection().openStream("shell:$command")
@Synchronized
fun push(localPath: Path, remotePath: String) {
requireConnection().push(localPath.toFile().readBytes(), remotePath)
}
@Synchronized
internal fun openAbstractSocket(name: String): AdbSocketStream =
requireConnection().openStream("localabstract:$name")
@Synchronized
fun close() = disconnect()
private fun requireConnection(): DirectAdbConnection {
return connection?.takeIf { it.isAlive() }
?: throw IllegalStateException("ADB not connected")
}
companion object {
private const val TAG = "NativeAdbService"
}
}

View File

@@ -0,0 +1,201 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Decodes and plays scrcpy audio stream (OPUS / AAC / FLAC / RAW PCM).
*
* All [feedPacket] calls are expected from a single background thread.
* [release] may be called from any thread.
*/
class ScrcpyAudioPlayer(private val codecId: Int) {
private var mediaCodec: MediaCodec? = null
private var audioTrack: AudioTrack? = null
private val bufferInfo = MediaCodec.BufferInfo()
@Volatile private var prepared = false
@Volatile private var released = false
private var packetCount = 0L
fun feedPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
if (released) return
if (isConfig) {
Log.i(TAG, "feedPacket(): config packet size=${data.size} codec=0x${codecId.toUInt().toString(16)}")
when (codecId) {
AUDIO_CODEC_OPUS -> prepareOpus(data)
AUDIO_CODEC_AAC -> prepareAac(data)
AUDIO_CODEC_FLAC -> prepareFlac(data)
// RAW has no config packet
}
return
}
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}")
}
if (codecId == AUDIO_CODEC_RAW) {
ensureRawAudioTrack()?.let { track ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
track.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
} else {
track.write(data, 0, data.size)
}
}
return
}
if (!prepared) return
val codec = mediaCodec ?: return
val inputIdx = codec.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (inputIdx >= 0) {
val buf = codec.getInputBuffer(inputIdx) ?: return
buf.clear()
buf.put(data)
codec.queueInputBuffer(inputIdx, 0, data.size, ptsUs, 0)
}
drainOutput(codec)
}
// OpusHead bytes (already extracted by server's fixOpusConfigPacket)
private fun prepareOpus(opusHead: ByteArray) {
if (prepared || released) return
runCatching {
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, SAMPLE_RATE, CHANNELS)
format.setByteBuffer("csd-0", ByteBuffer.wrap(opusHead))
// pre-skip field: 2 bytes LE at offset 10 of the OpusHead
if (opusHead.size >= 12) {
val preSkip = ((opusHead[11].toInt() and 0xFF) shl 8) or (opusHead[10].toInt() and 0xFF)
val codecDelayNs = preSkip.toLong() * 1_000_000_000L / SAMPLE_RATE
format.setByteBuffer("csd-1", longBuffer(codecDelayNs))
format.setByteBuffer("csd-2", longBuffer(OPUS_SEEK_PREROLL_NS))
}
startCodecAndTrack(format)
}.onFailure { Log.w(TAG, "prepareOpus failed", it) }
}
private fun prepareAac(aacConfig: ByteArray) {
if (prepared || released) return
runCatching {
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, CHANNELS)
format.setByteBuffer("csd-0", ByteBuffer.wrap(aacConfig))
startCodecAndTrack(format)
}.onFailure { Log.w(TAG, "prepareAac failed", it) }
}
private fun prepareFlac(flacConfig: ByteArray) {
if (prepared || released) return
runCatching {
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_FLAC, SAMPLE_RATE, CHANNELS)
if (flacConfig.isNotEmpty()) {
format.setByteBuffer("csd-0", ByteBuffer.wrap(flacConfig))
}
startCodecAndTrack(format)
}.onFailure { Log.w(TAG, "prepareFlac failed", it) }
}
private fun startCodecAndTrack(format: MediaFormat) {
val mime = format.getString(MediaFormat.KEY_MIME)!!
val codec = MediaCodec.createDecoderByType(mime)
codec.configure(format, null, null, 0)
val track = buildAudioTrack()
codec.start()
track.play()
mediaCodec = codec
audioTrack = track
prepared = true
Log.i(TAG, "audio player started: mime=$mime sampleRate=$SAMPLE_RATE ch=$CHANNELS")
}
private fun ensureRawAudioTrack(): AudioTrack? {
if (released) return null
if (audioTrack == null) {
val track = buildAudioTrack()
track.play()
audioTrack = track
prepared = true
}
return audioTrack
}
private fun buildAudioTrack(): AudioTrack {
val minBuf = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(1)
return AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build(),
(minBuf * 4).coerceAtLeast(65536),
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE,
)
}
private fun drainOutput(codec: MediaCodec) {
val track = audioTrack ?: return
var idx = codec.dequeueOutputBuffer(bufferInfo, 0L)
while (idx >= 0) {
val outBuf = codec.getOutputBuffer(idx) ?: break
val size = bufferInfo.size
if (size > 0) {
val pcm = ByteArray(size)
outBuf.position(bufferInfo.offset)
outBuf.get(pcm)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
track.write(pcm, 0, size, AudioTrack.WRITE_NON_BLOCKING)
} else {
track.write(pcm, 0, size)
}
}
codec.releaseOutputBuffer(idx, false)
idx = codec.dequeueOutputBuffer(bufferInfo, 0L)
}
}
fun release() {
if (released) return
released = true
prepared = false
runCatching { mediaCodec?.stop() }
runCatching { mediaCodec?.release() }
runCatching { audioTrack?.stop() }
runCatching { audioTrack?.release() }
mediaCodec = null
audioTrack = null
}
private fun longBuffer(value: Long): ByteBuffer =
ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply { putLong(value); flip() }
companion object {
private const val TAG = "ScrcpyAudioPlayer"
const val AUDIO_CODEC_OPUS = 0x6f707573
const val AUDIO_CODEC_AAC = 0x00616163
const val AUDIO_CODEC_FLAC = 0x666c6163
const val AUDIO_CODEC_RAW = 0x00726177
private const val SAMPLE_RATE = 48000
private const val CHANNELS = 2
private const val CODEC_TIMEOUT_US = 10_000L
private const val OPUS_SEEK_PREROLL_NS = 80_000_000L // 80 ms
}
}

View File

@@ -0,0 +1,676 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSpinner
import top.yukonga.miuix.kmp.extra.SuperSwitch
private val AUDIO_SOURCE_OPTIONS = listOf(
"output" to "output",
"playback" to "playback",
"mic" to "mic",
"mic-unprocessed" to "mic-unprocessed",
"mic-camcorder" to "mic-camcorder",
"mic-voice-recognition" to "mic-voice-recognition",
"mic-voice-communication" to "mic-voice-communication",
"voice-call" to "voice-call",
"voice-call-uplink" to "voice-call-uplink",
"voice-call-downlink" to "voice-call-downlink",
"voice-performance" to "voice-performance",
"custom" to "自定义",
)
private val VIDEO_SOURCE_OPTIONS = listOf(
"display" to "display",
"camera" to "camera",
)
private val CAMERA_FACING_OPTIONS = listOf(
"" to "默认",
"front" to "front",
"back" to "back",
"external" to "external",
)
private val CAMERA_FPS_PRESETS = listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)
@Composable
internal fun AdvancedConfigPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
snackbarHostState: SnackbarHostState,
sessionStarted: Boolean,
audioEnabled: Boolean,
noControl: Boolean,
onNoControlChange: (Boolean) -> Unit,
audioDup: Boolean,
onAudioDupChange: (Boolean) -> Unit,
audioSourcePreset: String,
onAudioSourcePresetChange: (String) -> Unit,
audioSourceCustom: String,
onAudioSourceCustomChange: (String) -> Unit,
videoSourcePreset: String,
onVideoSourcePresetChange: (String) -> Unit,
cameraIdInput: String,
onCameraIdInputChange: (String) -> Unit,
cameraFacingPreset: String,
onCameraFacingPresetChange: (String) -> Unit,
cameraSizePreset: String,
onCameraSizePresetChange: (String) -> Unit,
cameraSizeCustom: String,
onCameraSizeCustomChange: (String) -> Unit,
cameraSizeDropdownItems: List<String>,
cameraSizeIndex: Int,
cameraArInput: String,
onCameraArInputChange: (String) -> Unit,
cameraFpsInput: String,
onCameraFpsInputChange: (String) -> Unit,
cameraHighSpeed: Boolean,
onCameraHighSpeedChange: (Boolean) -> Unit,
noAudioPlayback: Boolean,
onNoAudioPlaybackChange: (Boolean) -> Unit,
noVideo: Boolean,
onNoVideoChange: (Boolean) -> Unit,
requireAudio: Boolean,
onRequireAudioChange: (Boolean) -> Unit,
turnScreenOff: Boolean,
onTurnScreenOffChange: (Boolean) -> Unit,
maxSizeInput: String,
onMaxSizeInputChange: (String) -> Unit,
maxFpsInput: String,
onMaxFpsInputChange: (String) -> Unit,
videoEncoderDropdownItems: List<String>,
videoEncoderTypeMap: Map<String, String>,
videoEncoderIndex: Int,
onVideoEncoderChange: (String) -> Unit,
videoCodecOptions: String,
onVideoCodecOptionsChange: (String) -> Unit,
audioEncoderDropdownItems: List<String>,
audioEncoderTypeMap: Map<String, String>,
audioEncoderIndex: Int,
onAudioEncoderChange: (String) -> Unit,
audioCodecOptions: String,
onAudioCodecOptionsChange: (String) -> Unit,
onRefreshEncoders: () -> Unit,
onRefreshCameraSizes: () -> Unit,
newDisplayWidth: String,
onNewDisplayWidthChange: (String) -> Unit,
newDisplayHeight: String,
onNewDisplayHeightChange: (String) -> Unit,
newDisplayDpi: String,
onNewDisplayDpiChange: (String) -> Unit,
displayIdInput: String,
onDisplayIdInputChange: (String) -> Unit,
cropWidth: String,
onCropWidthChange: (String) -> Unit,
cropHeight: String,
onCropHeightChange: (String) -> Unit,
cropX: String,
onCropXChange: (String) -> Unit,
cropY: String,
onCropYChange: (String) -> Unit,
) {
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val maxSizePresetIndex = presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize)
val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS)
val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second }
val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { it.first == audioSourcePreset }.let { if (it >= 0) it else 0 }
val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second }
val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { it.first == videoSourcePreset }.let { if (it >= 0) it else 0 }
val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second }
val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { it.first == cameraFacingPreset }.let { if (it >= 0) it else 0 }
val cameraFpsPresetIndex = presetIndexFromInputForAdvancedPage(cameraFpsInput, CAMERA_FPS_PRESETS)
val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName ->
if (encoderName == "默认") {
SpinnerEntry(title = encoderName)
} else {
val type = resolveEncoderTypeLabel(videoEncoderTypeMap[encoderName])
SpinnerEntry(
title = encoderName,
summary = type.ifBlank { null },
)
}
}
val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName ->
if (encoderName == "默认") {
SpinnerEntry(title = encoderName)
} else {
val type = resolveEncoderTypeLabel(audioEncoderTypeMap[encoderName])
SpinnerEntry(
title = encoderName,
summary = type.ifBlank { null },
)
}
}
// 高级参数
AppPageLazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
Card {
SuperSwitch(
title = "启动后关闭屏幕",
summary = "--turn-screen-off",
checked = turnScreenOff,
onCheckedChange = { value ->
onTurnScreenOffChange(value)
if (value) scope.launch {
// github.com/Genymobile/scrcpy/issues/3376
// github.com/Genymobile/scrcpy/issues/4587
// github.com/Genymobile/scrcpy/issues/5676
snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半")
}
},
enabled = !sessionStarted && !noControl,
)
SuperSwitch(
title = "禁用控制",
summary = "--no-control",
checked = noControl,
onCheckedChange = onNoControlChange,
enabled = !sessionStarted,
)
SuperSwitch(
title = "禁用视频",
summary = "--no-video",
checked = noVideo,
onCheckedChange = onNoVideoChange,
enabled = !sessionStarted,
)
}
}
item {
Card {
SuperDropdown(
title = "视频来源",
summary = "--video-source",
items = videoSourceItems,
selectedIndex = videoSourceIndex,
onSelectedIndexChange = { index ->
onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first)
},
enabled = !sessionStarted,
)
if (videoSourcePreset == "display") {
TextField(
value = displayIdInput,
onValueChange = onDisplayIdInputChange,
label = "--display-id",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
if (videoSourcePreset == "camera") {
TextField(
value = cameraIdInput,
onValueChange = onCameraIdInputChange,
label = "--camera-id",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperArrow(
title = "重新获取 Camera Sizes",
summary = "--list-camera-sizes",
onClick = onRefreshCameraSizes,
enabled = !sessionStarted,
)
SuperDropdown(
title = "摄像头朝向",
summary = "--camera-facing",
items = cameraFacingItems,
selectedIndex = cameraFacingIndex,
onSelectedIndexChange = { index ->
onCameraFacingPresetChange(CAMERA_FACING_OPTIONS[index].first)
},
enabled = !sessionStarted,
)
SuperDropdown(
title = "摄像头分辨率",
summary = "--camera-size",
items = cameraSizeDropdownItems,
selectedIndex = cameraSizeIndex.coerceIn(0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0)),
onSelectedIndexChange = { index ->
onCameraSizePresetChange(
when (index) {
0 -> ""
cameraSizeDropdownItems.lastIndex -> "custom"
else -> cameraSizeDropdownItems[index]
},
)
},
enabled = !sessionStarted,
)
if (cameraSizePreset == "custom") {
TextField(
value = cameraSizeCustom,
onValueChange = onCameraSizeCustomChange,
label = "--camera-size",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
TextField(
value = cameraArInput,
onValueChange = onCameraArInputChange,
label = "--camera-ar",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperSlide(
title = "摄像头帧率",
summary = "--camera-fps",
value = cameraFpsPresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex)
val preset = CAMERA_FPS_PRESETS[idx]
onCameraFpsInputChange(if (preset == 0) "" else preset.toString())
},
valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(),
steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps",
zeroStateText = "默认",
showUnitWhenZeroState = false,
showKeyPoints = true,
keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() },
displayText = cameraFpsInput,
inputHint = "0 或留空表示默认",
inputInitialValue = cameraFpsInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onCameraFpsInputChange(if (normalized == "0") "" else normalized)
},
)
SuperSwitch(
title = "高帧率模式",
summary = "--camera-high-speed",
checked = cameraHighSpeed,
onCheckedChange = onCameraHighSpeedChange,
enabled = !sessionStarted,
)
}
}
}
item {
Card {
SuperDropdown(
title = "音频来源",
summary = "--audio-source",
items = audioSourceItems,
selectedIndex = audioSourceIndex,
onSelectedIndexChange = { index ->
onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first)
},
enabled = !sessionStarted && audioEnabled,
)
if (audioSourcePreset == "custom") {
TextField(
value = audioSourceCustom,
onValueChange = onAudioSourceCustomChange,
label = "--audio-source",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
SuperSwitch(
title = "音频双路输出",
summary = "--audio-dup",
checked = audioDup,
onCheckedChange = onAudioDupChange,
enabled = !sessionStarted && audioEnabled,
)
SuperSwitch(
title = "仅转发不播放",
summary = "--no-audio-playback",
checked = noAudioPlayback,
onCheckedChange = onNoAudioPlaybackChange,
enabled = !sessionStarted && audioEnabled,
)
SuperSwitch(
title = "音频失败时终止 [TODO]",
summary = "--require-audio",
checked = requireAudio,
onCheckedChange = onRequireAudioChange,
enabled = false,
)
}
}
item {
Card {
SuperSlide(
title = "最大分辨率",
summary = "--max-size",
value = maxSizePresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
val preset = ScrcpyPresets.MaxSize[idx]
onMaxSizeInputChange(if (preset == 0) "" else preset.toString())
},
valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "px",
zeroStateText = "关闭",
showUnitWhenZeroState = false,
showKeyPoints = true,
keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() },
displayText = maxSizeInput,
inputHint = "0 或留空表示关闭",
inputInitialValue = maxSizeInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onMaxSizeInputChange(normalized)
},
)
SuperSlide(
title = "最大帧率",
summary = "--max-fps",
value = maxFpsPresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex)
val preset = ScrcpyPresets.MaxFPS[idx]
onMaxFpsInputChange(if (preset == 0) "" else preset.toString())
},
valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps",
zeroStateText = "关闭",
showUnitWhenZeroState = false,
showKeyPoints = true,
keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() },
displayText = maxFpsInput,
inputHint = "0 或留空表示关闭",
inputInitialValue = maxFpsInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onMaxFpsInputChange(normalized)
},
)
}
}
item {
Card {
SuperArrow(
title = "重新获取编码器列表",
summary = "--list-encoders",
onClick = onRefreshEncoders,
enabled = !sessionStarted,
)
SuperSpinner(
title = "视频编码器",
summary = "--video-encoder",
items = videoEncoderEntries,
selectedIndex = videoEncoderIndex,
onSelectedIndexChange = { index ->
onVideoEncoderChange(if (index == 0) "" else videoEncoderDropdownItems[index])
},
enabled = !sessionStarted,
)
TextField(
value = videoCodecOptions,
onValueChange = onVideoCodecOptionsChange,
label = "--video-codec-options",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperSpinner(
title = "音频编码器",
summary = "--audio-encoder",
items = audioEncoderEntries,
selectedIndex = audioEncoderIndex,
onSelectedIndexChange = { index ->
onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index])
},
enabled = !sessionStarted && audioEnabled,
)
TextField(
value = audioCodecOptions,
onValueChange = onAudioCodecOptionsChange,
label = "--audio-codec-options",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
}
item {
Card {
Text(
text = "--new-display",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(
top = UiSpacing.CardContent,
bottom = UiSpacing.FieldLabelBottom,
),
fontWeight = FontWeight.Medium,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = newDisplayWidth,
onValueChange = onNewDisplayWidthChange,
label = "width",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = newDisplayHeight,
onValueChange = onNewDisplayHeightChange,
label = "height",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = newDisplayDpi,
onValueChange = onNewDisplayDpiChange,
label = "dpi",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
),
modifier = Modifier.weight(1f),
)
}
}
}
item {
Card {
Text(
text = "--crop",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(
top = UiSpacing.CardContent,
bottom = UiSpacing.FieldLabelBottom,
),
fontWeight = FontWeight.Medium,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = cropWidth,
onValueChange = onCropWidthChange,
label = "width",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = cropHeight,
onValueChange = onCropHeightChange,
label = "height",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = cropX,
onValueChange = onCropXChange,
label = "x",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = cropY,
onValueChange = onCropYChange,
label = "y",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
),
modifier = Modifier.weight(1f),
)
}
}
}
}
// TODO: 放进 [AppPageLazyColumn] 里
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}
private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List<Int>): Int {
if (raw.isBlank()) return 0
val value = raw.toIntOrNull() ?: return 0
val exact = presets.indexOf(value)
if (exact >= 0) return exact
val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) }
return nearest?.index ?: 0
}
private fun resolveEncoderTypeLabel(raw: String?): String {
return when (raw?.trim()?.lowercase()) {
"hw" -> "hw"
"sw" -> "sw"
else -> ""
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity
import android.content.pm.ActivityInfo
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import top.yukonga.miuix.kmp.basic.Scaffold
data class FullscreenControlLaunch(
val deviceName: String,
val width: Int,
val height: Int,
val codec: String,
)
@Composable
fun FullscreenControlPage(
launch: FullscreenControlLaunch,
nativeCore: NativeCoreFacade,
virtualButtonsOutside: List<String>,
virtualButtonsInMore: List<String>,
showDebugInfo: Boolean,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val haptics = rememberAppHaptics()
val activity = remember(context) { context as? Activity }
val virtualButtonLayout = remember(virtualButtonsOutside, virtualButtonsInMore) {
VirtualButtonActions.resolveLayout(virtualButtonsOutside, virtualButtonsInMore)
}
val bar = remember(virtualButtonLayout) {
VirtualButtonBar(
outsideActions = virtualButtonLayout.first,
moreActions = virtualButtonLayout.second,
)
}
val initialOrientation = remember(activity) { activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
var session by remember(launch) {
mutableStateOf(
ScrcpySessionInfo(
width = launch.width,
height = launch.height,
deviceName = launch.deviceName.ifBlank { "设备" },
codec = launch.codec.ifBlank { "unknown" },
controlEnabled = true,
),
)
}
var currentFps by remember { mutableFloatStateOf(0f) }
DisposableEffect(activity, session.width, session.height) {
val targetOrientation = if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
}
activity?.requestedOrientation = targetOrientation
onDispose {
activity?.requestedOrientation = initialOrientation
}
}
DisposableEffect(activity) {
val window = activity?.window
if (window != null) {
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
onDispose {
val restoreWindow = activity?.window
if (restoreWindow != null) {
WindowInsetsControllerCompat(restoreWindow, restoreWindow.decorView).show(WindowInsetsCompat.Type.systemBars())
WindowCompat.setDecorFitsSystemWindows(restoreWindow, true)
}
}
}
DisposableEffect(nativeCore) {
val listener: (Int, Int) -> Unit = { w, h ->
session = session.copy(width = w, height = h)
}
nativeCore.addVideoSizeListener(listener)
onDispose {
nativeCore.removeVideoSizeListener(listener)
}
}
DisposableEffect(nativeCore) {
val listener: (Float) -> Unit = { fps ->
currentFps = fps
}
nativeCore.addVideoFpsListener(listener)
onDispose {
nativeCore.removeVideoFpsListener(listener)
}
}
fun sendKeycode(keycode: Int) {
nativeCore.scrcpyInjectKeycode(0, keycode)
nativeCore.scrcpyInjectKeycode(1, keycode)
}
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
) {
FullscreenControlScreen(
session = session,
nativeCore = nativeCore,
onDismiss = onDismiss,
showDebugInfo = showDebugInfo,
currentFps = currentFps,
enableBackHandler = false,
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
nativeCore.scrcpyInjectTouch(
action = action,
pointerId = pointerId,
x = x,
y = y,
screenWidth = session.width,
screenHeight = session.height,
pressure = pressure,
buttons = buttons,
)
},
)
bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter),
onPressHaptic = { haptics.press() },
onConfirmHaptic = { haptics.confirm() },
onAction = { action ->
action.keycode?.let(::sendKeycode)
},
)
}
}
}

View File

@@ -0,0 +1,728 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.ui.NavDisplay
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.services.MainSettings
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.NavigationBar
import top.yukonga.miuix.kmp.basic.NavigationBarItem
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SnackbarHost
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
private enum class MainTabDestination(
val title: String,
val label: String,
val icon: ImageVector,
) {
Device(title = "设备", label = "设备", icon = Icons.Default.Devices),
Settings(title = "设置", label = "设置", icon = Icons.Default.Settings),
}
private sealed interface RootScreen : NavKey {
data object Home : RootScreen
data object Advanced : RootScreen
data object VirtualButtonOrder : RootScreen
data class Fullscreen(val launch: FullscreenControlLaunch) : RootScreen
}
@Composable
fun MainPage() {
val context = LocalContext.current
val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
val initialSettings = remember(context) { loadMainSettings(context) }
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
val snackHostState = remember { SnackbarHostState() }
val tabs = remember { MainTabDestination.entries }
val pagerState = rememberPagerState(initialPage = MainTabDestination.Device.ordinal, pageCount = { tabs.size })
val currentTab = tabs[pagerState.currentPage]
val saveableStateHolder = rememberSaveableStateHolder()
val scope = rememberCoroutineScope()
val rootBackStack = remember { mutableStateListOf<NavKey>(RootScreen.Home) }
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
val deviceScrollBehavior = MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Device })
val settingsScrollBehavior = MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Settings })
val advancedScrollBehavior = MiuixScrollBehavior(
canScroll = {
currentRootScreen is RootScreen.Advanced || currentRootScreen is RootScreen.VirtualButtonOrder
},
)
val stringListSaver = listSaver<List<String>, String>(
save = { value -> ArrayList(value) },
restore = { restored -> restored.toList() },
)
var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) }
var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) }
var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) }
var keepScreenOnWhenStreamingEnabled by rememberSaveable {
mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled)
}
var virtualButtonsOutside by rememberSaveable(stateSaver = stringListSaver) {
mutableStateOf(VirtualButtonActions.parseStoredIds(initialSettings.virtualButtonsOutside))
}
var virtualButtonsInMore by rememberSaveable(stateSaver = stringListSaver) {
mutableStateOf(VirtualButtonActions.parseStoredIds(initialSettings.virtualButtonsInMore))
}
var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) }
var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) }
var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) }
var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) }
var audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) }
var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) }
var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) }
var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) }
var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) }
var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) }
var audioEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.audioEncoder) }
var audioCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.audioCodecOptions) }
var audioDup by rememberSaveable { mutableStateOf(initialDeviceSettings.audioDup) }
var audioSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourcePreset) }
var audioSourceCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourceCustom) }
var videoSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.videoSourcePreset) }
var cameraIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraIdInput) }
var cameraFacingPreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFacingPreset) }
var cameraSizePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizePreset) }
var cameraSizeCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizeCustom) }
var cameraArInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraArInput) }
var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFpsInput) }
var cameraHighSpeed by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraHighSpeed) }
var noAudioPlayback by rememberSaveable { mutableStateOf(initialDeviceSettings.noAudioPlayback) }
var noVideo by rememberSaveable { mutableStateOf(initialDeviceSettings.noVideo) }
var requireAudio by rememberSaveable { mutableStateOf(initialDeviceSettings.requireAudio) }
var turnScreenOff by rememberSaveable { mutableStateOf(initialDeviceSettings.turnScreenOff) }
var maxSizeInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxSizeInput) }
var maxFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxFpsInput) }
var newDisplayWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayWidth) }
var newDisplayHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayHeight) }
var newDisplayDpi by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayDpi) }
var displayIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.displayIdInput) }
var cropWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.cropWidth) }
var cropHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.cropHeight) }
var cropX by rememberSaveable { mutableStateOf(initialDeviceSettings.cropX) }
var cropY by rememberSaveable { mutableStateOf(initialDeviceSettings.cropY) }
val videoEncoderOptions = remember { mutableStateListOf<String>() }
val audioEncoderOptions = remember { mutableStateListOf<String>() }
val videoEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
val audioEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
val cameraSizeOptions = remember { mutableStateListOf<String>() }
var sessionStarted by remember { mutableStateOf(false) }
var refreshEncodersAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var refreshCameraSizesAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var clearLogsAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var canClearLogs by remember { mutableStateOf(false) }
var showDeviceMenu by rememberSaveable { mutableStateOf(false) }
val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled)
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
LaunchedEffect(
themeBaseIndex,
monetEnabled,
fullscreenDebugInfoEnabled,
keepScreenOnWhenStreamingEnabled,
virtualButtonsOutside,
virtualButtonsInMore,
devicePreviewCardHeightDp,
customServerUri,
serverRemotePath,
videoCodec,
audioEnabled,
audioCodec,
adbKeyName,
) {
saveMainSettings(
context,
MainSettings(
themeBaseIndex = themeBaseIndex,
monetEnabled = monetEnabled,
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
virtualButtonsOutside = VirtualButtonActions.encodeStoredIds(virtualButtonsOutside),
virtualButtonsInMore = VirtualButtonActions.encodeStoredIds(virtualButtonsInMore),
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
videoCodec = videoCodec,
audioEnabled = audioEnabled,
audioCodec = audioCodec,
adbKeyName = adbKeyName,
),
)
}
LaunchedEffect(adbKeyName) {
nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.DefaultAdbKeyName })
}
val canNavigateBack = rootBackStack.size > 1 || pagerState.currentPage != MainTabDestination.Device.ordinal
fun popRoot() {
if (rootBackStack.size > 1) {
rootBackStack.removeAt(rootBackStack.lastIndex)
}
}
fun handleBackNavigation() {
if (rootBackStack.size > 1) {
popRoot()
} else if (pagerState.currentPage != MainTabDestination.Device.ordinal) {
scope.launch {
pagerState.animateScrollToPage(
page = MainTabDestination.Device.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PageSwitchDampingRatio,
stiffness = UiMotion.PageSwitchStiffness,
),
)
}
}
}
PredictiveBackHandler(enabled = canNavigateBack) { progress ->
try {
progress.collect { }
handleBackNavigation()
} catch (_: CancellationException) {
// Gesture was cancelled by the system/user.
}
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
customServerUri = uri.toString()
}
val rootEntryProvider = entryProvider<NavKey> {
entry(RootScreen.Home) {
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { tab ->
NavigationBarItem(
selected = currentTab == tab,
onClick = {
scope.launch {
pagerState.animateScrollToPage(
page = tab.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PageSwitchDampingRatio,
stiffness = UiMotion.PageSwitchStiffness,
),
)
}
},
icon = tab.icon,
label = tab.label,
)
}
}
},
snackbarHost = { SnackbarHost(snackHostState) },
) { contentPadding ->
HorizontalPager(
modifier = Modifier
.fillMaxSize()
.padding(bottom = contentPadding.calculateBottomPadding()),
state = pagerState,
beyondViewportPageCount = 1,
) { page ->
val tab = tabs[page]
saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) {
MainTabDestination.Device -> Scaffold(
topBar = {
TopAppBar(
title = tab.title,
actions = {
IconButton(
onClick = { showDeviceMenu = true },
holdDownState = showDeviceMenu,
) {
Icon(Icons.Default.MoreVert, contentDescription = "更多")
}
DeviceMenuPopup(
show = showDeviceMenu,
canClearLogs = canClearLogs,
onDismissRequest = { showDeviceMenu = false },
onReorderDevices = {
openReorderDevicesAction?.invoke()
showDeviceMenu = false
},
onOpenVirtualButtonOrder = {
rootBackStack.add(RootScreen.VirtualButtonOrder)
showDeviceMenu = false
},
onClearLogs = {
clearLogsAction?.invoke()
showDeviceMenu = false
},
)
},
scrollBehavior = deviceScrollBehavior,
)
},
) { pagePadding ->
DeviceTabScreen(
contentPadding = pagePadding,
nativeCore = nativeCore,
snack = snackHostState,
scrollBehavior = deviceScrollBehavior,
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
virtualButtonsOutside = virtualButtonsOutside,
virtualButtonsInMore = virtualButtonsInMore,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
onServerRemotePathChange = { serverRemotePath = it },
videoCodec = videoCodec,
onVideoCodecChange = { videoCodec = it },
audioEnabled = audioEnabled,
onAudioEnabledChange = { audioEnabled = it },
audioCodec = audioCodec,
onAudioCodecChange = { audioCodec = it },
noControl = noControl,
onNoControlChange = {
noControl = it
if (it) {
turnScreenOff = false
}
},
videoEncoder = videoEncoder,
onVideoEncoderChange = { videoEncoder = it },
videoCodecOptions = videoCodecOptions,
onVideoCodecOptionsChange = { videoCodecOptions = it },
audioEncoder = audioEncoder,
onAudioEncoderChange = { audioEncoder = it },
audioCodecOptions = audioCodecOptions,
onAudioCodecOptionsChange = { audioCodecOptions = it },
audioDup = audioDup,
onAudioDupChange = { audioDup = it },
audioSourcePreset = audioSourcePreset,
onAudioSourcePresetChange = { audioSourcePreset = it },
audioSourceCustom = audioSourceCustom,
onAudioSourceCustomChange = { audioSourceCustom = it },
videoSourcePreset = videoSourcePreset,
onVideoSourcePresetChange = { videoSourcePreset = it },
cameraIdInput = cameraIdInput,
onCameraIdInputChange = { cameraIdInput = it },
cameraFacingPreset = cameraFacingPreset,
onCameraFacingPresetChange = { cameraFacingPreset = it },
cameraSizePreset = cameraSizePreset,
onCameraSizePresetChange = { cameraSizePreset = it },
cameraSizeCustom = cameraSizeCustom,
onCameraSizeCustomChange = { cameraSizeCustom = it },
cameraArInput = cameraArInput,
onCameraArInputChange = { cameraArInput = it },
cameraFpsInput = cameraFpsInput,
onCameraFpsInputChange = { cameraFpsInput = it },
cameraHighSpeed = cameraHighSpeed,
onCameraHighSpeedChange = { cameraHighSpeed = it },
noAudioPlayback = noAudioPlayback,
onNoAudioPlaybackChange = { noAudioPlayback = it },
noVideo = noVideo,
requireAudio = requireAudio,
onRequireAudioChange = { requireAudio = it },
turnScreenOff = turnScreenOff,
onTurnScreenOffChange = { turnScreenOff = it },
maxSizeInput = maxSizeInput,
onMaxSizeInputChange = { maxSizeInput = it },
maxFpsInput = maxFpsInput,
onMaxFpsInputChange = { maxFpsInput = it },
newDisplayWidth = newDisplayWidth,
onNewDisplayWidthChange = { newDisplayWidth = it },
newDisplayHeight = newDisplayHeight,
onNewDisplayHeightChange = { newDisplayHeight = it },
newDisplayDpi = newDisplayDpi,
onNewDisplayDpiChange = { newDisplayDpi = it },
displayIdInput = displayIdInput,
onDisplayIdInputChange = { displayIdInput = it },
cropWidth = cropWidth,
onCropWidthChange = { cropWidth = it },
cropHeight = cropHeight,
onCropHeightChange = { cropHeight = it },
cropX = cropX,
onCropXChange = { cropX = it },
cropY = cropY,
onCropYChange = { cropY = it },
videoEncoderOptions = videoEncoderOptions,
onVideoEncoderOptionsChange = {
videoEncoderOptions.clear()
videoEncoderOptions.addAll(it)
},
onVideoEncoderTypeMapChange = {
videoEncoderTypeMap.clear()
videoEncoderTypeMap.putAll(it)
},
audioEncoderOptions = audioEncoderOptions,
onAudioEncoderOptionsChange = {
audioEncoderOptions.clear()
audioEncoderOptions.addAll(it)
},
onAudioEncoderTypeMapChange = {
audioEncoderTypeMap.clear()
audioEncoderTypeMap.putAll(it)
},
cameraSizeOptions = cameraSizeOptions,
onCameraSizeOptionsChange = {
cameraSizeOptions.clear()
cameraSizeOptions.addAll(it)
},
onSessionStartedChange = { sessionStarted = it },
onRefreshEncodersActionChange = { refreshEncodersAction = it },
onRefreshCameraSizesActionChange = { refreshCameraSizesAction = it },
onClearLogsActionChange = { clearLogsAction = it },
onCanClearLogsChange = { canClearLogs = it },
onOpenReorderDevicesActionChange = { openReorderDevicesAction = it },
onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) },
onOpenFullscreenPage = { session ->
rootBackStack.add(
RootScreen.Fullscreen(
launch = FullscreenControlLaunch(
deviceName = session.deviceName,
width = session.width,
height = session.height,
codec = session.codec,
),
),
)
},
previewCardHeightDp = devicePreviewCardHeightDp,
)
}
MainTabDestination.Settings -> Scaffold(
topBar = {
TopAppBar(
title = tab.title,
scrollBehavior = settingsScrollBehavior,
)
},
) { pagePadding ->
SettingsScreen(
contentPadding = pagePadding,
themeBaseIndex = themeBaseIndex,
onThemeBaseIndexChange = { themeBaseIndex = it },
monetEnabled = monetEnabled,
onMonetEnabledChange = { monetEnabled = it },
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
onFullscreenDebugInfoEnabledChange = { fullscreenDebugInfoEnabled = it },
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
onKeepScreenOnWhenStreamingEnabledChange = {
keepScreenOnWhenStreamingEnabled = it
},
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
onDevicePreviewCardHeightDpChange = {
devicePreviewCardHeightDp = it.coerceAtLeast(120)
},
customServerUri = customServerUri,
onPickServer = {
picker.launch(arrayOf("application/java-archive", "application/octet-stream", "*/*"))
},
onClearServer = { customServerUri = null },
serverRemotePath = serverRemotePath,
onServerRemotePathChange = { serverRemotePath = it },
adbKeyName = adbKeyName,
onAdbKeyNameChange = { adbKeyName = it },
scrollBehavior = settingsScrollBehavior,
)
}
}
}
}
}
}
entry(RootScreen.Advanced) {
val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions
val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions
val videoEncoderIndex = (videoEncoderOptions.indexOf(videoEncoder) + 1).coerceAtLeast(0)
val audioEncoderIndex = (audioEncoderOptions.indexOf(audioEncoder) + 1).coerceAtLeast(0)
Scaffold(
topBar = {
TopAppBar(
title = "高级参数",
navigationIcon = {
IconButton(onClick = { popRoot() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
scrollBehavior = advancedScrollBehavior,
)
},
snackbarHost = { SnackbarHost(snackHostState) },
) { pagePadding ->
AdvancedConfigPage(
contentPadding = pagePadding,
scrollBehavior = advancedScrollBehavior,
sessionStarted = sessionStarted,
snackbarHostState = snackHostState,
audioEnabled = audioEnabled,
noControl = noControl,
onNoControlChange = {
noControl = it
if (it) {
turnScreenOff = false
}
},
audioDup = audioDup,
onAudioDupChange = { audioDup = it },
audioSourcePreset = audioSourcePreset,
onAudioSourcePresetChange = { audioSourcePreset = it },
audioSourceCustom = audioSourceCustom,
onAudioSourceCustomChange = { audioSourceCustom = it },
videoSourcePreset = videoSourcePreset,
onVideoSourcePresetChange = { videoSourcePreset = it },
cameraIdInput = cameraIdInput,
onCameraIdInputChange = { cameraIdInput = it },
cameraFacingPreset = cameraFacingPreset,
onCameraFacingPresetChange = { cameraFacingPreset = it },
cameraSizePreset = cameraSizePreset,
onCameraSizePresetChange = { cameraSizePreset = it },
cameraSizeCustom = cameraSizeCustom,
onCameraSizeCustomChange = { cameraSizeCustom = it },
cameraSizeDropdownItems = listOf("默认") + cameraSizeOptions + listOf("自定义"),
cameraSizeIndex = when (cameraSizePreset) {
"custom" -> cameraSizeOptions.size + 1
in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSizePreset) + 1
else -> 0
},
cameraArInput = cameraArInput,
onCameraArInputChange = { cameraArInput = it },
cameraFpsInput = cameraFpsInput,
onCameraFpsInputChange = { cameraFpsInput = it },
cameraHighSpeed = cameraHighSpeed,
onCameraHighSpeedChange = { cameraHighSpeed = it },
noAudioPlayback = noAudioPlayback,
onNoAudioPlaybackChange = { noAudioPlayback = it },
noVideo = noVideo,
onNoVideoChange = { noVideo = it },
requireAudio = requireAudio,
onRequireAudioChange = { requireAudio = it },
turnScreenOff = turnScreenOff,
onTurnScreenOffChange = { turnScreenOff = it },
maxSizeInput = maxSizeInput,
onMaxSizeInputChange = { maxSizeInput = it },
maxFpsInput = maxFpsInput,
onMaxFpsInputChange = { maxFpsInput = it },
videoEncoderDropdownItems = videoEncoderDropdownItems,
videoEncoderTypeMap = videoEncoderTypeMap,
videoEncoderIndex = videoEncoderIndex,
onVideoEncoderChange = { videoEncoder = it },
videoCodecOptions = videoCodecOptions,
onVideoCodecOptionsChange = { videoCodecOptions = it },
audioEncoderDropdownItems = audioEncoderDropdownItems,
audioEncoderTypeMap = audioEncoderTypeMap,
audioEncoderIndex = audioEncoderIndex,
onAudioEncoderChange = { audioEncoder = it },
audioCodecOptions = audioCodecOptions,
onAudioCodecOptionsChange = { audioCodecOptions = it },
onRefreshEncoders = { refreshEncodersAction?.invoke() },
onRefreshCameraSizes = { refreshCameraSizesAction?.invoke() },
newDisplayWidth = newDisplayWidth,
onNewDisplayWidthChange = { newDisplayWidth = it },
newDisplayHeight = newDisplayHeight,
onNewDisplayHeightChange = { newDisplayHeight = it },
newDisplayDpi = newDisplayDpi,
onNewDisplayDpiChange = { newDisplayDpi = it },
displayIdInput = displayIdInput,
onDisplayIdInputChange = { displayIdInput = it },
cropWidth = cropWidth,
onCropWidthChange = { cropWidth = it },
cropHeight = cropHeight,
onCropHeightChange = { cropHeight = it },
cropX = cropX,
onCropXChange = { cropX = it },
cropY = cropY,
onCropYChange = { cropY = it },
)
}
}
entry(RootScreen.VirtualButtonOrder) {
Scaffold(
modifier = Modifier.nestedScroll(advancedScrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = "虚拟按钮排序",
navigationIcon = {
IconButton(onClick = { popRoot() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
scrollBehavior = advancedScrollBehavior,
)
},
) { pagePadding ->
VirtualButtonOrderPage(
contentPadding = pagePadding,
scrollBehavior = advancedScrollBehavior,
outsideIds = virtualButtonsOutside,
moreIds = virtualButtonsInMore,
onLayoutChange = { outside, more ->
virtualButtonsOutside = outside
virtualButtonsInMore = more
},
)
}
}
entry<RootScreen.Fullscreen> { screen ->
FullscreenControlPage(
launch = screen.launch,
nativeCore = nativeCore,
virtualButtonsOutside = virtualButtonsOutside,
virtualButtonsInMore = virtualButtonsInMore,
showDebugInfo = fullscreenDebugInfoEnabled,
onDismiss = { popRoot() },
)
}
}
val rootEntries = rememberDecoratedNavEntries(
backStack = rootBackStack,
entryProvider = rootEntryProvider,
)
MiuixTheme(controller = themeController) {
NavDisplay(
entries = rootEntries,
onBack = { popRoot() },
)
}
}
@Composable
private fun DeviceMenuPopup(
show: Boolean,
canClearLogs: Boolean,
onDismissRequest: () -> Unit,
onReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onClearLogs: () -> Unit,
) {
SuperListPopup(
show = show,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
alignment = PopupPositionProvider.Align.TopEnd,
onDismissRequest = onDismissRequest,
enableWindowDim = false,
) {
ListPopupColumn {
DeviceMenuPopupItem(
text = "调整设备排序",
optionSize = 3,
index = 0,
onClick = onReorderDevices,
)
DeviceMenuPopupItem(
text = "虚拟按钮排序",
optionSize = 3,
index = 1,
onClick = onOpenVirtualButtonOrder,
)
DeviceMenuPopupItem(
text = "清空日志",
optionSize = 3,
index = 2,
enabled = canClearLogs,
onClick = onClearLogs,
)
}
}
}
@Composable
private fun DeviceMenuPopupItem(
text: String,
optionSize: Int,
index: Int,
enabled: Boolean = true,
// TODO: (Int) -> Unit
onClick: () -> Unit,
) {
if (enabled) {
DropdownImpl(
text = text,
optionSize = optionSize,
isSelected = false,
index = index,
onSelectedIndexChange = { onClick() },
)
return
}
val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem
val additionalBottomPadding = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem
Text(
text = text,
fontSize = MiuixTheme.textStyles.body1.fontSize,
fontWeight = FontWeight.Medium,
color = MiuixTheme.colorScheme.disabledOnSecondaryVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.PopupHorizontal)
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
)
}

View File

@@ -0,0 +1,207 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt
private data class ThemeModeOption(
val label: String,
val mode: ColorSchemeMode,
)
private val THEME_BASE_OPTIONS = listOf(
ThemeModeOption("跟随系统", ColorSchemeMode.System),
ThemeModeOption("浅色", ColorSchemeMode.Light),
ThemeModeOption("深色", ColorSchemeMode.Dark),
)
fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode {
return when (baseIndex.coerceIn(0, 2)) {
0 -> if (monetEnabled) ColorSchemeMode.MonetSystem else ColorSchemeMode.System
1 -> if (monetEnabled) ColorSchemeMode.MonetLight else ColorSchemeMode.Light
else -> if (monetEnabled) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark
}
}
private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
val base = THEME_BASE_OPTIONS.getOrNull(baseIndex.coerceIn(0, 2))?.label ?: "跟随系统"
return if (monetEnabled) "Monet$base" else base
}
@Composable
fun SettingsScreen(
contentPadding: PaddingValues,
themeBaseIndex: Int,
onThemeBaseIndexChange: (Int) -> Unit,
monetEnabled: Boolean,
onMonetEnabledChange: (Boolean) -> Unit,
fullscreenDebugInfoEnabled: Boolean,
onFullscreenDebugInfoEnabledChange: (Boolean) -> Unit,
keepScreenOnWhenStreamingEnabled: Boolean,
onKeepScreenOnWhenStreamingEnabledChange: (Boolean) -> Unit,
devicePreviewCardHeightDp: Int,
onDevicePreviewCardHeightDpChange: (Int) -> Unit,
customServerUri: String?,
onPickServer: () -> Unit,
onClearServer: () -> Unit,
serverRemotePath: String,
onServerRemotePathChange: (String) -> Unit,
adbKeyName: String,
onAdbKeyNameChange: (String) -> Unit,
scrollBehavior: ScrollBehavior,
) {
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
// 设置
AppPageLazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
SectionSmallTitle(text = "主题")
Card {
SuperDropdown(
title = "外观模式",
summary = resolveThemeLabel(themeBaseIndex, monetEnabled),
items = baseModeItems,
selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
onSelectedIndexChange = onThemeBaseIndexChange,
)
SuperSwitch(
title = "Monet",
summary = "开启后使用 Monet 动态配色",
checked = monetEnabled,
onCheckedChange = onMonetEnabledChange,
)
}
SectionSmallTitle(text = "投屏")
Card {
SuperSwitch(
title = "启用调试信息",
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
checked = fullscreenDebugInfoEnabled,
onCheckedChange = onFullscreenDebugInfoEnabledChange,
)
SuperSwitch(
title = "投屏时不允许息屏",
summary = "Scrcpy 启动后保持本机常亮,避免锁屏导致 ADB 断开",
checked = keepScreenOnWhenStreamingEnabled,
onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange,
)
SuperSlide(
title = "预览卡高度",
summary = "设备页预览卡高度",
value = devicePreviewCardHeightDp.toFloat(),
onValueChange = { onDevicePreviewCardHeightDpChange(it.roundToInt().coerceAtLeast(120)) },
valueRange = 160f..600f,
steps = 439,
unit = "dp",
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..Float.MAX_VALUE,
onInputConfirm = { raw ->
raw.toIntOrNull()?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) }
},
)
}
SectionSmallTitle(text = "scrcpy-server")
Card {
Spacer(modifier = Modifier.padding(top = UiSpacing.CardContent))
Text(
text = "自定义 binary",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = customServerUri ?: "",
onValueChange = {},
readOnly = true,
label = "scrcpy-server-v3.3.4",
useLabelAsPlaceholder = customServerUri == null,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
if (customServerUri != null) IconButton(onClick = onClearServer) {
Icon(Icons.Default.Clear, contentDescription = "清空")
}
IconButton(onClick = onPickServer) {
Icon(Icons.Default.FolderOpen, contentDescription = "选择文件")
}
}
},
)
Text(
text = "Remote Path",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = serverRemotePath,
onValueChange = onServerRemotePathChange,
label = AppDefaults.DefaultServerRemotePath,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
SectionSmallTitle(text = "ADB")
Card {
Text(
text = "自定义 ADB 密钥名",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = adbKeyName,
onValueChange = onAdbKeyNameChange,
label = AppDefaults.DefaultAdbKeyName,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
}
}
}

View File

@@ -0,0 +1,164 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.widgets.SortDropPayload
import io.github.miuzarte.scrcpyforandroid.widgets.SortTransferDirection
import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardItem
import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import kotlin.math.roundToInt
private const val LIST_REORDER_STEP_PX = 54f
private const val LIST_TRANSFER_STEP_PX = 72f
@Composable
internal fun VirtualButtonOrderPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
outsideIds: List<String>,
moreIds: List<String>,
onLayoutChange: (outside: List<String>, more: List<String>) -> Unit,
) {
val haptics = rememberAppHaptics()
val normalized = remember(outsideIds, moreIds) {
VirtualButtonActions.resolveLayout(outsideIds, moreIds)
}
val outsideState = remember { normalized.first.map { it.id }.toMutableStateList() }
val moreState = remember { normalized.second.map { it.id }.toMutableStateList() }
LaunchedEffect(outsideIds, moreIds) {
val resolved = VirtualButtonActions.resolveLayout(outsideIds, moreIds)
outsideState.clear()
outsideState.addAll(resolved.first.map { it.id })
moreState.clear()
moreState.addAll(resolved.second.map { it.id })
}
fun emit() {
onLayoutChange(outsideState.toList(), moreState.toList())
}
fun reorderInside(list: androidx.compose.runtime.snapshots.SnapshotStateList<String>, itemId: String, deltaY: Float) {
val fromIndex = list.indexOf(itemId)
if (fromIndex < 0) return
val steps = (deltaY / LIST_REORDER_STEP_PX).roundToInt()
if (steps == 0) return
val toIndex = (fromIndex + steps).coerceIn(0, list.lastIndex)
if (toIndex == fromIndex) return
val moved = list.removeAt(fromIndex)
list.add(toIndex, moved)
emit()
}
fun transferToOther(
from: androidx.compose.runtime.snapshots.SnapshotStateList<String>,
to: androidx.compose.runtime.snapshots.SnapshotStateList<String>,
itemId: String,
deltaY: Float,
) {
val fromIndex = from.indexOf(itemId)
if (fromIndex < 0) return
val steps = (deltaY / LIST_REORDER_STEP_PX).roundToInt()
val baseIndex = fromIndex + steps
val insertIndex = baseIndex.coerceIn(0, to.size)
from.removeAt(fromIndex)
to.add(insertIndex, itemId)
emit()
}
fun handleOutsideDrop(payload: SortDropPayload) {
val transfer = payload.transferDirection == SortTransferDirection.TO_RIGHT && payload.deltaX >= LIST_TRANSFER_STEP_PX
if (transfer) {
if (payload.itemId == VirtualButtonAction.MORE.id) return
transferToOther(outsideState, moreState, payload.itemId, payload.deltaY)
} else {
reorderInside(outsideState, payload.itemId, payload.deltaY)
}
}
fun handleMoreDrop(payload: SortDropPayload) {
val transfer = payload.transferDirection == SortTransferDirection.TO_LEFT && payload.deltaX <= -LIST_TRANSFER_STEP_PX
if (transfer) {
transferToOther(moreState, outsideState, payload.itemId, payload.deltaY)
} else {
reorderInside(moreState, payload.itemId, payload.deltaY)
}
}
AppPageLazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = "长按可在两列间拖动排序\n“更多”不可移入右侧菜单",
modifier = Modifier.padding(UiSpacing.CardContent),
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
) {
SortableCardList(
title = "外部按钮",
items = outsideState.map { id ->
val action = VirtualButtonAction.entries.first { it.id == id }
SortableCardItem(
id = action.id,
title = action.title,
subtitle = "显示在预览与全屏底部",
)
},
modifier = Modifier
.weight(1f)
.padding(end = UiSpacing.Small),
transferDirection = SortTransferDirection.TO_RIGHT,
onLongPressHaptic = haptics.press,
onDrop = ::handleOutsideDrop,
)
SortableCardList(
title = "更多菜单",
items = moreState.map { id ->
val action = VirtualButtonAction.entries.first { it.id == id }
SortableCardItem(
id = action.id,
title = action.title,
subtitle = "显示在“更多”弹窗中",
)
},
modifier = Modifier
.weight(1f)
.padding(start = UiSpacing.Small),
transferDirection = SortTransferDirection.TO_LEFT,
onLongPressHaptic = haptics.press,
onDrop = ::handleMoreDrop,
)
}
}
}
}

View File

@@ -0,0 +1,51 @@
package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.Dp
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.utils.overScrollVertical
@Composable
fun AppPageLazyColumn(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
modifier: Modifier = Modifier,
itemSpacing: Dp = UiSpacing.PageItem,
horizontalPadding: Dp = UiSpacing.PageHorizontal,
verticalPadding: Dp = UiSpacing.PageVertical,
clearFocusOnTap: Boolean = true,
content: LazyListScope.() -> Unit,
) {
val focusManager = LocalFocusManager.current
val focusClearModifier = if (clearFocusOnTap) {
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { focusManager.clearFocus() })
}
} else {
Modifier
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.then(focusClearModifier)
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(contentPadding),
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(itemSpacing),
content = content,
)
}

View File

@@ -0,0 +1,140 @@
package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Slider
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun SuperSlide(
title: String,
summary: String,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
enabled: Boolean = true,
unit: String = "",
zeroStateText: String? = null,
showUnitWhenZeroState: Boolean = false,
showKeyPoints: Boolean = false,
keyPoints: List<Float> = emptyList(),
displayFormatter: (Float) -> String = { it.toInt().toString() },
displayText: String? = null,
inputTitle: String = title,
inputHint: String = unit,
inputInitialValue: String = displayFormatter(value),
inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
inputValueRange: ClosedFloatingPointRange<Float>? = null,
onInputConfirm: (String) -> Unit,
) {
var showInputDialog by remember { mutableStateOf(false) }
var holdArrow by remember { mutableStateOf(false) }
SuperArrow(
title = title,
summary = summary,
onClick = {
showInputDialog = true
holdArrow = true
},
holdDownState = holdArrow,
endActions = {
val isZeroState = value == 0f && zeroStateText != null
val valueText = if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value))
val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
val text = if (shouldShowUnit) "$valueText $unit" else valueText
Text(
text = text,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = MiuixTheme.colorScheme.onSurfaceVariantActions,
)
},
enabled = enabled,
bottomAction = {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
showKeyPoints = showKeyPoints,
keyPoints = keyPoints,
enabled = enabled,
)
},
)
if (showInputDialog) {
var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) }
val activeInputRange = inputValueRange ?: valueRange
SuperDialog(
show = true,
onDismissRequest = {
showInputDialog = false
holdArrow = false
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(text = inputTitle)
}
TextField(
value = valueText,
onValueChange = { valueText = inputFilter(it) },
label = inputHint,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(top = UiSpacing.Large),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = UiSpacing.Large),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextButton(
text = "取消",
modifier = Modifier.weight(1f),
onClick = {
showInputDialog = false
holdArrow = false
},
)
TextButton(
text = "确定",
modifier = Modifier.weight(1f),
onClick = {
val inputValue = valueText.trim().toFloatOrNull()
if (inputValue != null && inputValue >= activeInputRange.start && inputValue <= activeInputRange.endInclusive) {
onInputConfirm(valueText.trim())
showInputDialog = false
holdArrow = false
}
},
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}
}

View File

@@ -0,0 +1,214 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
internal data class MainSettings(
val themeBaseIndex: Int = AppDefaults.DefaultThemeBaseIndex,
val monetEnabled: Boolean = AppDefaults.DefaultMonetEnabled,
val fullscreenDebugInfoEnabled: Boolean = AppDefaults.DefaultFullscreenDebugInfoEnabled,
val devicePreviewCardHeightDp: Int = AppDefaults.DefaultDevicePreviewCardHeightDp,
val keepScreenOnWhenStreamingEnabled: Boolean = AppDefaults.DefaultKeepScreenOnWhenStreamingEnabled,
val virtualButtonsOutside: String = AppDefaults.DefaultVirtualButtonsOutside,
val virtualButtonsInMore: String = AppDefaults.DefaultVirtualButtonsInMore,
val customServerUri: String? = null,
val serverRemotePath: String = AppDefaults.DefaultServerRemotePathInput,
val videoCodec: String = AppDefaults.DefaultVideoCodec,
val audioEnabled: Boolean = AppDefaults.DefaultAudioEnabled,
val audioCodec: String = AppDefaults.DefaultAudioCodec,
val adbKeyName: String = AppDefaults.DefaultAdbKeyNameInput,
)
internal data class DevicePageSettings(
val pairHost: String = AppDefaults.DefaultPairHost,
val pairPort: String = AppDefaults.DefaultPairPort,
val pairCode: String = AppDefaults.DefaultPairCode,
val quickConnectInput: String = AppDefaults.DefaultQuickConnectInput,
val bitRateMbps: Float = AppDefaults.DefaultBitRateMbps,
val bitRateInput: String = AppDefaults.DefaultBitRateInput,
val audioBitRateKbps: Int = AppDefaults.DefaultAudioBitRateKbps,
val maxSizeInput: String = AppDefaults.DefaultMaxSizeInput,
val maxFpsInput: String = AppDefaults.DefaultMaxFpsInput,
val noControl: Boolean = AppDefaults.DefaultNoControl,
val videoEncoder: String = AppDefaults.DefaultVideoEncoder,
val videoCodecOptions: String = AppDefaults.DefaultVideoCodecOptions,
val audioEncoder: String = AppDefaults.DefaultAudioEncoder,
val audioCodecOptions: String = AppDefaults.DefaultAudioCodecOptions,
val audioDup: Boolean = AppDefaults.DefaultAudioDup,
val audioSourcePreset: String = AppDefaults.DefaultAudioSourcePreset,
val audioSourceCustom: String = AppDefaults.DefaultAudioSourceCustom,
val videoSourcePreset: String = AppDefaults.DefaultVideoSourcePreset,
val cameraIdInput: String = AppDefaults.DefaultCameraIdInput,
val cameraFacingPreset: String = AppDefaults.DefaultCameraFacingPreset,
val cameraSizePreset: String = AppDefaults.DefaultCameraSizePreset,
val cameraSizeCustom: String = AppDefaults.DefaultCameraSizeCustom,
val cameraArInput: String = AppDefaults.DefaultCameraArInput,
val cameraFpsInput: String = AppDefaults.DefaultCameraFpsInput,
val cameraHighSpeed: Boolean = AppDefaults.DefaultCameraHighSpeed,
val noAudioPlayback: Boolean = AppDefaults.DefaultNoAudioPlayback,
val noVideo: Boolean = AppDefaults.DefaultNoVideo,
val requireAudio: Boolean = AppDefaults.DefaultRequireAudio,
val turnScreenOff: Boolean = AppDefaults.DefaultTurnScreenOff,
val newDisplayWidth: String = AppDefaults.DefaultNewDisplayWidth,
val newDisplayHeight: String = AppDefaults.DefaultNewDisplayHeight,
val newDisplayDpi: String = AppDefaults.DefaultNewDisplayDpi,
val displayIdInput: String = AppDefaults.DefaultDisplayIdInput,
val cropWidth: String = AppDefaults.DefaultCropWidth,
val cropHeight: String = AppDefaults.DefaultCropHeight,
val cropX: String = AppDefaults.DefaultCropX,
val cropY: String = AppDefaults.DefaultCropY,
)
internal fun loadMainSettings(context: Context): MainSettings {
val prefs = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
return MainSettings(
themeBaseIndex = prefs.getInt(AppPreferenceKeys.ThemeBaseIndex, AppDefaults.DefaultThemeBaseIndex),
monetEnabled = prefs.getBoolean(AppPreferenceKeys.MonetEnabled, AppDefaults.DefaultMonetEnabled),
fullscreenDebugInfoEnabled = prefs.getBoolean(
AppPreferenceKeys.FullscreenDebugInfoEnabled,
AppDefaults.DefaultFullscreenDebugInfoEnabled,
),
devicePreviewCardHeightDp = prefs.getInt(
AppPreferenceKeys.DevicePreviewCardHeightDp,
AppDefaults.DefaultDevicePreviewCardHeightDp,
).coerceAtLeast(120),
keepScreenOnWhenStreamingEnabled = prefs.getBoolean(
AppPreferenceKeys.KeepScreenOnWhenStreamingEnabled,
AppDefaults.DefaultKeepScreenOnWhenStreamingEnabled,
),
virtualButtonsOutside = prefs.getString(
AppPreferenceKeys.VirtualButtonsOutside,
AppDefaults.DefaultVirtualButtonsOutside,
).orEmpty().ifBlank { AppDefaults.DefaultVirtualButtonsOutside },
virtualButtonsInMore = prefs.getString(
AppPreferenceKeys.VirtualButtonsInMore,
AppDefaults.DefaultVirtualButtonsInMore,
).orEmpty().ifBlank { AppDefaults.DefaultVirtualButtonsInMore },
customServerUri = prefs.getString(AppPreferenceKeys.CustomServerUri, null),
serverRemotePath = prefs.getString(
AppPreferenceKeys.ServerRemotePath,
AppDefaults.DefaultServerRemotePathInput,
).orEmpty(),
videoCodec = prefs.getString(AppPreferenceKeys.VideoCodec, AppDefaults.DefaultVideoCodec)
.orEmpty()
.ifBlank { AppDefaults.DefaultVideoCodec },
audioEnabled = prefs.getBoolean(AppPreferenceKeys.AudioEnabled, AppDefaults.DefaultAudioEnabled),
audioCodec = prefs.getString(AppPreferenceKeys.AudioCodec, AppDefaults.DefaultAudioCodec)
.orEmpty()
.ifBlank { AppDefaults.DefaultAudioCodec },
adbKeyName = prefs.getString(AppPreferenceKeys.AdbKeyName, AppDefaults.DefaultAdbKeyNameInput).orEmpty(),
)
}
internal fun saveMainSettings(context: Context, settings: MainSettings) {
context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
.edit {
putInt(AppPreferenceKeys.ThemeBaseIndex, settings.themeBaseIndex)
.putBoolean(AppPreferenceKeys.MonetEnabled, settings.monetEnabled)
.putBoolean(AppPreferenceKeys.FullscreenDebugInfoEnabled, settings.fullscreenDebugInfoEnabled)
.putInt(AppPreferenceKeys.DevicePreviewCardHeightDp, settings.devicePreviewCardHeightDp.coerceAtLeast(120))
.putBoolean(AppPreferenceKeys.KeepScreenOnWhenStreamingEnabled, settings.keepScreenOnWhenStreamingEnabled)
.putString(AppPreferenceKeys.VirtualButtonsOutside, settings.virtualButtonsOutside)
.putString(AppPreferenceKeys.VirtualButtonsInMore, settings.virtualButtonsInMore)
.putString(AppPreferenceKeys.CustomServerUri, settings.customServerUri)
.putString(AppPreferenceKeys.ServerRemotePath, settings.serverRemotePath)
.putString(AppPreferenceKeys.VideoCodec, settings.videoCodec)
.putBoolean(AppPreferenceKeys.AudioEnabled, settings.audioEnabled)
.putString(AppPreferenceKeys.AudioCodec, settings.audioCodec)
.putString(AppPreferenceKeys.AdbKeyName, settings.adbKeyName)
}
}
internal fun loadDevicePageSettings(context: Context): DevicePageSettings {
val prefs = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
return DevicePageSettings(
pairHost = AppDefaults.DefaultPairHost,
pairPort = AppDefaults.DefaultPairPort,
pairCode = AppDefaults.DefaultPairCode,
quickConnectInput = prefs.getString(AppPreferenceKeys.QuickConnectInput, AppDefaults.DefaultQuickConnectInput).orEmpty(),
bitRateMbps = prefs.getFloat(AppPreferenceKeys.BitRateMbps, AppDefaults.DefaultBitRateMbps),
bitRateInput = prefs.getString(AppPreferenceKeys.BitRateInput, AppDefaults.DefaultBitRateInput)
.orEmpty()
.ifBlank { AppDefaults.DefaultBitRateInput },
maxSizeInput = prefs.getString(AppPreferenceKeys.MaxSizeInput, AppDefaults.DefaultMaxSizeInput).orEmpty(),
audioBitRateKbps = prefs.getInt(AppPreferenceKeys.AudioBitRateKbps, AppDefaults.DefaultAudioBitRateKbps),
maxFpsInput = prefs.getString(AppPreferenceKeys.MaxFpsInput, AppDefaults.DefaultMaxFpsInput).orEmpty(),
noControl = prefs.getBoolean(AppPreferenceKeys.NoControl, AppDefaults.DefaultNoControl),
videoEncoder = prefs.getString(AppPreferenceKeys.VideoEncoder, AppDefaults.DefaultVideoEncoder).orEmpty(),
videoCodecOptions = prefs.getString(AppPreferenceKeys.VideoCodecOptions, AppDefaults.DefaultVideoCodecOptions).orEmpty(),
audioEncoder = prefs.getString(AppPreferenceKeys.AudioEncoder, AppDefaults.DefaultAudioEncoder).orEmpty(),
audioCodecOptions = prefs.getString(AppPreferenceKeys.AudioCodecOptions, AppDefaults.DefaultAudioCodecOptions).orEmpty(),
audioDup = prefs.getBoolean(AppPreferenceKeys.AudioDup, AppDefaults.DefaultAudioDup),
audioSourcePreset = prefs.getString(AppPreferenceKeys.AudioSourcePreset, AppDefaults.DefaultAudioSourcePreset)
.orEmpty()
.ifBlank { AppDefaults.DefaultAudioSourcePreset },
audioSourceCustom = prefs.getString(AppPreferenceKeys.AudioSourceCustom, AppDefaults.DefaultAudioSourceCustom).orEmpty(),
videoSourcePreset = prefs.getString(AppPreferenceKeys.VideoSourcePreset, AppDefaults.DefaultVideoSourcePreset)
.orEmpty()
.ifBlank { AppDefaults.DefaultVideoSourcePreset },
cameraIdInput = prefs.getString(AppPreferenceKeys.CameraIdInput, AppDefaults.DefaultCameraIdInput).orEmpty(),
cameraFacingPreset = prefs.getString(AppPreferenceKeys.CameraFacingPreset, AppDefaults.DefaultCameraFacingPreset).orEmpty(),
cameraSizePreset = prefs.getString(AppPreferenceKeys.CameraSizePreset, AppDefaults.DefaultCameraSizePreset).orEmpty(),
cameraSizeCustom = prefs.getString(AppPreferenceKeys.CameraSizeCustom, AppDefaults.DefaultCameraSizeCustom).orEmpty(),
cameraArInput = prefs.getString(AppPreferenceKeys.CameraArInput, AppDefaults.DefaultCameraArInput).orEmpty(),
cameraFpsInput = prefs.getString(AppPreferenceKeys.CameraFpsInput, AppDefaults.DefaultCameraFpsInput).orEmpty(),
cameraHighSpeed = prefs.getBoolean(AppPreferenceKeys.CameraHighSpeed, AppDefaults.DefaultCameraHighSpeed),
noAudioPlayback = prefs.getBoolean(AppPreferenceKeys.NoAudioPlayback, AppDefaults.DefaultNoAudioPlayback),
noVideo = prefs.getBoolean(AppPreferenceKeys.NoVideo, AppDefaults.DefaultNoVideo),
requireAudio = prefs.getBoolean(AppPreferenceKeys.RequireAudio, AppDefaults.DefaultRequireAudio),
turnScreenOff = prefs.getBoolean(AppPreferenceKeys.TurnScreenOff, AppDefaults.DefaultTurnScreenOff),
newDisplayWidth = prefs.getString(AppPreferenceKeys.NewDisplayWidth, AppDefaults.DefaultNewDisplayWidth).orEmpty(),
newDisplayHeight = prefs.getString(AppPreferenceKeys.NewDisplayHeight, AppDefaults.DefaultNewDisplayHeight).orEmpty(),
newDisplayDpi = prefs.getString(AppPreferenceKeys.NewDisplayDpi, AppDefaults.DefaultNewDisplayDpi).orEmpty(),
displayIdInput = prefs.getString(AppPreferenceKeys.DisplayIdInput, AppDefaults.DefaultDisplayIdInput).orEmpty(),
cropWidth = prefs.getString(AppPreferenceKeys.CropWidth, AppDefaults.DefaultCropWidth).orEmpty(),
cropHeight = prefs.getString(AppPreferenceKeys.CropHeight, AppDefaults.DefaultCropHeight).orEmpty(),
cropX = prefs.getString(AppPreferenceKeys.CropX, AppDefaults.DefaultCropX).orEmpty(),
cropY = prefs.getString(AppPreferenceKeys.CropY, AppDefaults.DefaultCropY).orEmpty(),
)
}
internal fun saveDevicePageSettings(context: Context, settings: DevicePageSettings) {
context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
.edit {
remove(AppPreferenceKeys.PairHost)
.remove(AppPreferenceKeys.PairPort)
.remove(AppPreferenceKeys.PairCode)
.putString(AppPreferenceKeys.QuickConnectInput, settings.quickConnectInput)
.putFloat(AppPreferenceKeys.BitRateMbps, settings.bitRateMbps)
.putString(AppPreferenceKeys.BitRateInput, settings.bitRateInput)
.putInt(AppPreferenceKeys.AudioBitRateKbps, settings.audioBitRateKbps)
.putString(AppPreferenceKeys.MaxSizeInput, settings.maxSizeInput)
.putString(AppPreferenceKeys.MaxFpsInput, settings.maxFpsInput)
.putBoolean(AppPreferenceKeys.NoControl, settings.noControl)
.putString(AppPreferenceKeys.VideoEncoder, settings.videoEncoder)
.putString(AppPreferenceKeys.VideoCodecOptions, settings.videoCodecOptions)
.putString(AppPreferenceKeys.AudioEncoder, settings.audioEncoder)
.putString(AppPreferenceKeys.AudioCodecOptions, settings.audioCodecOptions)
.putBoolean(AppPreferenceKeys.AudioDup, settings.audioDup)
.putString(AppPreferenceKeys.AudioSourcePreset, settings.audioSourcePreset)
.putString(AppPreferenceKeys.AudioSourceCustom, settings.audioSourceCustom)
.putString(AppPreferenceKeys.VideoSourcePreset, settings.videoSourcePreset)
.putString(AppPreferenceKeys.CameraIdInput, settings.cameraIdInput)
.putString(AppPreferenceKeys.CameraFacingPreset, settings.cameraFacingPreset)
.putString(AppPreferenceKeys.CameraSizePreset, settings.cameraSizePreset)
.putString(AppPreferenceKeys.CameraSizeCustom, settings.cameraSizeCustom)
.putString(AppPreferenceKeys.CameraArInput, settings.cameraArInput)
.putString(AppPreferenceKeys.CameraFpsInput, settings.cameraFpsInput)
.putBoolean(AppPreferenceKeys.CameraHighSpeed, settings.cameraHighSpeed)
.putBoolean(AppPreferenceKeys.NoAudioPlayback, settings.noAudioPlayback)
.putBoolean(AppPreferenceKeys.NoVideo, settings.noVideo)
.putBoolean(AppPreferenceKeys.RequireAudio, settings.requireAudio)
.putBoolean(AppPreferenceKeys.TurnScreenOff, settings.turnScreenOff)
.putString(AppPreferenceKeys.NewDisplayWidth, settings.newDisplayWidth)
.putString(AppPreferenceKeys.NewDisplayHeight, settings.newDisplayHeight)
.putString(AppPreferenceKeys.NewDisplayDpi, settings.newDisplayDpi)
.putString(AppPreferenceKeys.DisplayIdInput, settings.displayIdInput)
.putString(AppPreferenceKeys.CropWidth, settings.cropWidth)
.putString(AppPreferenceKeys.CropHeight, settings.cropHeight)
.putString(AppPreferenceKeys.CropX, settings.cropX)
.putString(AppPreferenceKeys.CropY, settings.cropY)
}
}

View File

@@ -0,0 +1,35 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
internal data class ConnectedDeviceInfo(
val model: String,
val serial: String,
val manufacturer: String,
val brand: String,
val device: String,
val androidRelease: String,
val sdkInt: Int,
)
internal fun fetchConnectedDeviceInfo(nativeCore: NativeCoreFacade, host: String, port: Int): ConnectedDeviceInfo {
fun prop(name: String): String = runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("")
val model = prop("ro.product.model")
val serial = prop("ro.serialno")
val manufacturer = prop("ro.product.manufacturer")
val brand = prop("ro.product.brand")
val device = prop("ro.product.device")
val androidRelease = prop("ro.build.version.release")
val sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1
return ConnectedDeviceInfo(
model = model.ifBlank { "$host:$port" },
serial = serial,
manufacturer = manufacturer,
brand = brand,
device = device,
androidRelease = androidRelease,
sdkInt = sdkInt,
)
}

View File

@@ -0,0 +1,110 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
internal fun loadQuickDevices(context: Context): List<DeviceShortcut> {
val raw = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
.getString(AppPreferenceKeys.QuickDevices, "")
.orEmpty()
if (raw.isBlank()) return emptyList()
val result = mutableListOf<DeviceShortcut>()
raw.lineSequence().forEach { line ->
val parts = line.split("|", limit = 3)
when (parts.size) {
3 -> {
val name = parts[0].trim()
val host = parts[1].trim()
val port = parts[2].trim().toIntOrNull() ?: AppDefaults.DefaultAdbPort
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
id = "$host:$port",
name = name,
host = host,
port = port,
online = false,
),
)
}
}
2 -> {
// Backward compatibility with old format: name|host:port
val name = parts[0].trim()
val host = parts[1].substringBefore(":").trim()
val port = parts[1].substringAfter(":", AppDefaults.DefaultAdbPort.toString()).trim().toIntOrNull() ?: AppDefaults.DefaultAdbPort
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
id = "$host:$port",
name = name,
host = host,
port = port,
online = false,
),
)
}
}
}
}
return result
}
internal fun saveQuickDevices(context: Context, quickDevices: List<DeviceShortcut>) {
val raw = quickDevices.joinToString("\n") { "${it.name}|${it.host}|${it.port}" }
context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
.edit {
putString(AppPreferenceKeys.QuickDevices, raw)
}
}
internal fun parseQuickTarget(raw: String): ConnectionTarget? {
val value = raw.trim()
if (value.isEmpty()) return null
val host = value.substringBefore(':').trim()
if (host.isEmpty()) return null
val port = value.substringAfter(':', AppDefaults.DefaultAdbPort.toString()).trim().toIntOrNull() ?: AppDefaults.DefaultAdbPort
return ConnectionTarget(host, port)
}
internal fun upsertQuickDevice(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
port: Int,
online: Boolean,
) {
val id = "$host:$port"
val idx = quickDevices.indexOfFirst { it.id == id }
val existingName = if (idx >= 0) quickDevices[idx].name else ""
val item = DeviceShortcut(
id = id,
name = existingName,
host = host,
port = port,
online = online,
)
if (idx >= 0) quickDevices[idx] = item else quickDevices.add(0, item)
saveQuickDevices(context, quickDevices)
}
internal fun updateQuickDeviceNameIfEmpty(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
port: Int,
fallbackName: String,
) {
val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
if (idx >= 0 && quickDevices[idx].name.isBlank()) {
quickDevices[idx] = quickDevices[idx].copy(name = fallbackName)
saveQuickDevices(context, quickDevices)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.SmallTitle
@Composable
fun SectionSmallTitle(text: String, showLeadingSpacer: Boolean = true) {
if (showLeadingSpacer) {
Spacer(Modifier.height(UiSpacing.SectionTitleLeadingGap))
}
SmallTitle(
text = text,
insideMargin = PaddingValues(16.dp, 8.dp)
)
}

View File

@@ -0,0 +1,140 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
enum class SortTransferDirection {
NONE,
TO_LEFT,
TO_RIGHT,
}
data class SortableCardItem(
val id: String,
val title: String,
val subtitle: String = "",
)
data class SortDropPayload(
val itemId: String,
val deltaX: Float,
val deltaY: Float,
val transferDirection: SortTransferDirection,
)
@Composable
fun SortableCardList(
title: String,
items: List<SortableCardItem>,
modifier: Modifier = Modifier,
transferDirection: SortTransferDirection = SortTransferDirection.NONE,
onLongPressHaptic: (() -> Unit)? = null,
onDrop: (SortDropPayload) -> Unit,
) {
Column(modifier = modifier) {
SectionSmallTitle(text = title, showLeadingSpacer = false)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(UiSpacing.FieldLabelBottom),
) {
items.forEach { item ->
var dragX by remember(item.id) { mutableFloatStateOf(0f) }
var dragY by remember(item.id) { mutableFloatStateOf(0f) }
var dragging by remember(item.id) { androidx.compose.runtime.mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.zIndex(if (dragging) 1f else 0f)
.graphicsLayer {
translationX = if (dragging) dragX else 0f
translationY = if (dragging) dragY else 0f
}
.pointerInput(item.id) {
detectDragGesturesAfterLongPress(
onDragStart = {
dragging = true
dragX = 0f
dragY = 0f
onLongPressHaptic?.invoke()
},
onDrag = { change, dragAmount ->
change.consume()
dragX += dragAmount.x
dragY += dragAmount.y
},
onDragEnd = {
onDrop(
SortDropPayload(
itemId = item.id,
deltaX = dragX,
deltaY = dragY,
transferDirection = transferDirection,
),
)
dragging = false
dragX = 0f
dragY = 0f
},
onDragCancel = {
dragging = false
dragX = 0f
dragY = 0f
},
)
},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.FieldLabelBottom),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
color = MiuixTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
if (item.subtitle.isNotBlank()) {
Text(
text = item.subtitle,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
fontSize = 13.sp,
)
}
}
Icon(
Icons.Default.DragIndicator,
contentDescription = "拖动排序",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
}
}

View File

@@ -0,0 +1,166 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults.defaultColors
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.utils.PressFeedbackType
@Immutable
internal data class StatusBigCardSpec(
val title: String,
val subtitle: String,
val containerColor: Color,
val titleColor: Color,
val subtitleColor: Color,
val icon: ImageVector,
val iconTint: Color,
)
@Immutable
internal data class StatusSmallCardSpec(
val title: String,
val value: String,
)
@Immutable
internal data class StatusCardSpec(
val big: StatusBigCardSpec,
val firstSmall: StatusSmallCardSpec,
val secondSmall: StatusSmallCardSpec,
)
internal fun normalizeStatusLine(statusLine: String): String {
val cleaned = statusLine.removePrefix("ADB 已连接:").trim()
return cleaned.ifBlank { statusLine }
}
@Composable
internal fun StatusCardLayout(
spec: StatusCardSpec,
busyLabel: String?,
) {
val haptics = rememberAppHaptics()
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem),
verticalAlignment = Alignment.CenterVertically,
) {
Card(
modifier = Modifier.weight(1f).fillMaxHeight(),
colors = defaultColors(color = spec.big.containerColor),
pressFeedbackType = PressFeedbackType.Tilt,
onClick = haptics.press,
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.offset(38.dp, 45.dp),
contentAlignment = Alignment.BottomEnd,
) {
Icon(
imageVector = spec.big.icon,
contentDescription = null,
modifier = Modifier.size(170.dp),
tint = spec.big.iconTint,
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(UiSpacing.Large),
) {
Text(
text = spec.big.title,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = spec.big.titleColor,
)
Spacer(Modifier.height(UiSpacing.Tiny))
Text(
text = spec.big.subtitle,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = spec.big.subtitleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (busyLabel != null) {
Spacer(Modifier.height(UiSpacing.Small))
Text(
text = busyLabel,
fontSize = 12.sp,
color = MiuixTheme.colorScheme.primary,
)
}
}
}
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
StatusMetricCard(
spec = spec.firstSmall,
modifier = Modifier.fillMaxWidth().weight(1f),
)
Spacer(Modifier.height(UiSpacing.PageItem))
StatusMetricCard(
spec = spec.secondSmall,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
}
@Composable
private fun StatusMetricCard(spec: StatusSmallCardSpec, modifier: Modifier) {
val haptics = rememberAppHaptics()
Card(
modifier = modifier,
insideMargin = PaddingValues(UiSpacing.Large),
pressFeedbackType = PressFeedbackType.Tilt,
onClick = haptics.press,
) {
Text(
text = spec.title,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Text(
text = spec.value,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = MiuixTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -0,0 +1,282 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeDown
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.VolumeDown
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
enum class VirtualButtonAction(
val id: String,
val title: String,
val icon: ImageVector,
val keycode: Int?,
) {
MORE("more", "更多", Icons.Default.MoreVert, null),
HOME("home", "主页", Icons.Default.Home, UiAndroidKeycodes.HOME),
BACK("back", "返回", Icons.AutoMirrored.Filled.ArrowBack, UiAndroidKeycodes.BACK),
APP_SWITCH("app_switch", "多任务", Icons.Default.Apps, UiAndroidKeycodes.APP_SWITCH),
MENU("menu", "菜单", Icons.Default.Menu, UiAndroidKeycodes.MENU),
NOTIFICATION("notification", "通知栏", Icons.Default.Notifications, UiAndroidKeycodes.NOTIFICATION),
VOLUME_UP("volume_up", "音量+", Icons.AutoMirrored.Filled.VolumeUp, UiAndroidKeycodes.VOLUME_UP),
VOLUME_DOWN("volume_down", "音量-", Icons.AutoMirrored.Filled.VolumeDown, UiAndroidKeycodes.VOLUME_DOWN),
VOLUME_MUTE("volume_mute", "静音", Icons.AutoMirrored.Filled.VolumeOff, UiAndroidKeycodes.VOLUME_MUTE),
POWER("power", "锁屏", Icons.Default.PowerSettingsNew, UiAndroidKeycodes.POWER),
SCREENSHOT("screenshot", "截图", Icons.Default.PhotoCamera, UiAndroidKeycodes.SYSRQ),
}
object VirtualButtonActions {
val all = VirtualButtonAction.entries
val defaultOutsideIds = listOf(
VirtualButtonAction.MORE.id,
VirtualButtonAction.HOME.id,
VirtualButtonAction.BACK.id,
)
val defaultMoreIds = listOf(
VirtualButtonAction.APP_SWITCH.id,
VirtualButtonAction.MENU.id,
VirtualButtonAction.NOTIFICATION.id,
VirtualButtonAction.VOLUME_UP.id,
VirtualButtonAction.VOLUME_DOWN.id,
VirtualButtonAction.VOLUME_MUTE.id,
VirtualButtonAction.POWER.id,
VirtualButtonAction.SCREENSHOT.id,
)
private val byId = all.associateBy { it.id }
fun parseStoredIds(raw: String): List<String> {
if (raw.isBlank()) return emptyList()
return raw.split(',').mapNotNull { item ->
val id = item.trim()
id.ifBlank { null }
}
}
fun encodeStoredIds(ids: List<String>): String = ids.joinToString(",")
fun resolveLayout(
outsideIds: List<String>,
moreIds: List<String>,
): Pair<List<VirtualButtonAction>, List<VirtualButtonAction>> {
val outside = mutableListOf<VirtualButtonAction>()
val overflow = mutableListOf<VirtualButtonAction>()
val used = mutableSetOf<String>()
outsideIds.forEach { id ->
val action = byId[id] ?: return@forEach
if (used.add(action.id)) outside.add(action)
}
if (used.add(VirtualButtonAction.MORE.id)) {
outside.add(VirtualButtonAction.MORE)
}
moreIds.forEach { id ->
if (id == VirtualButtonAction.MORE.id) return@forEach
val action = byId[id] ?: return@forEach
if (used.add(action.id)) overflow.add(action)
}
all.forEach { action ->
if (action == VirtualButtonAction.MORE) return@forEach
if (used.add(action.id)) overflow.add(action)
}
return outside to overflow
}
}
class VirtualButtonBar(
private val outsideActions: List<VirtualButtonAction>,
private val moreActions: List<VirtualButtonAction>,
) {
@Composable
fun Preview(
enabled: Boolean,
onPressHaptic: () -> Unit,
onConfirmHaptic: () -> Unit,
onAction: (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier,
) {
val activeContainerColor = MiuixTheme.colorScheme.primary
val disabledContainerColor = MiuixTheme.colorScheme.primary.copy(alpha = 0.35f)
val activeContentColor = MiuixTheme.colorScheme.onPrimary
val disabledContentColor = MiuixTheme.colorScheme.onPrimary.copy(alpha = 0.45f)
var showMorePopup by remember { mutableStateOf(false) }
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
outsideActions.forEach { action ->
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = {
onPressHaptic()
if (action == VirtualButtonAction.MORE) {
showMorePopup = true
} else {
onAction(action)
}
},
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
color = activeContainerColor,
disabledColor = disabledContainerColor,
),
) {
val contentColor = if (enabled) activeContentColor else disabledContentColor
Icon(
action.icon,
contentDescription = action.title,
modifier = Modifier.size(18.dp),
tint = contentColor,
)
androidx.compose.foundation.layout.Spacer(Modifier.width(UiSpacing.Small))
Text(action.title, color = contentColor)
}
if (action == VirtualButtonAction.MORE) {
MorePopup(
show = showMorePopup,
moreActions = moreActions,
onDismiss = { showMorePopup = false },
onConfirmHaptic = onConfirmHaptic,
onAction = {
onAction(it)
showMorePopup = false
},
renderInRootScaffold = false,
)
}
}
}
}
}
@Composable
fun Fullscreen(
onPressHaptic: () -> Unit,
onConfirmHaptic: () -> Unit,
onAction: (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier,
) {
var showMorePopup by remember { mutableStateOf(false) }
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(0.dp),
) {
outsideActions.forEach { action ->
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = {
onPressHaptic()
if (action == VirtualButtonAction.MORE) {
showMorePopup = true
} else {
onAction(action)
}
},
modifier = Modifier.fillMaxWidth(),
cornerRadius = 0.dp,
minHeight = 16.dp,
insideMargin = PaddingValues(0.dp),
colors = ButtonDefaults.buttonColors(
color = Color.Black.copy(alpha = 0.1f),
),
) {
Icon(action.icon, contentDescription = action.title, tint = Color.White)
}
if (action == VirtualButtonAction.MORE) {
MorePopup(
show = showMorePopup,
moreActions = moreActions,
onDismiss = { showMorePopup = false },
onConfirmHaptic = onConfirmHaptic,
onAction = {
onAction(it)
showMorePopup = false
},
renderInRootScaffold = true,
)
}
}
}
}
}
@Composable
private fun MorePopup(
show: Boolean,
moreActions: List<VirtualButtonAction>,
onDismiss: () -> Unit,
onConfirmHaptic: () -> Unit,
onAction: (VirtualButtonAction) -> Unit,
renderInRootScaffold: Boolean,
) {
SuperListPopup(
show = show,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
alignment = PopupPositionProvider.Align.TopEnd,
onDismissRequest = onDismiss,
renderInRootScaffold = renderInRootScaffold,
enableWindowDim = false,
) {
ListPopupColumn {
moreActions.forEachIndexed { index, action ->
DropdownImpl(
text = action.title,
optionSize = moreActions.size,
isSelected = false,
index = index,
onSelectedIndexChange = {
onConfirmHaptic()
onAction(action)
},
)
}
}
}
}
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ScrcpyForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Scrcpy</string>
</resources>

View File

@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ScrcpyForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package io.github.miuzarte.scrcpyforandroid
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}