diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..9f2f4fa --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,218 @@ +name: Android Build and Release + +on: + workflow_dispatch: + push: + tags: + - "*" + +permissions: + contents: write + +jobs: + ci-build: + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + cache: gradle + + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + !~/.gradle/caches/build-cache-* + key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-deps- + + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches/build-cache-* + key: gradle-builds-${{ github.sha }} + restore-keys: gradle-builds- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Enable full ABI matrix for CI build + run: | + perl -0777 -i -pe 's/abiFilters\.add\("armeabi-v7a"\)\s*abiFilters\.add\("arm64-v8a"\)/abiFilters.add("armeabi-v7a")\n abiFilters.add("arm64-v8a")\n abiFilters.add("x86")\n abiFilters.add("x86_64")/s' app/build.gradle.kts + perl -0777 -i -pe 's/include\("armeabi-v7a",\s*"arm64-v8a"\)/include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")/s' app/build.gradle.kts + + - name: Build debug and unsigned release APKs + run: ./gradlew clean assembleDebug assembleRelease + + - name: Collect CI APKs + run: | + set -euxo pipefail + mkdir -p out + + collect_one() { + local src="$1" + local pattern="$2" + local out_name="$3" + local file + file=$(find "$src" -type f -name "*$pattern*.apk" | head -n1 || true) + if [ -n "$file" ]; then + cp "$file" "out/$out_name" + fi + } + + collect_unsigned_release_one() { + local pattern="$1" + local out_name="$2" + local file + file=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*$pattern*.apk" | head -n1 || true) + if [ -n "$file" ]; then + cp "$file" "out/$out_name" + fi + } + + collect_one app/build/outputs/apk/debug universal ci-debug-arm_all.apk + collect_one app/build/outputs/apk/debug arm64-v8a ci-debug-arm64.apk + collect_one app/build/outputs/apk/debug armeabi-v7a ci-debug-arm32.apk + collect_one app/build/outputs/apk/debug x86_64 ci-debug-x86_64.apk + x86_32_debug=$(find app/build/outputs/apk/debug -type f -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true) + if [ -n "$x86_32_debug" ]; then + cp "$x86_32_debug" out/ci-debug-x86_32.apk + fi + + collect_unsigned_release_one universal ci-release-unsigned-arm_all.apk + collect_unsigned_release_one arm64-v8a ci-release-unsigned-arm64.apk + collect_unsigned_release_one armeabi-v7a ci-release-unsigned-arm32.apk + collect_unsigned_release_one x86_64 ci-release-unsigned-x86_64.apk + x86_32_release=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true) + if [ -n "$x86_32_release" ]; then + cp "$x86_32_release" out/ci-release-unsigned-x86_32.apk + fi + + x86_all=$(find app/build/outputs/apk -type f \( -name "*x86_all*.apk" -o -name "*x86-all*.apk" -o -name "*x86universal*.apk" \) | head -n1 || true) + if [ -n "$x86_all" ]; then + cp "$x86_all" out/ci-release-unsigned-x86_all.apk + fi + + ls -la out + + - name: Upload CI artifacts + uses: actions/upload-artifact@v4 + with: + name: android-ci-apks-${{ github.run_number }} + path: out/*.apk + if-no-files-found: error + + release-build: + if: ${{ startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + cache: gradle + + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + !~/.gradle/caches/build-cache-* + key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-deps- + + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches/build-cache-* + key: gradle-builds-${{ github.sha }} + restore-keys: gradle-builds- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Enable full ABI matrix for release build + run: | + perl -0777 -i -pe 's/abiFilters\.add\("armeabi-v7a"\)\s*abiFilters\.add\("arm64-v8a"\)/abiFilters.add("armeabi-v7a")\n abiFilters.add("arm64-v8a")\n abiFilters.add("x86")\n abiFilters.add("x86_64")/s' app/build.gradle.kts + perl -0777 -i -pe 's/include\("armeabi-v7a",\s*"arm64-v8a"\)/include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")/s' app/build.gradle.kts + + - name: Build debug and unsigned release APKs + run: ./gradlew clean assembleDebug assembleRelease + + - name: Collect release assets + run: | + set -euxo pipefail + TAG="${GITHUB_REF_NAME}" + REPO_NAME="${GITHUB_REPOSITORY##*/}" + mkdir -p dist + + collect_one() { + local src="$1" + local pattern="$2" + local out_name="$3" + local file + file=$(find "$src" -type f -name "*$pattern*.apk" | head -n1 || true) + if [ -n "$file" ]; then + cp "$file" "dist/$out_name" + fi + } + + collect_unsigned_release_one() { + local pattern="$1" + local out_name="$2" + local file + file=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*$pattern*.apk" | head -n1 || true) + if [ -n "$file" ]; then + cp "$file" "dist/$out_name" + fi + } + + collect_one app/build/outputs/apk/debug universal "${REPO_NAME}-${TAG}-debug-arm_all.apk" + collect_one app/build/outputs/apk/debug arm64-v8a "${REPO_NAME}-${TAG}-debug-arm64.apk" + collect_one app/build/outputs/apk/debug armeabi-v7a "${REPO_NAME}-${TAG}-debug-arm32.apk" + collect_one app/build/outputs/apk/debug x86_64 "${REPO_NAME}-${TAG}-debug-x86_64.apk" + x86_32_debug=$(find app/build/outputs/apk/debug -type f -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true) + if [ -n "$x86_32_debug" ]; then + cp "$x86_32_debug" "dist/${REPO_NAME}-${TAG}-debug-x86_32.apk" + fi + + collect_unsigned_release_one universal "${REPO_NAME}-${TAG}-release-unsigned-arm_all.apk" + collect_unsigned_release_one arm64-v8a "${REPO_NAME}-${TAG}-release-unsigned-arm64.apk" + collect_unsigned_release_one armeabi-v7a "${REPO_NAME}-${TAG}-release-unsigned-arm32.apk" + collect_unsigned_release_one x86_64 "${REPO_NAME}-${TAG}-release-unsigned-x86_64.apk" + x86_32_release=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true) + if [ -n "$x86_32_release" ]; then + cp "$x86_32_release" "dist/${REPO_NAME}-${TAG}-release-unsigned-x86_32.apk" + fi + + x86_all=$(find app/build/outputs/apk -type f \( -name "*x86_all*.apk" -o -name "*x86-all*.apk" -o -name "*x86universal*.apk" \) | head -n1 || true) + if [ -n "$x86_all" ]; then + cp "$x86_all" "dist/${REPO_NAME}-${TAG}-release-unsigned-x86_all.apk" + fi + + ls -la dist + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.repository }} ${{ github.ref_name }} + generate_release_notes: true + files: dist/*.apk diff --git a/ScrcpyForAndroid.code-workspace b/ScrcpyForAndroid.code-workspace new file mode 100644 index 0000000..06010f7 --- /dev/null +++ b/ScrcpyForAndroid.code-workspace @@ -0,0 +1,19 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../scrcpy" + }, + { + "path": "../miuix" + }, + { + "path": "../adblib" + }, + { + "path": "../KernelSU" + } + ], +} \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c31a956 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "io.github.miuzarte.scrcpyforandroid" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "io.github.miuzarte.scrcpyforandroid" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.0.1" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } + buildToolsVersion = "36.0.0" + ndkVersion = "28.2.13676358" +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.material) + implementation(libs.miuix) + implementation(libs.miuix.icons) + implementation(libs.miuix.navigation3.ui) + + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/io/github/miuzarte/scrcpyforandroid/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/github/miuzarte/scrcpyforandroid/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..1be3e1b --- /dev/null +++ b/app/src/androidTest/java/io/github/miuzarte/scrcpyforandroid/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..758ec89 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/bin/scrcpy-server-v3.3.4 b/app/src/main/assets/bin/scrcpy-server-v3.3.4 new file mode 100644 index 0000000..89054b7 Binary files /dev/null and b/app/src/main/assets/bin/scrcpy-server-v3.3.4 differ diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt new file mode 100644 index 0000000..c9fda86 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -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() + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt new file mode 100644 index 0000000..3161ab5 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -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() + private val surfaceIdentityMap = ConcurrentHashMap() + private val decoderMap = ConcurrentHashMap() + 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() + 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 ioCall(task: () -> T): T { + return try { + executor.submit { 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, + val audioEncoders: List, + val videoEncoderTypes: Map = emptyMap(), + val audioEncoderTypes: Map = emptyMap(), + val rawOutput: String = "", +) + +data class ScrcpyCameraSizeLists( + val sizes: List, + 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 +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt new file mode 100644 index 0000000..571f79d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt @@ -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" +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt new file mode 100644 index 0000000..7e5161d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt @@ -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" +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt new file mode 100644 index 0000000..daa7025 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt @@ -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) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt new file mode 100644 index 0000000..b3f2653 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt @@ -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 +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt new file mode 100644 index 0000000..2991b2d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt @@ -0,0 +1,6 @@ +package io.github.miuzarte.scrcpyforandroid.constants + +object UiMotion { + const val PageSwitchDampingRatio = 0.9f + const val PageSwitchStiffness = 700f +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt new file mode 100644 index 0000000..4337411 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt new file mode 100644 index 0000000..dc28085 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt @@ -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() }, + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt new file mode 100644 index 0000000..6d0fdd1 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -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, +) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt new file mode 100644 index 0000000..0f9f415 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt @@ -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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt new file mode 100644 index 0000000..24088bb --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -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 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 { + 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() + @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() + 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) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt new file mode 100644 index 0000000..0589b49 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -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" + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt new file mode 100644 index 0000000..d34bf09 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt @@ -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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt new file mode 100644 index 0000000..0405161 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt @@ -0,0 +1,1060 @@ +package io.github.miuzarte.scrcpyforandroid.nativecore + +import android.util.Log +import android.view.KeyEvent +import java.io.BufferedInputStream +import java.io.BufferedReader +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException +import java.io.InputStreamReader +import java.nio.file.Path +import java.security.SecureRandom +import java.util.ArrayDeque +import java.util.LinkedHashSet +import kotlin.concurrent.thread +import kotlin.math.roundToInt + +class ScrcpySessionManager(private val adbService: NativeAdbService) { + @Volatile + private var activeSession: ActiveSession? = null + @Volatile + private var videoConsumer: ((VideoPacket) -> Unit)? = null + @Volatile + private var videoReaderThread: Thread? = null + @Volatile + private var audioConsumer: ((AudioPacket) -> Unit)? = null + @Volatile + private var audioReaderThread: Thread? = null + @Volatile + private var lastServerCommand: String? = null + private val serverLogBuffer = ArrayDeque() + + @Synchronized + fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo { + stop() + synchronized(this) { + serverLogBuffer.clear() + } + val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } + val scid = random.nextInt(Int.MAX_VALUE) + val socketName = socketNameFor(scid) + + try { + adbService.push(serverJarPath, targetPath) + val cmd = buildServerCommand(targetPath, scid, options) + lastServerCommand = cmd + Log.i(TAG, "start(): socket=$socketName codec=${options.videoCodec} audio=${options.audio} audioCodec=${options.audioCodec}") + val serverStream = adbService.openShellStream(cmd) + val serverLogThread = startServerLogThread(serverStream, socketName) + Thread.sleep(SERVER_BOOT_DELAY_MS) + + // The first socket always carries device meta (and dummy byte). + val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true) + val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream)) + + var videoStream: AdbSocketStream? = null + var videoInput: DataInputStream? = null + var audioStream: AdbSocketStream? = null + var audioInput: DataInputStream? = null + var controlStream: AdbSocketStream? = null + + when { + options.video -> { + videoStream = firstStream + videoInput = firstInput + } + options.audio -> { + audioStream = firstStream + audioInput = firstInput + } + options.control -> { + controlStream = firstStream + } + else -> { + throw IllegalArgumentException("At least one of video/audio/control must be enabled") + } + } + + if (options.video && videoStream == null) { + val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + videoStream = vStream + videoInput = DataInputStream(BufferedInputStream(vStream.inputStream)) + } + + if (options.audio && audioStream == null) { + val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + audioStream = aStream + audioInput = DataInputStream(BufferedInputStream(aStream.inputStream)) + } + + if (options.control && controlStream == null) { + controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + } + + val deviceName = readDeviceName(firstInput) + val audioCodecId = if (options.audio) audioCodecIdFromName(options.audioCodec) else 0 + val codecId: Int + val width: Int + val height: Int + if (options.video) { + val vInput = checkNotNull(videoInput) + codecId = vInput.readInt() + width = vInput.readInt() + height = vInput.readInt() + } else { + codecId = 0 + width = 0 + height = 0 + } + + val sessionInfo = SessionInfo( + deviceName = deviceName, + codecId = codecId, + codecName = codecName(codecId), + width = width, + height = height, + audioCodecId = audioCodecId, + controlEnabled = controlStream != null, + ) + + activeSession = ActiveSession( + info = sessionInfo, + socketName = socketName, + serverStream = serverStream, + serverLogThread = serverLogThread, + videoStream = videoStream, + videoInput = videoInput, + audioStream = audioStream, + audioInput = audioInput, + controlStream = controlStream, + controlWriter = controlStream?.outputStream?.let { ScrcpyControlWriter(DataOutputStream(it)) }, + ) + return sessionInfo + } catch (t: Throwable) { + val tail = snapshotServerLogs(maxLines = 120) + val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail" + throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t) + } + } + + @Synchronized + fun attachVideoConsumer(consumer: (VideoPacket) -> Unit) { + val session = activeSession ?: throw IllegalStateException("scrcpy session not started") + val vInput = session.videoInput ?: return + val vStream = session.videoStream ?: return + videoConsumer = consumer + if (videoReaderThread?.isAlive == true) { + return + } + + videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { + while (activeSession === session && !vStream.closed) { + try { + val ptsAndFlags = vInput.readLong() + val packetSize = vInput.readInt() + if (packetSize <= 0) { + continue + } + + val payload = ByteArray(packetSize) + vInput.readFully(payload) + + val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L + val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L + val ptsUs = ptsAndFlags and PACKET_PTS_MASK + videoConsumer?.invoke( + VideoPacket( + data = payload, + ptsUs = ptsUs, + isConfig = config, + isKeyFrame = keyFrame, + ), + ) + } catch (_: EOFException) { + break + } catch (e: InterruptedException) { + // Ignore transient interrupts while session remains active. + if (activeSession !== session || vStream.closed) { + break + } + Thread.interrupted() + } catch (e: Exception) { + Log.w(TAG, "video reader failed", e) + break + } + } + } + } + + @Synchronized + fun clearVideoConsumer() { + videoConsumer = null + } + + @Synchronized + fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) { + val session = activeSession ?: throw IllegalStateException("scrcpy session not started") + val aInput = session.audioInput ?: return // audio disabled or unavailable + val aStream = session.audioStream ?: return + audioConsumer = consumer + if (audioReaderThread?.isAlive == true) return + + audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { + val streamCodecId = try { + aInput.readInt() + } catch (e: Exception) { + Log.w(TAG, "audio codec header read failed", e) + return@thread + } + when (streamCodecId) { + AUDIO_DISABLED -> { + Log.w(TAG, "audio disabled by server") + return@thread + } + AUDIO_ERROR -> { + Log.e(TAG, "audio stream configuration error from server") + return@thread + } + else -> { + Log.i(TAG, "audio stream codec=0x${streamCodecId.toUInt().toString(16)}") + } + } + if (session.info.audioCodecId != 0 && streamCodecId != session.info.audioCodecId) { + Log.w( + TAG, + "audio codec mismatch: requested=0x${session.info.audioCodecId.toUInt().toString(16)} stream=0x${streamCodecId.toUInt().toString(16)}", + ) + } + + while (activeSession === session && !aStream.closed) { + try { + val ptsAndFlags = aInput.readLong() + val packetSize = aInput.readInt() + if (packetSize <= 0) continue + + val payload = ByteArray(packetSize) + aInput.readFully(payload) + + val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L + val ptsUs = ptsAndFlags and PACKET_PTS_MASK + audioConsumer?.invoke(AudioPacket(data = payload, ptsUs = ptsUs, isConfig = isConfig)) + } catch (_: EOFException) { + break + } catch (e: InterruptedException) { + if (activeSession !== session || aStream.closed) break + Thread.interrupted() + } catch (e: Exception) { + Log.w(TAG, "audio reader failed", e) + break + } + } + } + } + + @Synchronized + fun clearAudioConsumer() { + audioConsumer = null + } + + @Synchronized + fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) { + requireControlWriter().injectKeycode(action, keycode, repeat, metaState) + } + + @Synchronized + fun injectText(text: String) { + requireControlWriter().injectText(text) + } + + @Synchronized + fun injectTouch( + action: Int, + pointerId: Long, + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + pressure: Float, + actionButton: Int, + buttons: Int, + ) { + requireControlWriter().injectTouch(action, pointerId, x, y, screenWidth, screenHeight, pressure, actionButton, buttons) + } + + @Synchronized + fun injectScroll(x: Int, y: Int, screenWidth: Int, screenHeight: Int, hScroll: Float, vScroll: Float, buttons: Int) { + requireControlWriter().injectScroll(x, y, screenWidth, screenHeight, hScroll, vScroll, buttons) + } + + @Synchronized + fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) { + requireControlWriter().pressBackOrScreenOn(action) + } + + @Synchronized + fun setDisplayPower(on: Boolean) { + requireControlWriter().setDisplayPower(on) + } + + @Synchronized + fun stop() { + val session = activeSession ?: return + activeSession = null + videoConsumer = null + audioConsumer = null + + if (Thread.currentThread() !== videoReaderThread) { + runCatching { videoReaderThread?.interrupt() } + runCatching { videoReaderThread?.join(300) } + } + videoReaderThread = null + + if (Thread.currentThread() !== audioReaderThread) { + runCatching { audioReaderThread?.interrupt() } + runCatching { audioReaderThread?.join(300) } + } + audioReaderThread = null + + runCatching { session.controlStream?.close() } + runCatching { session.audioStream?.close() } + runCatching { session.videoStream?.close() } + runCatching { session.serverStream.close() } + if (Thread.currentThread() !== session.serverLogThread) { + runCatching { session.serverLogThread.interrupt() } + runCatching { session.serverLogThread.join(300) } + } + } + + fun isStarted(): Boolean = activeSession != null + + fun getLastServerCommand(): String? = lastServerCommand + + @Synchronized + fun listEncoders(serverJarPath: Path, options: ScrcpyStartOptions): EncoderLists { + val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } + adbService.push(serverJarPath, targetPath) + val cmd = buildServerCommand( + targetPath = targetPath, + scid = 0x1234abcd, + options = options.copy( + video = false, + audio = false, + control = false, + listEncoders = true, + cleanup = false, + ), + ) + Log.i(TAG, "listEncoders(): cmd=$cmd") + // scrcpy encoder list is printed in logs, so merge stderr into stdout. + val output = adbService.shell("$cmd 2>&1") + val parsed = parseEncoderLists(output) + val preview = output.lineSequence().take(40).joinToString("\n") + Log.i( + TAG, + "listEncoders(): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview", + ) + return parsed.copy(rawOutput = output) + } + + @Synchronized + fun listCameraSizes(serverJarPath: Path, options: ScrcpyStartOptions): CameraSizeLists { + val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } + adbService.push(serverJarPath, targetPath) + val cmd = buildServerCommand( + targetPath = targetPath, + scid = 0x2234abcd, + options = options.copy( + video = false, + audio = false, + control = false, + listCameraSizes = true, + cleanup = false, + ), + ) + Log.i(TAG, "listCameraSizes(): cmd=$cmd") + val output = adbService.shell("$cmd 2>&1") + val parsed = parseCameraSizeLists(output) + val preview = output.lineSequence().take(40).joinToString("\n") + Log.i( + TAG, + "listCameraSizes(): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview", + ) + return parsed.copy(rawOutput = output) + } + + private fun requireControlWriter(): ScrcpyControlWriter { + return activeSession?.controlWriter ?: throw IllegalStateException("scrcpy control channel not available") + } + + private fun buildServerCommand(targetPath: String, scid: Int, options: ScrcpyStartOptions): String { + val serverArgs = buildServerArgs(scid, options) + return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server ${options.serverVersion} $serverArgs" + } + + private fun buildServerArgs(scid: Int, options: ScrcpyStartOptions): String { + val videoSource = options.videoSource.trim().ifBlank { "display" } + val isCameraSource = videoSource.equals("camera", ignoreCase = true) + val cameraId = options.cameraId.trim() + val hasCameraId = cameraId.isNotBlank() + val hasExplicitCameraSize = options.cameraSize.trim().isNotBlank() + val hasValidCameraFps = options.cameraFps > 0 + + val args = listOf( + ServerArg( + type = ServerArgType.STRING, + key = "scid", + value = String.format("%08x", scid), + ), + ServerArg( + type = ServerArgType.STRING, + key = "log_level", + value = "debug", + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "tunnel_forward", + value = true, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "cleanup", + value = options.cleanup, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "send_device_meta", + value = true, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "send_frame_meta", + value = true, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "send_dummy_byte", + value = true, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "send_codec_meta", + value = true, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "video", + value = options.video, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "audio", + value = options.audio, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "control", + value = options.control, + ), + ServerArg( + type = ServerArgType.STRING, + key = "video_source", + value = videoSource, + ), + ServerArg( + type = ServerArgType.NUMBER, + key = "max_size", + value = options.maxSize, + ), + ServerArg( + type = ServerArgType.STRING, + key = "video_codec", + value = options.videoCodec, + ), + ServerArg( + type = ServerArgType.NUMBER, + key = "video_bit_rate", + value = options.videoBitRate, zeroValueBehavior = ZeroValueBehavior.KEEP, + ), + ServerArg( + type = ServerArgType.NUMBER, + key = "max_fps", + value = options.maxFps, + ), + ServerArg( + type = ServerArgType.STRING, + key = "camera_id", + value = cameraId, + includeWhen = isCameraSource, + ), + ServerArg( + type = ServerArgType.STRING, + key = "camera_facing", + value = options.cameraFacing.trim(), + includeWhen = isCameraSource && !hasCameraId, + ), + ServerArg( + type = ServerArgType.STRING, + key = "camera_size", + value = options.cameraSize.trim(), + includeWhen = isCameraSource, + ), + ServerArg( + type = ServerArgType.STRING, + key = "camera_ar", + value = options.cameraAr.trim(), + includeWhen = isCameraSource && !hasExplicitCameraSize, + ), + ServerArg( + type = ServerArgType.NUMBER, + key = "camera_fps", + value = options.cameraFps, + includeWhen = isCameraSource, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "camera_high_speed", + value = options.cameraHighSpeed, + includeWhen = isCameraSource && options.cameraHighSpeed && hasValidCameraFps, + ), + ServerArg( + type = ServerArgType.STRING, + key = "audio_codec", + value = options.audioCodec, + includeWhen = options.audio, + ), + ServerArg( + type = ServerArgType.NUMBER, + key = "audio_bit_rate", + value = options.audioBitRate, + includeWhen = options.audio, + zeroValueBehavior = ZeroValueBehavior.KEEP, + ), + ServerArg( + type = ServerArgType.STRING, + key = "audio_source", + value = options.audioSource.trim(), + includeWhen = options.audio, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "audio_dup", + value = options.audioDup, includeWhen = options.audioDup, + ), + ServerArg( + type = ServerArgType.STRING, + key = "video_encoder", + value = options.videoEncoder.trim(), + ), + ServerArg( + type = ServerArgType.STRING, + key = "video_codec_options", + value = options.videoCodecOptions.trim(), + ), + ServerArg( + type = ServerArgType.STRING, + key = "audio_encoder", + value = options.audioEncoder.trim(), + ), + ServerArg( + type = ServerArgType.STRING, + key = "audio_codec_options", + value = options.audioCodecOptions.trim(), + ), + ServerArg( + type = ServerArgType.STRING, + key = "new_display", + value = options.newDisplay.trim(), + ), + ServerArg( + type = ServerArgType.NUMBER, + key = "display_id", + value = options.displayId, + ), + ServerArg( + type = ServerArgType.STRING, + key = "crop", + value = options.crop.trim(), + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "list_encoders", + value = options.listEncoders, includeWhen = options.listEncoders, + ), + ServerArg( + type = ServerArgType.BOOLEAN, + key = "list_camera_sizes", + value = options.listCameraSizes, includeWhen = options.listCameraSizes, + ), + // Reserved for future args that require repeated/list values. + // ServerArg(type = ServerArgType.LIST, key = "_unused_list", value = emptyList(), includeWhen = false), + ) + + val rendered = args.mapNotNull { it.render() } + val header = rendered.firstOrNull() + val kv = rendered.drop(1) + return if (header == null) { + kv.joinToString(" ") + } else { + "$header ${kv.joinToString(" ")}".trim() + } + } + + private enum class ServerArgType { + NUMBER, + STRING, + BOOLEAN, + LIST, + } + + private enum class ZeroValueBehavior { + KEEP, + OMIT, + } + + private data class ServerArg( + val type: ServerArgType, + val key: String, + val value: T?, + val includeWhen: Boolean = true, + val zeroValueBehavior: ZeroValueBehavior = ZeroValueBehavior.OMIT, + ) { + fun render(): String? { + if (!includeWhen) return null + return when (type) { + ServerArgType.STRING -> renderString(value as? String) + ServerArgType.BOOLEAN -> renderBoolean(value as? Boolean) + ServerArgType.NUMBER -> renderNumber(value as? Number) + ServerArgType.LIST -> renderList(value as? Iterable<*>) + } + } + + private fun renderString(raw: String?): String? { + val normalized = raw?.trim().orEmpty() + if (normalized.isBlank()) return null + return "$key=$normalized" + } + + private fun renderBoolean(raw: Boolean?): String? { + val normalized = raw ?: return null + return "$key=$normalized" + } + + private fun renderNumber(raw: Number?): String? { + val normalized = raw ?: return null + val asDouble = normalized.toDouble() + if (asDouble == 0.0 && zeroValueBehavior == ZeroValueBehavior.OMIT) return null + val valueString = when (normalized) { + is Float -> normalized.toString() + is Double -> normalized.toString() + else -> normalized.toLong().toString() + } + return "$key=$valueString" + } + + private fun renderList(raw: Iterable<*>?): String? { + val items = raw + ?.mapNotNull { it?.toString()?.trim() } + ?.filter { it.isNotBlank() } + .orEmpty() + if (items.isEmpty()) return null + return "$key=${items.joinToString(",")}" + } + } + + private fun parseEncoderLists(output: String): EncoderLists { + val video = LinkedHashSet() + val audio = LinkedHashSet() + val videoTypes = linkedMapOf() + val audioTypes = linkedMapOf() + VIDEO_ENCODER_REGEX.findAll(output).forEach { match -> + video.add(match.groupValues[1]) + } + AUDIO_ENCODER_REGEX.findAll(output).forEach { match -> + audio.add(match.groupValues[1]) + } + // Fallback for log formats that include codec+encoder in one line. + VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> + video.add(match.groupValues[1]) + } + AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> + audio.add(match.groupValues[1]) + } + VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> + val name = match.groupValues[1] + val type = match.groupValues[2] + if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) { + videoTypes[name] = type + } + } + AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> + val name = match.groupValues[1] + val type = match.groupValues[2] + if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) { + audioTypes[name] = type + } + } + return EncoderLists( + videoEncoders = video.toList(), + audioEncoders = audio.toList(), + videoEncoderTypes = videoTypes, + audioEncoderTypes = audioTypes, + ) + } + + private fun parseCameraSizeLists(output: String): CameraSizeLists { + val sizes = LinkedHashSet() + CAMERA_SIZE_REGEX.findAll(output).forEach { match -> + sizes.add(match.groupValues[1]) + } + CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match -> + sizes.add(match.groupValues[1]) + } + return CameraSizeLists(sizes = sizes.toList()) + } + + private fun startServerLogThread(serverStream: AdbSocketStream, socketName: String): Thread { + return thread(start = true, name = "scrcpy-server-log") { + try { + BufferedReader(InputStreamReader(serverStream.inputStream, Charsets.UTF_8)).use { reader -> + while (true) { + val line = reader.readLine() ?: break + appendServerLog(line) + Log.i(TAG, "[server:$socketName] $line") + } + } + } catch (e: Exception) { + if (activeSession != null) { + Log.w(TAG, "server log thread failed", e) + } + } + } + } + + @Synchronized + private fun appendServerLog(line: String) { + if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) { + serverLogBuffer.removeFirst() + } + serverLogBuffer.addLast(line) + } + + @Synchronized + private fun snapshotServerLogs(maxLines: Int): String { + if (serverLogBuffer.isEmpty()) { + return "" + } + val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) + return serverLogBuffer.toList().takeLast(take).joinToString("\n") + } + + private fun openAbstractSocketWithRetry(socketName: String, expectDummyByte: Boolean): AdbSocketStream { + var lastEx: Exception? = null + repeat(CONNECT_RETRY_COUNT) { attempt -> + try { + val stream = adbService.openAbstractSocket(socketName) + if (expectDummyByte) { + val value = stream.inputStream.read() + if (value < 0) { + stream.close() + throw EOFException("scrcpy dummy byte missing") + } + } + return stream + } catch (e: Exception) { + lastEx = e + if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS) + } + } + throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx) + } + + private fun readDeviceName(input: DataInputStream): String { + val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH) + input.readFully(buffer) + val firstZero = buffer.indexOf(0) + val length = if (firstZero >= 0) firstZero else buffer.size + return buffer.copyOf(length).toString(Charsets.UTF_8) + } + + private fun codecName(codecId: Int): String { + return when (codecId) { + VIDEO_CODEC_H264 -> "h264" + VIDEO_CODEC_H265 -> "h265" + VIDEO_CODEC_AV1 -> "av1" + else -> "unknown" + } + } + + private fun audioCodecIdFromName(name: String): Int { + return when (name.lowercase()) { + "opus" -> AUDIO_CODEC_OPUS + "aac" -> AUDIO_CODEC_AAC + "raw" -> AUDIO_CODEC_RAW + "flac" -> AUDIO_CODEC_FLAC + else -> 0 + } + } + + data class SessionInfo( + val deviceName: String, + val codecId: Int, + val codecName: String, + val width: Int, + val height: Int, + val audioCodecId: Int = 0, + val controlEnabled: Boolean, + ) + + data class VideoPacket( + 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 VideoPacket + + 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 + } + } + + data class AudioPacket( + val data: ByteArray, + val ptsUs: Long, + val isConfig: Boolean, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AudioPacket + + if (ptsUs != other.ptsUs) return false + if (isConfig != other.isConfig) 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 + data.contentHashCode() + return result + } + } + + data class ScrcpyStartOptions( + val serverVersion: String = "3.3.4", + val serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH, + val video: Boolean = true, + val audio: Boolean = true, + val control: Boolean = true, + val cleanup: Boolean = true, + val maxSize: Int = 0, + val maxFps: Float = 0f, + val videoBitRate: Int = 8_000_000, + val videoCodec: String = "h264", + val audioBitRate: Int = 128_000, + val audioCodec: String = "opus", + 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 newDisplay: String = "", + val displayId: Int? = null, + val crop: String = "", + val listEncoders: Boolean = false, + val listCameraSizes: Boolean = false, + ) + + data class EncoderLists( + val videoEncoders: List, + val audioEncoders: List, + val videoEncoderTypes: Map = emptyMap(), + val audioEncoderTypes: Map = emptyMap(), + val rawOutput: String = "", + ) + + data class CameraSizeLists( + val sizes: List, + val rawOutput: String = "", + ) + + private data class ActiveSession( + val info: SessionInfo, + val socketName: String, + val serverStream: AdbSocketStream, + val serverLogThread: Thread, + val videoStream: AdbSocketStream?, + val videoInput: DataInputStream?, + val audioStream: AdbSocketStream?, + val audioInput: DataInputStream?, + val controlStream: AdbSocketStream?, + val controlWriter: ScrcpyControlWriter?, + ) + + private class ScrcpyControlWriter(private val output: DataOutputStream) { + @Synchronized + fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) { + output.writeByte(TYPE_INJECT_KEYCODE) + output.writeByte(action) + output.writeInt(keycode) + output.writeInt(repeat) + output.writeInt(metaState) + output.flush() + } + + @Synchronized + fun injectText(text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + output.writeByte(TYPE_INJECT_TEXT) + output.writeInt(bytes.size) + output.write(bytes) + output.flush() + } + + @Synchronized + fun injectTouch( + action: Int, + pointerId: Long, + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + pressure: Float, + actionButton: Int, + buttons: Int, + ) { + output.writeByte(TYPE_INJECT_TOUCH_EVENT) + output.writeByte(action) + output.writeLong(pointerId) + writePosition(x, y, screenWidth, screenHeight) + output.writeShort(encodeUnsignedFixedPoint16(pressure)) + output.writeInt(actionButton) + output.writeInt(buttons) + output.flush() + } + + @Synchronized + fun injectScroll(x: Int, y: Int, screenWidth: Int, screenHeight: Int, hScroll: Float, vScroll: Float, buttons: Int) { + output.writeByte(TYPE_INJECT_SCROLL_EVENT) + writePosition(x, y, screenWidth, screenHeight) + output.writeShort(encodeSignedFixedPoint16(hScroll / 16f)) + output.writeShort(encodeSignedFixedPoint16(vScroll / 16f)) + output.writeInt(buttons) + output.flush() + } + + @Synchronized + fun pressBackOrScreenOn(action: Int) { + output.writeByte(TYPE_BACK_OR_SCREEN_ON) + output.writeByte(action) + output.flush() + } + + @Synchronized + fun setDisplayPower(on: Boolean) { + output.writeByte(TYPE_SET_DISPLAY_POWER) + output.writeBoolean(on) + output.flush() + } + + private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) { + output.writeInt(x) + output.writeInt(y) + output.writeShort(screenWidth) + output.writeShort(screenHeight) + } + + private fun encodeUnsignedFixedPoint16(value: Float): Int { + val clamped = value.coerceIn(0f, 1f) + return if (clamped >= 1f) { + 0xffff + } else { + (clamped * 65536f).roundToInt().coerceIn(0, 0xfffe) + } + } + + private fun encodeSignedFixedPoint16(value: Float): Int { + val clamped = value.coerceIn(-1f, 1f) + if (clamped >= 1f) { + return 0x7fff + } + if (clamped <= -1f) { + return -0x8000 + } + return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe) + } + } + + companion object { + const val DEFAULT_SERVER_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" + private const val TAG = "ScrcpySessionManager" + private const val SERVER_BOOT_DELAY_MS = 200L + private const val SERVER_LOG_BUFFER_MAX_LINES = 400 + + private const val CONNECT_RETRY_COUNT = 100 + private const val CONNECT_RETRY_DELAY_MS = 100L + private const val DEVICE_NAME_FIELD_LENGTH = 64 + private const val PACKET_FLAG_CONFIG = 1L shl 63 + private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 + private const val PACKET_PTS_MASK = (1L shl 62) - 1 + private const val VIDEO_CODEC_H264 = 0x68323634 + private const val VIDEO_CODEC_H265 = 0x68323635 + private const val VIDEO_CODEC_AV1 = 0x00617631 + private const val AUDIO_CODEC_OPUS = 0x6f707573 + private const val AUDIO_CODEC_AAC = 0x00616163 + private const val AUDIO_CODEC_FLAC = 0x666c6163 + private const val AUDIO_CODEC_RAW = 0x00726177 + // Audio stream disable codes from server (writeDisableStream) + private const val AUDIO_DISABLED = 0 + private const val AUDIO_ERROR = 1 + private const val TYPE_INJECT_KEYCODE = 0 + private const val TYPE_INJECT_TEXT = 1 + private const val TYPE_INJECT_TOUCH_EVENT = 2 + private const val TYPE_INJECT_SCROLL_EVENT = 3 + private const val TYPE_BACK_OR_SCREEN_ON = 4 + private const val TYPE_SET_DISPLAY_POWER = 10 + private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)") + private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)") + private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['\"]?([^'\"\s]+)""") + private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['\"]?([^'\"\s]+)""") + private val VIDEO_ENCODER_TYPE_REGEX = Regex("""--video-codec=[^\s]+\s+--video-encoder=([^\s]+).*?\((hw|sw)\)""") + private val AUDIO_ENCODER_TYPE_REGEX = Regex("""--audio-codec=[^\s]+\s+--audio-encoder=([^\s]+).*?\((hw|sw)\)""") + private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)") + private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b") + + private val random = SecureRandom() + + private fun socketNameFor(scid: Int): String { + return "scrcpy_%08x".format(scid) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt new file mode 100644 index 0000000..2484790 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -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, + 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, + videoEncoderTypeMap: Map, + videoEncoderIndex: Int, + onVideoEncoderChange: (String) -> Unit, + videoCodecOptions: String, + onVideoCodecOptionsChange: (String) -> Unit, + audioEncoderDropdownItems: List, + audioEncoderTypeMap: Map, + 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 { + 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 -> "" + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt new file mode 100644 index 0000000..86f515c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -0,0 +1,1080 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.annotation.SuppressLint +import android.app.Activity +import android.util.Log +import android.view.WindowManager +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.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DragIndicator +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut +import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo +import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings +import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices +import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings +import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget +import io.github.miuzarte.scrcpyforandroid.services.saveQuickDevices +import io.github.miuzarte.scrcpyforandroid.services.saveDevicePageSettings +import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty +import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice +import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel +import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard +import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen +import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile +import io.github.miuzarte.scrcpyforandroid.widgets.LogsPanel +import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard +import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard +import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle +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.StatusCard +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.SnackbarHostState +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.extra.SuperBottomSheet +import top.yukonga.miuix.kmp.theme.MiuixTheme +import kotlin.math.roundToInt + +private const val ADB_CONNECT_TIMEOUT_MS = 3_000L +private const val ADB_KEEPALIVE_INTERVAL_MS = 15_000L +private const val ADB_KEEPALIVE_TIMEOUT_MS = 2_000L +private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F" +private const val LOG_TAG = "DevicePage" + +private val DeviceShortcutStateListSaver = listSaver, String>( + save = { list -> + list.map { item -> + listOf( + item.id, + item.name, + item.host, + item.port.toString(), + if (item.online) "1" else "0", + ).joinToString(DEVICE_SHORTCUT_SEPARATOR) + } + }, + restore = { saved -> + saved.mapNotNull { line -> + val parts = line.split(DEVICE_SHORTCUT_SEPARATOR) + if (parts.size != 5) return@mapNotNull null + val port = parts[3].toIntOrNull() ?: return@mapNotNull null + DeviceShortcut( + id = parts[0], + name = parts[1], + host = parts[2], + port = port, + online = parts[4] == "1", + ) + }.toMutableStateList() + }, +) + +private val StringStateListSaver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() }, +) + +@Composable +fun DeviceTabScreen( + contentPadding: PaddingValues, + nativeCore: NativeCoreFacade, + snack: SnackbarHostState, + scrollBehavior: ScrollBehavior, + keepScreenOnWhenStreamingEnabled: Boolean, + virtualButtonsOutside: List, + virtualButtonsInMore: List, + previewCardHeightDp: Int, + customServerUri: String?, + serverRemotePath: String, + onServerRemotePathChange: (String) -> Unit, + videoCodec: String, + onVideoCodecChange: (String) -> Unit, + audioEnabled: Boolean, + onAudioEnabledChange: (Boolean) -> Unit, + audioCodec: String, + onAudioCodecChange: (String) -> Unit, + noControl: Boolean, + onNoControlChange: (Boolean) -> Unit, + videoEncoder: String, + onVideoEncoderChange: (String) -> Unit, + videoCodecOptions: String, + onVideoCodecOptionsChange: (String) -> Unit, + audioEncoder: String, + onAudioEncoderChange: (String) -> Unit, + audioCodecOptions: String, + onAudioCodecOptionsChange: (String) -> 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, + cameraArInput: String, + onCameraArInputChange: (String) -> Unit, + cameraFpsInput: String, + onCameraFpsInputChange: (String) -> Unit, + cameraHighSpeed: Boolean, + onCameraHighSpeedChange: (Boolean) -> Unit, + noAudioPlayback: Boolean, + onNoAudioPlaybackChange: (Boolean) -> Unit, + noVideo: Boolean, + requireAudio: Boolean, + onRequireAudioChange: (Boolean) -> Unit, + turnScreenOff: Boolean, + onTurnScreenOffChange: (Boolean) -> Unit, + maxSizeInput: String, + onMaxSizeInputChange: (String) -> Unit, + maxFpsInput: String, + onMaxFpsInputChange: (String) -> 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, + videoEncoderOptions: List, + onVideoEncoderOptionsChange: (List) -> Unit, + onVideoEncoderTypeMapChange: (Map) -> Unit, + audioEncoderOptions: List, + onAudioEncoderOptionsChange: (List) -> Unit, + onAudioEncoderTypeMapChange: (Map) -> Unit, + cameraSizeOptions: List, + onCameraSizeOptionsChange: (List) -> Unit, + onSessionStartedChange: (Boolean) -> Unit, + onRefreshEncodersActionChange: ((() -> Unit)?) -> Unit, + onRefreshCameraSizesActionChange: ((() -> Unit)?) -> Unit, + onClearLogsActionChange: ((() -> Unit)?) -> Unit, + onCanClearLogsChange: (Boolean) -> Unit, + onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, + onOpenAdvancedPage: () -> Unit, + onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, +) { + val context = LocalContext.current + val haptics = rememberAppHaptics() + val virtualButtonLayout = remember(virtualButtonsOutside, virtualButtonsInMore) { + VirtualButtonActions.resolveLayout(virtualButtonsOutside, virtualButtonsInMore) + } + val activity = remember(context) { context as? Activity } + val initialSettings = remember(context) { loadDevicePageSettings(context) } + val scope = rememberCoroutineScope() + + var busy by rememberSaveable { mutableStateOf(false) } + var statusLine by rememberSaveable { mutableStateOf("未连接") } + var adbConnected by rememberSaveable { mutableStateOf(false) } + var currentTargetHost by rememberSaveable { mutableStateOf("") } + var currentTargetPort by rememberSaveable { mutableIntStateOf(AppDefaults.DefaultAdbPort) } + var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } + var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } + var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) } + var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") } + var sessionInfoCodec by rememberSaveable { mutableStateOf("") } + var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } + var sessionInfo by remember { + mutableStateOf(null) + } + LaunchedEffect(sessionInfoWidth, sessionInfoHeight, sessionInfoDeviceName, sessionInfoCodec, sessionInfoControlEnabled) { + sessionInfo = if (sessionInfoDeviceName.isNotBlank()) { + ScrcpySessionInfo( + width = sessionInfoWidth, + height = sessionInfoHeight, + deviceName = sessionInfoDeviceName, + codec = sessionInfoCodec, + controlEnabled = sessionInfoControlEnabled, + ) + } else { + null + } + } + var previewControlsVisible by rememberSaveable { mutableStateOf(false) } + var editingDeviceId by rememberSaveable { mutableStateOf(null) } + var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } + var showReorderSheet by rememberSaveable { mutableStateOf(false) } + var adbConnecting by rememberSaveable { mutableStateOf(false) } + + var connectHost by rememberSaveable { mutableStateOf("") } + var connectPort by rememberSaveable { mutableStateOf(AppDefaults.DefaultAdbPort.toString()) } + var quickConnectInput by rememberSaveable { mutableStateOf(initialSettings.quickConnectInput) } + var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } + var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } + + var bitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.bitRateMbps) } + var bitRateInput by rememberSaveable { mutableStateOf(initialSettings.bitRateInput) } + var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) } + val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget(currentTargetHost, currentTargetPort) else null + + val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() } + val quickDevices = rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() } + + LaunchedEffect(eventLog.size) { + onCanClearLogsChange(eventLog.isNotEmpty()) + } + + fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) { + val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + val line = "[$timestamp] $message" + eventLog.add(0, line) + if (eventLog.size > AppDefaults.MaxEventLogLines) { + eventLog.removeRange(AppDefaults.MaxEventLogLines, eventLog.size) + } + when (level) { + Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error) else Log.e(LOG_TAG, message) + Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error) else Log.w(LOG_TAG, message) + Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error) else Log.d(LOG_TAG, message) + else -> if (error != null) Log.i(LOG_TAG, message, error) else Log.i(LOG_TAG, message) + } + } + + fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { + val audioSupported = sdkInt !in 0..<30 + audioForwardingSupported = audioSupported + if (!audioSupported && audioEnabled) { + onAudioEnabledChange(false) + logEvent("设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", Log.WARN) + } + val cameraSupported = sdkInt !in 0..<31 + cameraMirroringSupported = cameraSupported + if (!cameraSupported && videoSourcePreset == "camera") { + onVideoSourcePresetChange("display") + logEvent("设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", Log.WARN) + } + } + + suspend fun connectWithTimeout(host: String, port: Int): Boolean { + return withContext(Dispatchers.IO) { + withTimeout(ADB_CONNECT_TIMEOUT_MS) { + nativeCore.adbConnect(host, port) + } + } + } + + suspend fun keepAliveCheck(host: String, port: Int): Boolean { + return withContext(Dispatchers.IO) { + withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { + val connected = nativeCore.adbIsConnected() + if (!connected) { + return@withTimeout false + } + runCatching { + nativeCore.adbShell("echo -n 1") + true + }.getOrElse { false } + } + } + } + + fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { + if (busy) return + scope.launch { + busy = true + try { + block() + } catch (_: TimeoutCancellationException) { + logEvent("$label 超时", Log.WARN) + } catch (e: IllegalArgumentException) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 参数错误: $detail", Log.WARN, e) + snack.showSnackbar("$label 参数错误: $detail") + } catch (e: Exception) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 失败: $detail", Log.ERROR, e) + } finally { + busy = false + onFinished?.invoke() + } + } + } + + fun runAdbConnect(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { + if (adbConnecting) return + scope.launch { + adbConnecting = true + try { + block() + } catch (_: TimeoutCancellationException) { + logEvent("$label 超时", Log.WARN) + } catch (e: IllegalArgumentException) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 参数错误: $detail", Log.WARN, e) + snack.showSnackbar("$label 参数错误: $detail") + } catch (e: Exception) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 失败: $detail", Log.ERROR, e) + } finally { + adbConnecting = false + onFinished?.invoke() + } + } + } + + fun refreshEncoderLists() { + if (!adbConnected) return + val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.DefaultServerRemotePath } + runCatching { + nativeCore.scrcpyListEncoders( + customServerUri = customServerUri, + remotePath = remotePath, + ) + }.onSuccess { lists -> + onVideoEncoderOptionsChange(lists.videoEncoders) + onAudioEncoderOptionsChange(lists.audioEncoders) + onVideoEncoderTypeMapChange(lists.videoEncoderTypes) + onAudioEncoderTypeMapChange(lists.audioEncoderTypes) + if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) { + onVideoEncoderChange("") + } + if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { + onAudioEncoderChange("") + } + logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") + if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { + logEvent("提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN) + val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") + if (preview.isNotBlank()) { + logEvent("编码器原始输出: $preview", Log.DEBUG) + } + } + }.onFailure { e -> + onVideoEncoderOptionsChange(emptyList()) + onAudioEncoderOptionsChange(emptyList()) + onVideoEncoderTypeMapChange(emptyMap()) + onAudioEncoderTypeMapChange(emptyMap()) + logEvent("读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e) + } + } + + fun refreshCameraSizeLists() { + if (!adbConnected) return + val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.DefaultServerRemotePath } + runCatching { + nativeCore.scrcpyListCameraSizes( + customServerUri = customServerUri, + remotePath = remotePath, + ) + }.onSuccess { lists -> + onCameraSizeOptionsChange(lists.sizes) + if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) { + onCameraSizePresetChange("") + } + logEvent("camera sizes 已刷新: count=${lists.sizes.size}") + if (lists.sizes.isEmpty()) { + val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") + if (preview.isNotBlank()) { + logEvent("camera sizes 原始输出: $preview", Log.DEBUG) + } + } + }.onFailure { e -> + onCameraSizeOptionsChange(emptyList()) + logEvent("读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e) + } + } + + fun handleAdbConnected(host: String, port: Int) { + currentTargetHost = host + currentTargetPort = port + + val info = fetchConnectedDeviceInfo(nativeCore, host, port) + val fullLabel = if (info.serial.isNotBlank()) { + "${info.model} (${info.serial})" + } else { + info.model + } + + connectedDeviceLabel = info.model + applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) + updateQuickDeviceNameIfEmpty(context, quickDevices, host, port, fullLabel) + connectHost = host + connectPort = port.toString() + statusLine = "$host:$port" + + logEvent("ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}") + scope.launch { + snack.showSnackbar("ADB 已连接") + } + refreshEncoderLists() + refreshCameraSizeLists() + } + + LaunchedEffect(bitRateInput) { + val parsed = bitRateInput.toFloatOrNull() ?: return@LaunchedEffect + bitRateMbps = parsed.coerceAtLeast(0.1f) + } + + LaunchedEffect( + quickConnectInput, + bitRateMbps, + bitRateInput, + audioBitRateKbps, + maxSizeInput, + maxFpsInput, + noControl, + videoEncoder, + videoCodecOptions, + audioEncoder, + audioCodecOptions, + audioDup, + audioSourcePreset, + audioSourceCustom, + videoSourcePreset, + cameraIdInput, + cameraFacingPreset, + cameraSizePreset, + cameraSizeCustom, + cameraArInput, + cameraFpsInput, + cameraHighSpeed, + noAudioPlayback, + noVideo, + requireAudio, + turnScreenOff, + newDisplayWidth, + newDisplayHeight, + newDisplayDpi, + displayIdInput, + cropWidth, + cropHeight, + cropX, + cropY, + ) { + saveDevicePageSettings( + context, + DevicePageSettings( + quickConnectInput = quickConnectInput, + bitRateMbps = bitRateMbps, + bitRateInput = bitRateInput, + audioBitRateKbps = audioBitRateKbps, + maxSizeInput = maxSizeInput, + maxFpsInput = maxFpsInput, + noControl = noControl, + videoEncoder = videoEncoder, + videoCodecOptions = videoCodecOptions, + audioEncoder = audioEncoder, + audioCodecOptions = audioCodecOptions, + audioDup = audioDup, + audioSourcePreset = audioSourcePreset, + audioSourceCustom = audioSourceCustom, + videoSourcePreset = videoSourcePreset, + cameraIdInput = cameraIdInput, + cameraFacingPreset = cameraFacingPreset, + cameraSizePreset = cameraSizePreset, + cameraSizeCustom = cameraSizeCustom, + cameraArInput = cameraArInput, + cameraFpsInput = cameraFpsInput, + cameraHighSpeed = cameraHighSpeed, + noAudioPlayback = noAudioPlayback, + noVideo = noVideo, + requireAudio = requireAudio, + turnScreenOff = turnScreenOff, + newDisplayWidth = newDisplayWidth, + newDisplayHeight = newDisplayHeight, + newDisplayDpi = newDisplayDpi, + displayIdInput = displayIdInput, + cropWidth = cropWidth, + cropHeight = cropHeight, + cropX = cropX, + cropY = cropY, + ), + ) + } + + LaunchedEffect(Unit) { + if (quickDevices.isEmpty()) { + quickDevices.clear() + quickDevices.addAll(loadQuickDevices(context)) + } + } + + LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, quickDevices.size) { + val activeId = if (adbConnected && currentTargetHost.isNotBlank()) { + "$currentTargetHost:$currentTargetPort" + } else { + null + } + for (index in quickDevices.indices) { + val item = quickDevices[index] + val shouldOnline = activeId != null && item.id == activeId + if (item.online != shouldOnline) { + quickDevices[index] = item.copy(online = shouldOnline) + } + } + } + + LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { + if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect + + val host = currentTargetHost + val port = currentTargetPort + while (adbConnected && currentTargetHost == host && currentTargetPort == port) { + delay(ADB_KEEPALIVE_INTERVAL_MS) + val alive = runCatching { keepAliveCheck(host, port) }.getOrElse { false } + if (alive) continue + + logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN) + val reconnected = runCatching { connectWithTimeout(host, port) }.getOrElse { false } + adbConnected = reconnected + if (reconnected) { + statusLine = "$host:$port" + logEvent("ADB 自动重连成功: $host:$port") + scope.launch { + snack.showSnackbar("ADB 自动重连成功") + } + } else { + statusLine = "ADB 连接断开" + connectedDeviceLabel = "未连接" + sessionInfo = null + logEvent("ADB 自动重连失败: $host:$port", Log.ERROR) + scope.launch { + snack.showSnackbar("ADB 自动重连失败") + } + break + } + } + } + + DisposableEffect(nativeCore) { + val listener: (Int, Int) -> Unit = { width, height -> + sessionInfo = sessionInfo?.copy(width = width, height = height) + } + nativeCore.addVideoSizeListener(listener) + onDispose { + nativeCore.removeVideoSizeListener(listener) + } + } + + DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionInfo != null) { + val window = activity?.window + val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionInfo != null + if (window != null && shouldKeepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + onDispose { + if (window != null && shouldKeepScreenOn) { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + + LaunchedEffect(sessionInfo) { + if (sessionInfo != null) { + sessionInfoWidth = sessionInfo?.width ?: 0 + sessionInfoHeight = sessionInfo?.height ?: 0 + sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() + sessionInfoCodec = sessionInfo?.codec.orEmpty() + sessionInfoControlEnabled = sessionInfo?.controlEnabled == true + } else { + sessionInfoWidth = 0 + sessionInfoHeight = 0 + sessionInfoDeviceName = "" + sessionInfoCodec = "" + sessionInfoControlEnabled = false + } + onSessionStartedChange(sessionInfo != null) + } + + DisposableEffect(Unit) { + onRefreshEncodersActionChange { + runBusy("刷新编码器") { refreshEncoderLists() } + } + onRefreshCameraSizesActionChange { + runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() } + } + onClearLogsActionChange { + eventLog.clear() + } + onOpenReorderDevicesActionChange { + showReorderSheet = true + } + onDispose { + onRefreshEncodersActionChange(null) + onRefreshCameraSizesActionChange(null) + onClearLogsActionChange(null) + onCanClearLogsChange(false) + onOpenReorderDevicesActionChange(null) + onSessionStartedChange(false) + } + } + + SuperBottomSheet( + show = showReorderSheet, + title = "调整设备排序", + onDismissRequest = { showReorderSheet = false }, + ) { + SortableCardList( + title = "设备列表", + items = quickDevices.map { device -> + SortableCardItem( + id = device.id, + title = device.name.ifBlank { device.host }, + subtitle = "${device.host}:${device.port}", + ) + }, + modifier = Modifier.padding(horizontal = UiSpacing.CardContent), + transferDirection = SortTransferDirection.NONE, + onLongPressHaptic = { haptics.press() }, + onDrop = { payload: SortDropPayload -> + val fromIndex = quickDevices.indexOfFirst { it.id == payload.itemId } + if (fromIndex < 0) return@SortableCardList + val steps = (payload.deltaY / 54f).roundToInt() + if (steps == 0) return@SortableCardList + val toIndex = (fromIndex + steps).coerceIn(0, quickDevices.lastIndex) + if (toIndex == fromIndex) return@SortableCardList + val moved = quickDevices.removeAt(fromIndex) + quickDevices.add(toIndex, moved) + saveQuickDevices(context, quickDevices) + }, + ) + Spacer(Modifier.height(UiSpacing.Large)) + } + + fun sendVirtualButtonAction(action: VirtualButtonAction) { + val keycode = action.keycode ?: return + runBusy("发送 ${action.title}") { + nativeCore.scrcpyInjectKeycode(0, keycode) + nativeCore.scrcpyInjectKeycode(1, keycode) + } + } + + if (editingDeviceId != null) { + val device = quickDevices.firstOrNull { it.id == editingDeviceId } + if (device != null) { + DeviceEditorScreen( + contentPadding = contentPadding, + device = device, + onSave = { updated -> + val idx = quickDevices.indexOfFirst { it.id == device.id } + if (idx >= 0) { + quickDevices[idx] = updated.copy(online = quickDevices[idx].online) + saveQuickDevices(context, quickDevices) + } + editingDeviceId = null + }, + onDelete = { + quickDevices.removeAll { it.id == device.id } + saveQuickDevices(context, quickDevices) + editingDeviceId = null + }, + onBack = { editingDeviceId = null }, + ) + return + } + editingDeviceId = null + } + + // 设备 + AppPageLazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + item { + StatusCard( + statusLine = statusLine, + adbConnected = adbConnected, + streaming = sessionInfo != null, + sessionInfo = sessionInfo, + busyLabel = null, + connectedDeviceLabel = connectedDeviceLabel, + ) + } + + itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device -> + val host = device.host + val port = device.port + val isConnectedTarget = adbConnected && currentTarget?.host == host && currentTarget.port == port + + DeviceTile( + device = device, + actionText = if (isConnectedTarget) "断开" else "连接", + actionEnabled = !busy && !adbConnecting, + actionInProgress = adbConnecting && activeDeviceActionId == device.id, + onLongPress = { editingDeviceId = device.id }, + onContentClick = { + scope.launch { + snack.showSnackbar("长按可编辑设备") + } + }, + onAction = { + haptics.press() + if (isConnectedTarget) { + activeDeviceActionId = device.id + runBusy("断开 ADB", onFinished = { activeDeviceActionId = null }) { + nativeCore.adbDisconnect() + adbConnected = false + currentTargetHost = "" + currentTargetPort = AppDefaults.DefaultAdbPort + audioForwardingSupported = true + cameraMirroringSupported = true + sessionInfo = null + statusLine = "未连接" + connectedDeviceLabel = "未连接" + upsertQuickDevice(context, quickDevices, host, port, false) + logEvent("ADB 已断开: ${device.name}") + scope.launch { + snack.showSnackbar("ADB 已断开") + } + } + } else { + activeDeviceActionId = device.id + runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { + val ok = connectWithTimeout(host, port) + adbConnected = ok + upsertQuickDevice(context, quickDevices, host, port, ok) + if (ok) { + handleAdbConnected(host, port) + } else { + statusLine = "ADB 连接失败" + logEvent("ADB 连接失败: $host:$port", Log.ERROR) + scope.launch { + snack.showSnackbar("ADB 连接失败") + } + } + } + } + }, + ) + } + + if (!adbConnected) item { + // "快速连接" + QuickConnectCard( + input = quickConnectInput, + onInputChange = { quickConnectInput = it }, + enabled = !adbConnecting, + onAddDevice = { + val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard + upsertQuickDevice(context, quickDevices, target.host, target.port, online = false) + scope.launch { + snack.showSnackbar("已添加设备: ${target.host}:${target.port}") + } + }, + onConnect = { + val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard + runAdbConnect("连接 ADB") { + val ok = connectWithTimeout(target.host, target.port) + adbConnected = ok + upsertQuickDevice(context, quickDevices, target.host, target.port, ok) + if (ok) { + handleAdbConnected(target.host, target.port) + } else { + statusLine = "ADB 连接失败" + logEvent("ADB 连接失败: ${target.host}:${target.port}", Log.ERROR) + scope.launch { + snack.showSnackbar("ADB 连接失败") + } + } + } + }, + ) + SectionSmallTitle(text = "无线配对", showLeadingSpacer = false) + // "使用配对码配对设备" + PairingCard( + busy = busy, + onPair = { host, port, code -> + runBusy("执行配对") { + val ok = nativeCore.adbPair( + host.trim(), + port.toIntOrNull() ?: AppDefaults.DefaultAdbPort, + code.trim(), + ) + logEvent(if (ok) "配对成功" else "配对失败", if (ok) Log.INFO else Log.ERROR) + scope.launch { + snack.showSnackbar(if (ok) "配对成功" else "配对失败") + } + } + }, + ) + } + + if (adbConnected) { + item { + ConfigPanel( + busy = busy, + bitRateMbps = bitRateMbps, + onBitRateSliderChange = { + bitRateMbps = it + @SuppressLint("DefaultLocale") + bitRateInput = String.format("%.1f", it) + }, + onBitRateInputChange = { bitRateInput = it }, + audioBitRateKbps = audioBitRateKbps, + onAudioBitRateChange = { audioBitRateKbps = it }, + videoCodec = videoCodec, + onVideoCodecChange = onVideoCodecChange, + audioEnabled = audioEnabled, + onAudioEnabledChange = onAudioEnabledChange, + audioForwardingSupported = audioForwardingSupported, + audioCodec = audioCodec, + onAudioCodecChange = onAudioCodecChange, + onOpenAdvanced = onOpenAdvancedPage, + onStartStopHaptic = { haptics.press() }, + onStart = { + runBusy("启动 scrcpy") { + if (noVideo && !audioEnabled) { + throw IllegalArgumentException("--no-video 需要同时启用音频") + } + if (audioEnabled && audioSourcePreset == "custom" && audioSourceCustom.isBlank()) { + throw IllegalArgumentException("audio-source 选择自定义时不能为空") + } + val resolvedVideoSource = videoSourcePreset.trim().ifBlank { "display" } + if (resolvedVideoSource == "camera" && !cameraMirroringSupported) { + throw IllegalArgumentException("camera mirroring 需要 Android 12+ (SDK 31+)") + } + val resolvedCameraSize = when (cameraSizePreset) { + "custom" -> cameraSizeCustom.trim() + else -> cameraSizePreset.trim() + } + if (resolvedVideoSource == "camera" && cameraSizePreset == "custom" && resolvedCameraSize.isBlank()) { + throw IllegalArgumentException("camera-size 选择自定义时不能为空") + } + val resolvedCameraId = cameraIdInput.trim() + val resolvedCameraFacing = cameraFacingPreset.trim() + if (resolvedVideoSource == "camera" && resolvedCameraId.isNotBlank() && resolvedCameraFacing.isNotBlank()) { + throw IllegalArgumentException("camera-id 与 camera-facing 不能同时设置") + } + val resolvedCameraAr = cameraArInput.trim() + val resolvedCameraFps = + cameraFpsInput.filter(Char::isDigit).toIntOrNull() ?: 0 + if (resolvedVideoSource == "camera" && cameraHighSpeed && resolvedCameraFps <= 0) { + throw IllegalArgumentException("启用 --camera-high-speed 时,--camera-fps 不能为 0") + } + val maxSize = + maxSizeInput.filter(Char::isDigit).toIntOrNull()?.takeIf { it > 0 } + ?: 0 + val maxFps = + maxFpsInput.filter(Char::isDigit).toIntOrNull()?.toFloat() ?: 0f + if (resolvedVideoSource == "camera" && resolvedCameraSize.isNotBlank() && (maxSize > 0 || resolvedCameraAr.isNotBlank())) { + throw IllegalArgumentException("显式 camera-size 时不能同时设置 --max-size 或 --camera-ar") + } + val bitRateBps = (bitRateMbps * 1_000_000).toInt() + val audioBitRateBps = (audioBitRateKbps.coerceAtLeast(1)) * 1_000 + val resolvedAudioSource = when (audioSourcePreset) { + "custom" -> audioSourceCustom.trim() + else -> audioSourcePreset.trim() + } + val newDisplayArg = buildNewDisplayArg( + newDisplayWidth.filter(Char::isDigit), + newDisplayHeight.filter(Char::isDigit), + newDisplayDpi.filter(Char::isDigit), + ) + val displayId = displayIdInput.filter(Char::isDigit).toIntOrNull() + ?.takeIf { it > 0 } + val crop = buildCropArg( + cropWidth.filter(Char::isDigit), + cropHeight.filter(Char::isDigit), + cropX.filter(Char::isDigit), + cropY.filter(Char::isDigit), + ) + val effectiveTurnScreenOff = turnScreenOff && !noControl + val session = nativeCore.scrcpyStart( + NativeCoreFacade.defaultStartRequest( + customServerUri = customServerUri, + maxSize = maxSize, + maxFps = maxFps, + videoBitRate = bitRateBps, + remotePath = serverRemotePath.trim(), + videoCodec = videoCodec, + audio = audioEnabled, + audioCodec = audioCodec, + audioBitRate = audioBitRateBps, + noControl = noControl, + videoEncoder = videoEncoder, + videoCodecOptions = videoCodecOptions, + audioEncoder = audioEncoder, + audioCodecOptions = audioCodecOptions, + audioDup = audioDup, + audioSource = resolvedAudioSource, + videoSource = resolvedVideoSource, + cameraId = resolvedCameraId, + cameraFacing = resolvedCameraFacing, + cameraSize = resolvedCameraSize, + cameraAr = resolvedCameraAr, + cameraFps = resolvedCameraFps, + cameraHighSpeed = cameraHighSpeed, + noAudioPlayback = noAudioPlayback, + noVideo = noVideo, + requireAudio = requireAudio, + turnScreenOff = effectiveTurnScreenOff, + newDisplay = newDisplayArg, + displayId = displayId, + crop = crop, + ), + ) + sessionInfo = session + statusLine = "scrcpy 运行中" + @SuppressLint("DefaultLocale") + val videoDetail = if (noVideo) { + "off" + } else { + "${session.codec} ${session.width}x${session.height} @${ + String.format( + "%.1f", + bitRateMbps + ) + }Mbps" + } + val audioDetail = if (!audioEnabled) { + "off" + } else { + val playback = if (noAudioPlayback) "(no-playback)" else "" + "$audioCodec ${audioBitRateKbps}kbps source=${resolvedAudioSource.ifBlank { "default" }}$playback" + } + logEvent("scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, control=${!noControl}, turnScreenOff=$effectiveTurnScreenOff, maxSize=${if (maxSize > 0) maxSize else "auto"}, maxFps=${if (maxFps > 0f) maxFps else "auto"}") + scope.launch { + snack.showSnackbar("scrcpy 已启动") + } + nativeCore.getLastScrcpyServerCommand()?.let { command -> + logEvent("scrcpy-server args: $command") + } + } + }, + onStop = { + runBusy("停止 scrcpy") { + nativeCore.scrcpyStop() + sessionInfo = null + statusLine = + currentTarget?.let { "${it.host}:${it.port}" } ?: "ADB 已连接" + logEvent("scrcpy 已停止") + scope.launch { + snack.showSnackbar("scrcpy 已停止") + } + } + }, + sessionStarted = sessionInfo != null, + ) + } + + if ( + sessionInfo != null && + sessionInfo!!.width > 0 && + sessionInfo!!.height > 0 + ) { + item { + PreviewCard( + sessionInfo = sessionInfo, + nativeCore = nativeCore, + previewHeightDp = previewCardHeightDp.coerceAtLeast(120), + controlsVisible = previewControlsVisible, + onTapped = { + previewControlsVisible = true + scope.launch { + delay(2000) + previewControlsVisible = false + } + }, + onOpenFullscreen = { + val info = sessionInfo ?: return@PreviewCard + onOpenFullscreenPage(info) + }, + onOpenFullscreenHaptic = { haptics.press() }, + ) + } + item { + VirtualButtonCard( + busy = busy, + outsideActions = virtualButtonLayout.first, + moreActions = virtualButtonLayout.second, + onPressHaptic = { haptics.press() }, + onConfirmHaptic = { haptics.confirm() }, + onAction = ::sendVirtualButtonAction, + ) + } + } + + item { + Spacer(Modifier.height(UiSpacing.PageItem)) + LogsPanel(lines = eventLog) + } + } + + // TODO: 放进 [AppPageLazyColumn] 里 + item { Spacer(Modifier.height(UiSpacing.BottomContent)) } + } +} + +private fun buildNewDisplayArg(width: String, height: String, dpi: String): String { + val w = width.toIntOrNull()?.takeIf { it > 0 } + val h = height.toIntOrNull()?.takeIf { it > 0 } + val d = dpi.toIntOrNull()?.takeIf { it > 0 } + val sizePart = if (w != null && h != null) "${w}x${h}" else "" + return when { + sizePart.isNotEmpty() && d != null -> "$sizePart/$d" + sizePart.isNotEmpty() -> sizePart + d != null -> "/$d" + else -> "" + } +} + +private fun buildCropArg(width: String, height: String, x: String, y: String): String { + val w = width.toIntOrNull()?.takeIf { it > 0 } ?: return "" + val h = height.toIntOrNull()?.takeIf { it > 0 } ?: return "" + val ox = x.toIntOrNull()?.takeIf { it >= 0 } ?: return "" + val oy = y.toIntOrNull()?.takeIf { it >= 0 } ?: return "" + return "$w:$h:$ox:$oy" +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt new file mode 100644 index 0000000..defcd35 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt @@ -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, + virtualButtonsInMore: List, + 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) + }, + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt new file mode 100644 index 0000000..a46ec25 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -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(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, 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() } + val audioEncoderOptions = remember { mutableStateListOf() } + val videoEncoderTypeMap = remember { mutableStateMapOf() } + val audioEncoderTypeMap = remember { mutableStateMapOf() } + val cameraSizeOptions = remember { mutableStateListOf() } + 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 { + 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 { 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), + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt new file mode 100644 index 0000000..d4d1980 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt new file mode 100644 index 0000000..39da73e --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt @@ -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, + moreIds: List, + onLayoutChange: (outside: List, more: List) -> 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, 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, + to: androidx.compose.runtime.snapshots.SnapshotStateList, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt new file mode 100644 index 0000000..bd863f9 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt @@ -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, + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt new file mode 100644 index 0000000..e0e6c80 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt @@ -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, + steps: Int, + enabled: Boolean = true, + unit: String = "", + zeroStateText: String? = null, + showUnitWhenZeroState: Boolean = false, + showKeyPoints: Boolean = false, + keyPoints: List = 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? = 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(), + ) + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt new file mode 100644 index 0000000..1d4fca2 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt @@ -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) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt new file mode 100644 index 0000000..6dcaf27 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt @@ -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, + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt new file mode 100644 index 0000000..5a2b1bc --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt @@ -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 { + val raw = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE) + .getString(AppPreferenceKeys.QuickDevices, "") + .orEmpty() + + if (raw.isBlank()) return emptyList() + + val result = mutableListOf() + 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) { + 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, + 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, + 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) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt new file mode 100644 index 0000000..e1180df --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -0,0 +1,1055 @@ +package io.github.miuzarte.scrcpyforandroid.widgets + +import android.annotation.SuppressLint +import android.graphics.SurfaceTexture +import android.view.MotionEvent +import android.view.Surface +import android.view.TextureView +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +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.aspectRatio +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AddLink +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material.icons.rounded.LinkOff +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.onSizeChanged +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 androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import top.yukonga.miuix.kmp.basic.Button +import top.yukonga.miuix.kmp.basic.ButtonDefaults +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.CardDefaults +import top.yukonga.miuix.kmp.basic.CircularProgressIndicator +import top.yukonga.miuix.kmp.basic.Icon +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.extra.SuperDropdown +import top.yukonga.miuix.kmp.extra.SuperSwitch +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor +import top.yukonga.miuix.kmp.utils.PressFeedbackType +import kotlin.math.roundToInt + +private val VIDEO_CODEC_OPTIONS = listOf( + "h264" to "H.264", + "h265" to "H.265", + "av1" to "AV1", +) + +private val AUDIO_CODEC_OPTIONS = listOf( + "opus" to "Opus", + "aac" to "AAC", + "flac" to "FLAC", + "raw" to "RAW", +) + +private object UiMotionActions { + 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 +} + +@Composable +internal fun StatusCard( + statusLine: String, + adbConnected: Boolean, + streaming: Boolean, + sessionInfo: ScrcpySessionInfo?, + busyLabel: String?, + connectedDeviceLabel: String, +) { + val cleanStatusLine = normalizeStatusLine(statusLine) + val spec = when { + streaming && sessionInfo != null -> { + val streamCardColor = when { + isDynamicColor -> MiuixTheme.colorScheme.secondaryContainer + isSystemInDarkTheme() -> Color(0xFF1A3825) + else -> Color(0xFFDFFAE4) + } + val streamTextColor = when { + isDynamicColor -> MiuixTheme.colorScheme.onSecondaryContainer + isSystemInDarkTheme() -> Color.White + else -> Color(0xFF111111) + } + val streamIconColor = if (isDynamicColor) { + MiuixTheme.colorScheme.primary.copy(alpha = 0.8f) + } else { + Color(0xFF36D167) + } + StatusCardSpec( + big = StatusBigCardSpec( + title = "投屏中 (视频流)", + subtitle = sessionInfo.deviceName, + containerColor = streamCardColor, + titleColor = streamTextColor, + subtitleColor = streamTextColor, + icon = Icons.Rounded.CheckCircleOutline, + iconTint = streamIconColor, + ), + firstSmall = StatusSmallCardSpec( + "分辨率", + "${sessionInfo.width}×${sessionInfo.height}", + ), + secondSmall = StatusSmallCardSpec( + "编解码器", + sessionInfo.codec, + ), + ) + } + + adbConnected -> StatusCardSpec( + big = StatusBigCardSpec( + title = "ADB 已连接", + subtitle = cleanStatusLine, + containerColor = MiuixTheme.colorScheme.primaryContainer, + titleColor = MiuixTheme.colorScheme.onPrimaryContainer, + subtitleColor = MiuixTheme.colorScheme.onPrimaryContainer, + icon = Icons.Rounded.Wifi, + iconTint = MiuixTheme.colorScheme.primary.copy(alpha = 0.6f), + ), + firstSmall = StatusSmallCardSpec( + "当前设备", + connectedDeviceLabel, + ), + secondSmall = StatusSmallCardSpec( + "状态", + "空闲", + ), + ) + + else -> StatusCardSpec( + big = StatusBigCardSpec( + title = "ADB 未连接", + subtitle = "", + containerColor = MiuixTheme.colorScheme.secondaryContainer, + titleColor = MiuixTheme.colorScheme.onSecondaryContainer, + subtitleColor = MiuixTheme.colorScheme.onSecondaryContainer, + icon = Icons.Rounded.LinkOff, + iconTint = MiuixTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f), + ), + firstSmall = StatusSmallCardSpec( + "当前设备", + "N/A", + ), + secondSmall = StatusSmallCardSpec( + "状态", + "N/A", + ), + ) + } + + StatusCardLayout(spec = spec, busyLabel = busyLabel) +} + +@Composable +internal fun PairingCard( + busy: Boolean, + onPair: (host: String, port: String, code: String) -> Unit, +) { + val showPairDialog = remember { mutableStateOf(false) } + val holdDownState = remember { mutableStateOf(false) } + + Card { + SuperArrow( + title = "使用配对码配对设备", + onClick = { + showPairDialog.value = true + holdDownState.value = true + }, + holdDownState = holdDownState.value, + enabled = !busy, + ) + } + + PairingDialog( + showDialog = showPairDialog.value, + enabled = !busy, + onDismissRequest = { showPairDialog.value = false }, + onDismissFinished = { holdDownState.value = false }, + onConfirm = { host, port, code -> + showPairDialog.value = false + onPair(host, port, code) + }, + ) +} + +@Composable +internal fun PreviewCard( + sessionInfo: ScrcpySessionInfo?, + nativeCore: NativeCoreFacade, + previewHeightDp: Int, + controlsVisible: Boolean, + onTapped: () -> Unit, + onOpenFullscreenHaptic: (() -> Unit)? = null, + onOpenFullscreen: () -> Unit, +) { + val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls") + + Card { + Box( + modifier = Modifier + .fillMaxWidth() + .height(previewHeightDp.coerceAtLeast(120).dp) + .pointerInput(sessionInfo) { detectTapGestures(onTap = { onTapped() }) }, + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val sessionAspect = if (sessionInfo == null || sessionInfo.height == 0) { + 16f / 9f + } else { + sessionInfo.width.toFloat() / sessionInfo.height.toFloat() + } + val containerAspect = maxWidth.value / maxHeight.value + val fittedModifier = if (sessionAspect > containerAspect) { + Modifier.fillMaxWidth().aspectRatio(sessionAspect) + } else { + Modifier.fillMaxHeight().aspectRatio(sessionAspect) + } + + Box( + modifier = Modifier + .align(Alignment.Center) + .then(fittedModifier), + ) { + ScrcpyVideoSurface( + modifier = Modifier.fillMaxSize(), + nativeCore = nativeCore, + session = sessionInfo, + ) + } + } + + if (sessionInfo != null) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(UiSpacing.CardContent), + ) { + Button( + onClick = { + onOpenFullscreenHaptic?.invoke() + onOpenFullscreen() + }, + modifier = Modifier.alpha(alpha), + ) { + Icon(Icons.Default.Fullscreen, contentDescription = "全屏") + Spacer(Modifier.width(UiSpacing.SectionTitleBottom)) + Text("全屏") + } + } + } + } + } +} + +@Composable +internal fun VirtualButtonCard( + busy: Boolean, + outsideActions: List, + moreActions: List, + onPressHaptic: () -> Unit, + onConfirmHaptic: () -> Unit, + onAction: (VirtualButtonAction) -> Unit, +) { + val bar = remember(outsideActions, moreActions) { + VirtualButtonBar( + outsideActions = outsideActions, + moreActions = moreActions, + ) + } + + Card { + bar.Preview( + enabled = !busy, + onPressHaptic = onPressHaptic, + onConfirmHaptic = onConfirmHaptic, + onAction = onAction, + modifier = Modifier + .fillMaxWidth() + .padding(UiSpacing.CardContent), + ) + } +} + +@Composable +internal fun ConfigPanel( + busy: Boolean, + bitRateMbps: Float, + onBitRateSliderChange: (Float) -> Unit, + onBitRateInputChange: (String) -> Unit, + audioBitRateKbps: Int, + onAudioBitRateChange: (Int) -> Unit, + videoCodec: String, + onVideoCodecChange: (String) -> Unit, + audioEnabled: Boolean, + onAudioEnabledChange: (Boolean) -> Unit, + audioForwardingSupported: Boolean, + audioCodec: String, + onAudioCodecChange: (String) -> Unit, + onOpenAdvanced: () -> Unit, + onStartStopHaptic: (() -> Unit)? = null, + onStart: () -> Unit, + onStop: () -> Unit, + sessionStarted: Boolean, +) { + val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } } + val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst { it.first == videoCodec }.coerceAtLeast(0) + val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } } + val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst { it.first == audioCodec }.coerceAtLeast(0) + val audioBitRatePresetIndex = presetIndexFromInput(audioBitRateKbps.toString(), ScrcpyPresets.AudioBitRate) + + SectionSmallTitle(text = "Scrcpy") + Card { + SuperSwitch( + title = "音频转发", + summary = "转发设备音频到本机 (Android 11+)", + checked = audioEnabled, + onCheckedChange = onAudioEnabledChange, + enabled = !sessionStarted && audioForwardingSupported, + ) + SuperDropdown( + title = "音频编码", + summary = "--audio-codec", + items = audioCodecItems, + selectedIndex = audioCodecIndex, + onSelectedIndexChange = { onAudioCodecChange(AUDIO_CODEC_OPTIONS[it].first) }, + enabled = !sessionStarted && audioEnabled, + ) + if (audioEnabled && (audioCodec == "opus" || audioCodec == "aac")) { + SuperSlide( + title = "音频码率", + summary = "--audio-bit-rate", + value = audioBitRatePresetIndex.toFloat(), + onValueChange = { value -> + val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) + onAudioBitRateChange(ScrcpyPresets.AudioBitRate[idx]) + }, + valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), + enabled = !sessionStarted, + unit = "Kbps", + displayText = audioBitRateKbps.toString(), + inputInitialValue = audioBitRateKbps.toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 1f..Float.MAX_VALUE, + onInputConfirm = { raw -> + raw.toIntOrNull()?.takeIf { it > 0 }?.let { onAudioBitRateChange(it) } + }, + ) + } + SuperDropdown( + title = "视频编码", + summary = "--video-codec", + items = videoCodecItems, + selectedIndex = videoCodecIndex, + onSelectedIndexChange = { onVideoCodecChange(VIDEO_CODEC_OPTIONS[it].first) }, + enabled = !sessionStarted, + ) + SuperSlide( + title = "视频码率", + summary = "--video-bit-rate", + value = bitRateMbps, + onValueChange = { + onBitRateSliderChange(it) + onBitRateInputChange(formatBitRate(it)) + }, + valueRange = 0.1f..40f, + steps = 399, + enabled = !sessionStarted, + unit = "Mbps", + displayFormatter = { formatBitRate(it) }, + inputInitialValue = formatBitRate(bitRateMbps), + inputFilter = { text -> + var dotUsed = false + text.filter { ch -> + when { + ch.isDigit() -> true + ch == '.' && !dotUsed -> { + dotUsed = true + true + } + else -> false + } + } + }, + inputValueRange = 0.1f..Float.MAX_VALUE, + onInputConfirm = { raw -> + raw.toFloatOrNull()?.let { parsed -> + if (parsed >= 0.1f) { + onBitRateSliderChange(parsed) + onBitRateInputChange(formatBitRate(parsed)) + } + } + }, + ) + SuperArrow( + title = "高级参数", + summary = "更多 scrcpy 启动参数", + onClick = onOpenAdvanced, + enabled = !sessionStarted, + ) + TextButton( + text = if (sessionStarted) "停止" else "启动", + onClick = { + onStartStopHaptic?.invoke() + if (sessionStarted) onStop() else onStart() + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(bottom = UiSpacing.CardContent), + enabled = !busy, + colors = if (sessionStarted) { + ButtonDefaults.textButtonColors() + } else { + ButtonDefaults.textButtonColorsPrimary() + }, + ) + } +} + +@Composable +private fun PairingDialog( + showDialog: Boolean, + enabled: Boolean, + onDismissRequest: () -> Unit, + onDismissFinished: () -> Unit, + onConfirm: (host: String, port: String, code: String) -> Unit, +) { + var host by rememberSaveable(showDialog) { mutableStateOf("") } + var port by rememberSaveable(showDialog) { mutableStateOf("") } + var code by rememberSaveable(showDialog) { mutableStateOf("") } + + fun clearInputs() { + host = "" + port = "" + code = "" + } + + SuperDialog( + show = showDialog, + title = "使用配对码配对设备", + summary = "使用六位数的配对码配对新设备", + onDismissRequest = { + clearInputs() + onDismissRequest() + }, + onDismissFinished = onDismissFinished, + content = { + TextField( + value = host, + onValueChange = { host = it }, + label = "IP 地址", + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = UiSpacing.CardContent), + ) + TextField( + value = port, + onValueChange = { port = it.filter(Char::isDigit) }, + label = "端口", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth().padding(bottom = UiSpacing.CardContent), + ) + TextField( + value = code, + onValueChange = { code = it }, + label = "WLAN 配对码", + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = UiSpacing.Large), + ) + Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal)) { + TextButton( + text = "取消", + onClick = { + clearInputs() + onDismissRequest() + }, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "配对", + onClick = { + onConfirm(host.trim(), port.trim().ifBlank { "37099" }, code.trim()) + clearInputs() + }, + enabled = enabled && host.isNotBlank() && code.isNotBlank(), + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + }, + ) +} + +private fun presetIndexFromInput(raw: String, presets: List): 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 +} + +@SuppressLint("DefaultLocale") +private fun formatBitRate(value: Float): String = String.format("%.1f", value) + +@Composable +internal fun LogsPanel(lines: List) { + Card( + pressFeedbackType = PressFeedbackType.Sink, + ) { + TextField( + value = lines.joinToString(separator = "\n"), + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +fun FullscreenControlScreen( + session: ScrcpySessionInfo, + nativeCore: NativeCoreFacade, + onDismiss: () -> Unit, + showDebugInfo: Boolean, + currentFps: Float, + enableBackHandler: Boolean = true, + onInjectTouch: (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, +) { + var touchAreaSize by remember { mutableStateOf(IntSize.Zero) } + val activePointerIds = remember { linkedSetOf() } + val activePointerPositions = remember { linkedMapOf() } + var activeTouchCount by remember { mutableIntStateOf(0) } + BackHandler(enabled = enableBackHandler, onBack = onDismiss) + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .pointerInteropFilter { event -> + if (touchAreaSize.width == 0 || touchAreaSize.height == 0) { + return@pointerInteropFilter true + } + + fun mapToDevice(rawX: Float, rawY: Float): Pair { + val x = ((rawX / touchAreaSize.width) * session.width).roundToInt().coerceIn(0, (session.width - 1).coerceAtLeast(0)) + val y = ((rawY / touchAreaSize.height) * session.height).roundToInt().coerceIn(0, (session.height - 1).coerceAtLeast(0)) + return x to y + } + + fun syncActivePointersFromEvent(skipPointerId: Int? = null) { + for (i in 0 until event.pointerCount) { + val pointerId = event.getPointerId(i) + if (!activePointerIds.contains(pointerId) || pointerId == skipPointerId) continue + val px = event.getX(i) + val py = event.getY(i) + activePointerPositions[pointerId] = Offset(px, py) + val (x, y) = mapToDevice(px, py) + onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, event.getPressure(i).coerceIn(0f, 1f), 1) + } + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + activePointerIds.clear() + activePointerPositions.clear() + activeTouchCount = 0 + val (x, y) = mapToDevice(event.x, event.y) + val pointerId = event.getPointerId(0) + activePointerIds += pointerId + activePointerPositions[pointerId] = Offset(event.x, event.y) + activeTouchCount = activePointerIds.size + onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, event.getPressure(0).coerceIn(0f, 1f), 1) + } + + MotionEvent.ACTION_POINTER_DOWN -> { + val index = event.actionIndex + val px = event.getX(index) + val py = event.getY(index) + val (x, y) = mapToDevice(px, py) + val pointerId = event.getPointerId(index) + activePointerIds += pointerId + activePointerPositions[pointerId] = Offset(px, py) + activeTouchCount = activePointerIds.size + onInjectTouch(UiMotionActions.POINTER_DOWN, pointerId.toLong(), x, y, event.getPressure(index).coerceIn(0f, 1f), 1) + syncActivePointersFromEvent() + } + + MotionEvent.ACTION_MOVE -> { + for (i in 0 until event.pointerCount) { + val pointerId = event.getPointerId(i) + if (!activePointerIds.contains(pointerId)) continue + val px = event.getX(i) + val py = event.getY(i) + activePointerPositions[pointerId] = Offset(px, py) + val (x, y) = mapToDevice(px, py) + onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, event.getPressure(i).coerceIn(0f, 1f), 1) + } + } + + MotionEvent.ACTION_POINTER_UP -> { + val index = event.actionIndex + val px = event.getX(index) + val py = event.getY(index) + val (x, y) = mapToDevice(px, py) + val pointerId = event.getPointerId(index) + onInjectTouch(UiMotionActions.POINTER_UP, pointerId.toLong(), x, y, 0f, 1) + activePointerIds -= pointerId + activePointerPositions.remove(pointerId) + activeTouchCount = activePointerIds.size + syncActivePointersFromEvent(skipPointerId = pointerId) + } + + MotionEvent.ACTION_UP -> { + val (x, y) = mapToDevice(event.x, event.y) + val pointerId = event.getPointerId(0) + onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 1) + activePointerIds.clear() + activePointerPositions.clear() + activeTouchCount = 0 + } + + MotionEvent.ACTION_CANCEL -> { + for (pointerId in activePointerIds) { + val pos = activePointerPositions[pointerId] ?: Offset.Zero + val (x, y) = mapToDevice(pos.x, pos.y) + onInjectTouch(UiMotionActions.CANCEL, pointerId.toLong(), x, y, 0f, 0) + } + activePointerIds.clear() + activePointerPositions.clear() + activeTouchCount = 0 + } + } + true + } + .onSizeChanged { touchAreaSize = it }, + ) { + val sessionAspect = if (session.height == 0) { + 16f / 9f + } else { + session.width.toFloat() / session.height.toFloat() + } + val containerAspect = maxWidth.value / maxHeight.value + val fittedModifier = if (sessionAspect > containerAspect) { + Modifier.fillMaxWidth().aspectRatio(sessionAspect) + } else { + Modifier.fillMaxHeight().aspectRatio(sessionAspect) + } + + Box( + modifier = Modifier + .align(Alignment.Center) + .then(fittedModifier), + ) { + ScrcpyVideoSurface( + modifier = Modifier.fillMaxSize(), + nativeCore = nativeCore, + session = session, + ) + } + + if (showDebugInfo) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent) + .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(25)) + .padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.Medium), + ) { + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) { + Text( + text = "分辨率: ${session.width}x${session.height}", + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "触点: $activeTouchCount", + color = Color.White, + fontSize = 13.sp, + ) + @SuppressLint("DefaultLocale") + Text( + text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}", + color = Color.White, + fontSize = 13.sp, + ) + } + } + } + } +} + +@Composable +private fun ScrcpyVideoSurface( + modifier: Modifier, + nativeCore: NativeCoreFacade, + session: ScrcpySessionInfo?, +) { + val surfaceTag = "video-main" + var currentSurface by remember { mutableStateOf(null) } + + LaunchedEffect(session, currentSurface) { + if (session != null && currentSurface != null) { + nativeCore.registerVideoSurface(surfaceTag, currentSurface!!) + } + // Unregistration is handled directly in onSurfaceTextureDestroyed and DisposableEffect + } + + DisposableEffect(Unit) { + onDispose { + val released = currentSurface + if (released != null) { + nativeCore.unregisterVideoSurface(surfaceTag, released) + released.release() + currentSurface = null + } + // If currentSurface is null, onSurfaceTextureDestroyed already handled cleanup + } + } + + AndroidView( + modifier = modifier, + factory = { context -> + TextureView(context).apply { + surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + currentSurface?.release() // Release stale surface if any + @SuppressLint("Recycle") + currentSurface = Surface(surfaceTexture) + } + + override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) = Unit + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + val released = currentSurface + currentSurface = null + if (released != null) { + nativeCore.unregisterVideoSurface(surfaceTag, released) + released.release() + } + return true + } + + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit + } + } + }, + update = {}, + ) +} + +@Composable +internal fun DeviceTile( + device: DeviceShortcut, + actionText: String, + actionEnabled: Boolean, + actionInProgress: Boolean, + onLongPress: () -> Unit, + onContentClick: () -> Unit, + onAction: () -> Unit, +) { + val haptics = rememberAppHaptics() + Card( + colors = CardDefaults.defaultColors( + color = if (device.online) MiuixTheme.colorScheme.surfaceContainer + else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), + ), + pressFeedbackType = PressFeedbackType.Sink, + onClick = haptics.press, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongPress, + ) + .padding(UiSpacing.PageItem), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .weight(1f) + .padding(end = UiSpacing.CardContent), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = if (device.online) Color(0xFF44C74F) else MiuixTheme.colorScheme.outline, + shape = CircleShape, + ), + ) + Spacer(modifier = Modifier.width(UiSpacing.PageItem)) + Column(modifier = Modifier.weight(1f)) { + Text( + device.name.ifBlank { device.host }, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MiuixTheme.colorScheme.onSurface, + ) + Text( + "${device.host}:${device.port}", + fontSize = 13.sp, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (actionInProgress) { + CircularProgressIndicator(progress = null) + Spacer(Modifier.width(UiSpacing.Medium)) + } + TextButton( + text = actionText, + onClick = onAction, + enabled = actionEnabled && !actionInProgress, + ) + } + } + } +} + +@Composable +internal fun QuickConnectCard( + input: String, + onInputChange: (String) -> Unit, + onConnect: () -> Unit, + onAddDevice: () -> Unit, + enabled: Boolean = true, +) { + val focusManager = LocalFocusManager.current + + Card( + colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), + pressFeedbackType = if (enabled) PressFeedbackType.Tilt else PressFeedbackType.None, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = UiSpacing.Large, + end = UiSpacing.Large, + top = UiSpacing.PageItem, + bottom = UiSpacing.Small, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.AddLink, + contentDescription = "快速连接", + tint = MiuixTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.width(UiSpacing.Medium)) + Text( + "快速连接", + fontWeight = FontWeight.Bold, + color = MiuixTheme.colorScheme.onPrimaryContainer, + ) + } + TextField( + value = input, + onValueChange = { + if (enabled) onInputChange(it) + }, + label = "IP:PORT", + enabled = enabled, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(bottom = UiSpacing.SectionTitleLeadingGap), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(bottom = UiSpacing.CardContent), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + TextButton( + text = "添加设备", + onClick = onAddDevice, + modifier = Modifier.weight(1f), + enabled = enabled, + ) + TextButton( + text = "连接", + onClick = onConnect, + modifier = Modifier.weight(1f), + enabled = enabled, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } +} + +@Composable +internal fun DeviceEditorScreen( + contentPadding: PaddingValues, + device: DeviceShortcut, + onSave: (DeviceShortcut) -> Unit, + onDelete: () -> Unit, + onBack: () -> Unit, +) { + var name by rememberSaveable(device.id) { mutableStateOf(device.name) } + var host by rememberSaveable(device.id) { mutableStateOf(device.host) } + var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) } + + BackHandler(enabled = true, onBack = onBack) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(UiSpacing.PageHorizontal), + ) { + SectionSmallTitle(text = "编辑设备") + Card { + TextField( + value = name, + onValueChange = { name = it }, + label = "设备名称", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(top = UiSpacing.CardContent), + ) + TextField( + value = host, + onValueChange = { host = it }, + label = "IP 地址", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(top = UiSpacing.CardContent), + ) + TextField( + value = port, + onValueChange = { port = it.filter(Char::isDigit) }, + label = "端口", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(top = UiSpacing.CardContent), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(UiSpacing.CardContent), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + TextButton( + text = "返回", + onClick = onBack, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "删除", + onClick = onDelete, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "保存", + onClick = { + val p = port.toIntOrNull() ?: AppDefaults.DefaultAdbPort + val h = host.trim() + if (h.isNotBlank()) { + onSave( + DeviceShortcut( + id = "$h:$p", + name = name.trim(), + host = h, + port = p, + online = device.online, + ), + ) + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt new file mode 100644 index 0000000..3522532 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt @@ -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) + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt new file mode 100644 index 0000000..3834c5e --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt @@ -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, + 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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt new file mode 100644 index 0000000..2809ddc --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt new file mode 100644 index 0000000..ceb55ff --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -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 { + if (raw.isBlank()) return emptyList() + return raw.split(',').mapNotNull { item -> + val id = item.trim() + id.ifBlank { null } + } + } + + fun encodeStoredIds(ids: List): String = ids.joinToString(",") + + fun resolveLayout( + outsideIds: List, + moreIds: List, + ): Pair, List> { + val outside = mutableListOf() + val overflow = mutableListOf() + val used = mutableSetOf() + + 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, + private val moreActions: List, +) { + @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, + 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) + }, + ) + } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..02d60ee --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..929133e --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Scrcpy + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1b04660 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/io/github/miuzarte/scrcpyforandroid/ExampleUnitTest.kt b/app/src/test/java/io/github/miuzarte/scrcpyforandroid/ExampleUnitTest.kt new file mode 100644 index 0000000..5d91839 --- /dev/null +++ b/app/src/test/java/io/github/miuzarte/scrcpyforandroid/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b2c8728 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.compose.compiler) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..34c5e9e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,15 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..75221e1 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,40 @@ +[versions] +agp = "9.1.0" +kotlin = "2.3.20" +coreKtx = "1.18.0" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.13.0" +composeBom = "2026.03.00" +androidxNavigation3 = "1.1.0-beta01" +junit = "4.13.2" +androidxJunit = "1.3.0" +espressoCore = "3.7.0" +miuix = "0.8.7" +material = "1.13.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime-android", version.ref = "androidxNavigation3" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +miuix = { group = "top.yukonga.miuix.kmp", name = "miuix", version.ref = "miuix" } +miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.ref = "miuix" } +miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..880cbbc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Thu Mar 19 07:51:35 HKT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ac5630b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Scrcpy" +include(":app")