From 45f2df661b958660c11cc354e6e4631fee54de0d Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Thu, 19 Mar 2026 08:53:26 +0800 Subject: [PATCH] first commit --- .github/workflows/android.yml | 218 ++++ ScrcpyForAndroid.code-workspace | 19 + app/.gitignore | 1 + app/build.gradle.kts | 66 + app/proguard-rules.pro | 21 + .../ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 29 + app/src/main/assets/bin/scrcpy-server-v3.3.4 | Bin 0 -> 90980 bytes .../miuzarte/scrcpyforandroid/MainActivity.kt | 18 + .../scrcpyforandroid/NativeCoreFacade.kt | 654 ++++++++++ .../scrcpyforandroid/constants/AppDefaults.kt | 74 ++ .../constants/AppPreferenceKeys.kt | 58 + .../constants/ScrcpyPresets.kt | 7 + .../constants/UiAndroidKeycodes.kt | 14 + .../scrcpyforandroid/constants/UiMotion.kt | 6 + .../scrcpyforandroid/constants/UiSpacing.kt | 23 + .../scrcpyforandroid/haptics/AppHaptics.kt | 32 + .../scrcpyforandroid/models/DeviceModels.kt | 14 + .../nativecore/AnnexBDecoder.kt | 161 +++ .../nativecore/DirectAdbClient.kt | 506 ++++++++ .../nativecore/NativeAdbService.kt | 84 ++ .../nativecore/ScrcpyAudioPlayer.kt | 201 +++ .../nativecore/ScrcpySessionManager.kt | 1060 ++++++++++++++++ .../pages/AdvancedConfigPage.kt | 676 +++++++++++ .../scrcpyforandroid/pages/DevicePage.kt | 1080 +++++++++++++++++ .../pages/FullscreenControlPage.kt | 163 +++ .../scrcpyforandroid/pages/MainPage.kt | 728 +++++++++++ .../scrcpyforandroid/pages/SettingsPage.kt | 207 ++++ .../pages/VirtualButtonOrderPage.kt | 164 +++ .../scrcpyforandroid/scaffolds/PageLayouts.kt | 51 + .../scrcpyforandroid/scaffolds/SuperSlide.kt | 140 +++ .../services/AppSettingsStore.kt | 214 ++++ .../services/DeviceConnectionLogic.kt | 35 + .../services/QuickDeviceStore.kt | 110 ++ .../scrcpyforandroid/widgets/DeviceWidgets.kt | 1055 ++++++++++++++++ .../widgets/SectionSmallTitle.kt | 21 + .../widgets/SortableCardList.kt | 140 +++ .../widgets/StatusCardModels.kt | 166 +++ .../widgets/VirtualButtons.kt | 282 +++++ .../res/drawable/ic_launcher_background.xml | 170 +++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values-night/themes.xml | 16 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 16 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../scrcpyforandroid/ExampleUnitTest.kt | 17 + build.gradle.kts | 6 + gradle.properties | 15 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 40 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++ gradlew.bat | 94 ++ settings.gradle.kts | 26 + 69 files changed, 9281 insertions(+) create mode 100644 .github/workflows/android.yml create mode 100644 ScrcpyForAndroid.code-workspace create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/io/github/miuzarte/scrcpyforandroid/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/assets/bin/scrcpy-server-v3.3.4 create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/io/github/miuzarte/scrcpyforandroid/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..89054b7ecfa6735b93ee7b293e91abe7ecd8d26e GIT binary patch literal 90980 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ŕhu8Y%@FGhFErLoLjI!7Xx= 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 z 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 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")