diff --git a/.github/scripts/run-android-runtime-tests.sh b/.github/scripts/run-android-runtime-tests.sh new file mode 100644 index 0000000..8b5fbd1 --- /dev/null +++ b/.github/scripts/run-android-runtime-tests.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bound every adb operation: a stuck device must not consume the entire job timeout. +adb_command() { timeout --kill-after=5s 120s adb -s "${ANDROID_SERIAL:-emulator-5554}" "$@"; } +collect_diagnostics() { + local result=$? + trap - EXIT + timeout --kill-after=5s 15s adb -s "${ANDROID_SERIAL:-emulator-5554}" logcat -d -v threadtime > runtime-logcat.log 2>&1 || true + timeout --kill-after=5s 15s adb -s "${ANDROID_SERIAL:-emulator-5554}" shell dumpsys activity services > runtime-services.log 2>&1 || true + exit "$result" +} +trap collect_diagnostics EXIT + +run_test() { + local logfile=$1 test_class=$2 runner=$3 status=0 + adb_command shell am instrument -w -r -e class "$test_class" "$runner" > "$logfile" 2>&1 || status=$? + cat "$logfile" + # am instrument can exit zero even when JUnit fails or the process crashes. + if [[ "$status" != 0 ]] || ! grep -Eq '^OK \([1-9][0-9]* tests?\)' "$logfile"; then + echo "::error::Runtime test failed ($test_class, adb exit $status); see $logfile and runtime-logcat.log" + return 1 + fi +} + +adb_command logcat -c +adb_command install -r runtime-tests/common-debug-androidTest.apk +result=0 +run_test browser-runtime.log com.shilapi.xcertplay.web.BrowserPageRuntimeTest \ + com.shilapi.xcertplay.host.test/androidx.test.runner.AndroidJUnitRunner || result=1 + +adb_command install -r plugin-host/mobile-debug.apk +adb_command install -r plugin-sdk/projection-plugin-sample/build/outputs/apk/debug/projection-plugin-sample-debug.apk +adb_command install -r plugin-tests/mobile-debug-androidTest.apk +run_test plugin-runtime.log moe.feng.wheelplay.plugin.PluginSurfaceIntegrationTest \ + moe.feng.wheelplay.debug.test/androidx.test.runner.AndroidJUnitRunner || result=1 +exit "$result" diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml index 80fa584..111e5a3 100644 --- a/.github/workflows/debug-build.yml +++ b/.github/workflows/debug-build.yml @@ -59,18 +59,17 @@ jobs: echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" echo "root=$ANDROID_HOME" >> "$GITHUB_OUTPUT" - - name: Cache required Android toolchain + # Platform and build-tools are preinstalled and root-owned on GitHub runners. + - name: Cache downloaded native Android toolchain uses: actions/cache@v4 with: path: | - ${{ steps.android-sdk.outputs.root }}/platforms/android-${{ env.ANDROID_API_LEVEL }} - ${{ steps.android-sdk.outputs.root }}/build-tools/${{ env.ANDROID_BUILD_TOOLS }} ${{ steps.android-sdk.outputs.root }}/ndk/${{ env.ANDROID_NDK_VERSION }} ${{ steps.android-sdk.outputs.root }}/cmake/${{ env.ANDROID_CMAKE_VERSION }} - key: android-toolchain-v1-ubuntu24-${{ runner.arch }}-api${{ env.ANDROID_API_LEVEL }}-bt${{ env.ANDROID_BUILD_TOOLS }}-ndk${{ env.ANDROID_NDK_VERSION }}-cmake${{ env.ANDROID_CMAKE_VERSION }} + key: android-native-toolchain-v2-ubuntu24-${{ runner.arch }}-ndk${{ env.ANDROID_NDK_VERSION }}-cmake${{ env.ANDROID_CMAKE_VERSION }} - name: Verify SDK installation helper - run: node --test test/ci-android-toolchain.test.js + run: node --test test/ci-android-*.test.js - name: Install missing Android packages run: | @@ -80,8 +79,12 @@ jobs: "ndk;$ANDROID_NDK_VERSION" \ "cmake;$ANDROID_CMAKE_VERSION" - - name: Build and verify Debug - run: ./gradlew --no-daemon --build-cache :projection-plugin-api:testDebugUnitTest :projection-plugin-api:assembleRelease :projection-plugin-sample:lintDebug :projection-plugin-sample:assembleDebug :shared:testDebugUnitTest :common:testDebugUnitTest :common:assembleDebugAndroidTest :mobile:lintDebug :mobile:assembleDebug :mobile:assembleDebugAndroidTest + - name: Build and lint Debug artifacts + run: ./gradlew --no-daemon --build-cache :projection-plugin-api:assembleRelease :projection-plugin-sample:lintDebug :projection-plugin-sample:assembleDebug :common:assembleDebugAndroidTest :mobile:lintDebug :mobile:assembleDebug :mobile:assembleDebugAndroidTest + + # Native compilation and time-sensitive socket tests must not compete for runner CPU. + - name: Run unit tests + run: ./gradlew --no-daemon --build-cache :projection-plugin-api:testDebugUnitTest :shared:testDebugUnitTest :common:testDebugUnitTest - name: Upload plugin SDK and independent sample APK uses: actions/upload-artifact@v4 @@ -146,25 +149,21 @@ jobs: matrix: api-level: [28, 36] steps: - - name: Locate emulator cache directories - id: emulator-cache + - uses: actions/checkout@v4 + + - name: Locate Android SDK + id: runtime-sdk run: | - echo "sdk=$ANDROID_HOME" >> "$GITHUB_OUTPUT" - echo "image-version=$ImageVersion" >> "$GITHUB_OUTPUT" + echo "root=$ANDROID_HOME" >> "$GITHUB_OUTPUT" + echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" - # Cache only this API's image and clean AVD. The runner image version also - # invalidates snapshots when the preinstalled emulator/host software changes. - - name: Restore Android emulator and clean snapshot - id: avd-cache - uses: actions/cache/restore@v4 + # Only cache downloaded images. Preinstalled platform directories are root-owned; + # restoring them caused thousands of tar errors. Each run gets a fresh AVD. + - name: Cache Android system image + uses: actions/cache@v4 with: - path: | - ${{ steps.emulator-cache.outputs.sdk }}/emulator - ${{ steps.emulator-cache.outputs.sdk }}/platforms/android-${{ matrix.api-level }} - ${{ steps.emulator-cache.outputs.sdk }}/system-images/android-${{ matrix.api-level }}/google_apis/x86_64 - ~/.android/avd - ~/.android/adb* - key: android-avd-v1-ubuntu24-${{ runner.arch }}-${{ steps.emulator-cache.outputs.image-version }}-api${{ matrix.api-level }}-google_apis-x86_64 + path: ${{ steps.runtime-sdk.outputs.root }}/system-images/android-${{ matrix.api-level }}/google_apis/x86_64 + key: android-system-image-v2-ubuntu24-${{ runner.arch }}-api${{ matrix.api-level }}-google_apis-x86_64 - name: Download browser runtime test APK uses: actions/download-artifact@v4 @@ -193,53 +192,26 @@ jobs: - name: Enable KVM run: sudo chmod a+rw /dev/kvm - - name: Create clean emulator snapshot on cache miss - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - arch: x86_64 - target: google_apis - avd-name: browser-runtime - force-avd-creation: false - disable-animations: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: echo 'Clean emulator snapshot created.' + - name: Start adb outside the emulator action + run: adb start-server > adb-server.log 2>&1 - # Save before installing or running tests so later jobs restore a clean device. - - name: Save clean emulator snapshot - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: | - ${{ steps.emulator-cache.outputs.sdk }}/emulator - ${{ steps.emulator-cache.outputs.sdk }}/platforms/android-${{ matrix.api-level }} - ${{ steps.emulator-cache.outputs.sdk }}/system-images/android-${{ matrix.api-level }}/google_apis/x86_64 - ~/.android/avd - ~/.android/adb* - key: ${{ steps.avd-cache.outputs.cache-primary-key }} - - - name: Verify localized pages on Android ICU + - name: Verify browser and plugin runtimes uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} arch: x86_64 target: google_apis avd-name: browser-runtime - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + force-avd-creation: true + # Detach output from the action pipes so emulator helper processes cannot + # keep a successful step alive after adb emu kill (seen on API 28). + emulator-options: -no-snapshot -no-metrics -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none > emulator.log 2>&1 < /dev/null disable-animations: true - script: | - adb install -r runtime-tests/common-debug-androidTest.apk - adb shell am instrument -w -r -e class com.shilapi.xcertplay.web.BrowserPageRuntimeTest com.shilapi.xcertplay.host.test/androidx.test.runner.AndroidJUnitRunner > browser-runtime.log - adb install -r plugin-host/mobile-debug.apk - adb install -r plugin-sdk/projection-plugin-sample/build/outputs/apk/debug/projection-plugin-sample-debug.apk - adb install -r plugin-tests/mobile-debug-androidTest.apk - adb shell am instrument -w -r -e class moe.feng.wheelplay.plugin.PluginSurfaceIntegrationTest moe.feng.wheelplay.debug.test/androidx.test.runner.AndroidJUnitRunner > plugin-runtime.log - cat plugin-runtime.log - grep -Eq '^OK \([1-9][0-9]* tests?\)' plugin-runtime.log - cat browser-runtime.log - grep -Eq '^OK \([1-9][0-9]* tests?\)' browser-runtime.log + script: bash .github/scripts/run-android-runtime-tests.sh + + - name: Stop adb + if: always() + run: timeout --kill-after=5s 15s adb kill-server || true - name: Upload runtime test result if: always() @@ -249,4 +221,8 @@ jobs: path: | browser-runtime.log plugin-runtime.log + runtime-logcat.log + runtime-services.log + emulator.log + adb-server.log if-no-files-found: warn diff --git a/common/build.gradle.kts b/common/build.gradle.kts index aa5377e..abbd9b1 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -68,5 +68,6 @@ tasks.withType().configureEach { // The full Robolectric suite loads several Android SDK resource tables. Gradle's // default 512 MiB test worker heap is insufficient; org.gradle.jvmargs only sizes the daemon. maxHeapSize = "2g" + testLogging.exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") } diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml index 8306d9a..f1a3419 100644 --- a/common/src/main/AndroidManifest.xml +++ b/common/src/main/AndroidManifest.xml @@ -29,7 +29,7 @@ android:authorities="${applicationId}.shizuku" android:multiprocess="false" android:enabled="true" android:exported="true" android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> - @@ -47,20 +47,6 @@ - - - - - @@ -68,20 +54,35 @@ android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/usb_device_filter" /> + + android:name="moe.feng.wheelplay.CarPlayHostActivity" + android:configChanges="density|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" + android:exported="true" + android:launchMode="singleTop" + android:resizeableActivity="true" + android:screenOrientation="fullSensor" + android:theme="@style/Theme.WheelPlay.ConnectionDialog" + android:windowSoftInputMode="adjustResize" /> + + + + diff --git a/common/src/main/assets/licenses/crop-kit.txt b/common/src/main/assets/licenses/crop-kit.txt new file mode 100644 index 0000000..739ab99 --- /dev/null +++ b/common/src/main/assets/licenses/crop-kit.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Tanish Ranjan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt deleted file mode 100644 index a5f990d..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ /dev/null @@ -1,3660 +0,0 @@ -package com.shilapi.xcertplay - -import com.shilapi.xcertplay.projection.ProjectionSourceIds -import com.shilapi.xcertplay.projection.CarPlaySourceSession -import com.shilapi.xcertplay.projection.ProjectionResource -import com.shilapi.xcertplay.projection.projectionSessions - -import com.shilapi.xcertplay.shared.R as UiR -import com.shilapi.xcertplay.i18n.* - -import com.shilapi.xcertplay.web.WebSession -import com.shilapi.xcertplay.web.OffscreenVideoOutput -import android.Manifest -import android.content.Intent -import android.content.pm.PackageManager -import android.content.res.Configuration -import android.content.res.ColorStateList -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Color -import android.graphics.SurfaceTexture -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable -import android.media.MediaCodecList -import android.media.MediaFormat -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.Process -import android.text.Editable -import android.text.InputType -import android.text.TextUtils -import android.text.TextWatcher -import android.util.Log -import android.view.Gravity -import android.view.MotionEvent -import android.view.Surface -import android.view.TextureView -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.RadioButton -import android.widget.RadioGroup -import android.widget.ScrollView -import android.widget.SeekBar -import android.widget.Switch -import android.widget.TextView -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import com.shilapi.xcertplay.airplay.AirPlayConfig -import com.shilapi.xcertplay.airplay.AirPlayDisplaySettings -import com.shilapi.xcertplay.airplay.AirPlayPhysicalSizeBasis -import com.shilapi.xcertplay.airplay.AirPlayPhysicalSizeMm -import com.shilapi.xcertplay.airplay.CarPlayDisplayScale -import com.shilapi.xcertplay.airplay.CarPlayUiScale -import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig -import com.shilapi.xcertplay.airplay.AirPlayIdentity -import com.shilapi.xcertplay.airplay.AirPlayIcon -import com.shilapi.xcertplay.airplay.AirPlaySafeArea -import com.shilapi.xcertplay.airplay.AirPlaySession -import com.shilapi.xcertplay.airplay.AirPlaySessionListener -import com.shilapi.xcertplay.airplay.CarPlayMediaEngine -import com.shilapi.xcertplay.airplay.SafeAreaRect -import com.shilapi.xcertplay.host.R -import com.shilapi.xcertplay.location.AndroidCarPlayLocationProvider -import com.shilapi.xcertplay.media.AndroidMediaSink -import com.shilapi.xcertplay.media.CarPlayTouchMapper -import com.shilapi.xcertplay.network.CarPlayVpnService -import com.shilapi.xcertplay.orchestration.CarPlayController -import com.shilapi.xcertplay.orchestration.CarPlayRuntimeConfig -import com.shilapi.xcertplay.orchestration.CarPlayStatus -import com.shilapi.xcertplay.orchestration.CarPlayTransport -import com.shilapi.xcertplay.orchestration.ManualHotspotBand -import com.shilapi.xcertplay.orchestration.ManualHotspotSecurity -import com.shilapi.xcertplay.orchestration.MfiTarget -import com.shilapi.xcertplay.orchestration.WirelessHotspotMode -import com.shilapi.xcertplay.orchestration.isManualHotspotChannelCompatible -import com.shilapi.xcertplay.transport.Iap2IdentificationConfig -import com.shilapi.xcertplay.transport.Iap2LocationProvider -import com.shilapi.xcertplay.transport.UsbDeviceId -import java.io.File -import java.text.SimpleDateFormat -import java.util.ArrayDeque -import java.util.Date -import java.util.Locale -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Full-screen CarPlay host. It renders decoded video through a [TextureView], forwards touch to - * the active AirPlay session, and drives the complete wired or wireless bring-up through - * [CarPlayController]. - * - * Apple devices are discovered by vendor ID; CH341 uses the configured VID/PID below. - */ -class CarPlayHostActivity : ComponentActivity() { - private data class SettingsBaseline( - val safeAreaSize: DisplaySize?, - val safeAreaRect: SafeAreaRect?, - val customIconBytes: ByteArray?, - ) - - private var connectionPanel: View? = null - private var wifiRecoveryButton: View? = null - private var reconnectAttempts = 0 - private lateinit var airPlayIdentity: AirPlayIdentity - - // CH341 USB\VID_1A86&PID_5512&REV_0304 is the deployment-supplied bridge identity. - private fun createRuntimeConfig(): CarPlayRuntimeConfig = CarPlayRuntimeConfig( - mfiTarget = MfiTarget.LOCAL, - ch341Devices = if (mfiTarget == MfiTarget.USB_CH341) { - listOf(UsbDeviceId(0x1a86, 0x5512)) - } else { - emptyList() - }, - // The CP latches its I2C address from the RST level at its own power-up, so the host must - // not pulse RST before discovery. Driving D0 re-latches the part onto the alternate - // address (0x10), where the accessory certificate is not readable. Leave RST at its - // hardware pull (VCC -> 0x11) and let the scanner find the part with its certificate. - // Set this back to 0 to restore the D0 pulse. - ch341MfiResetGpio = null, - linuxI2cPath = if (mfiTarget == MfiTarget.I2C) mfiI2cPath.trim() else null, - remoteMfiServer = remoteMfiServer.trim().takeIf { it.isNotEmpty() }, - remoteMfiToken = remoteMfiToken.takeIf { it.isNotEmpty() }, - identification = Iap2IdentificationConfig( - name = "WheelPlay", - modelIdentifier = normalizedModel(), - manufacturer = normalizedManufacturer(), - serialNumber = "DIPLAY-" + DiPlayBootstrap.deviceId(airPlayIdentity).replace(":", ""), - firmwareVersion = "0.1.0", - hardwareVersion = "1.0", - carPlayUsbInterfaceNumber = 3, - // Advertise the channel so a browser can opt in during an existing session. - // No fixes are supplied until browser consent or the Android GPS setting enables a source. - locationInformationEnabled = true, - ), - label = "WheelPlay", - hostName = "wheelplay-" + DiPlayBootstrap.deviceId(airPlayIdentity).replace(":", "").lowercase(), - hostMac = DiPlayBootstrap.deviceId(airPlayIdentity).split(":").map { it.toInt(16).toByte() }.toByteArray(), - wirelessBluetoothDeviceAddress = DiPlayPreferences.phoneAddress(this), - transport = if (wirelessEnabled) CarPlayTransport.WIRELESS else CarPlayTransport.WIRED, - wirelessHotspotMode = wirelessHotspotMode, - manualHotspotSsid = manualHotspotSsid, - manualHotspotPassphrase = manualHotspotPassphrase, - manualHotspotBand = manualHotspotBand, - manualHotspotChannel = manualHotspotChannel, - manualHotspotSecurity = manualHotspotSecurity, - locationReportingEnabled = true, - ) - - private val vpnConsent = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - awaitingVpnConsent = false - if (result.resultCode == RESULT_OK) { - vpnReady = true - maybeStartCarPlay() - } else { - setLocalizedConnectionStage(UiText(UiR.string.host_vpn_consent_was_denied)) - } - } - private val wirelessPermissions = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - awaitingWirelessPermissions = false - wirelessPermissionsReady = hasRequiredWirelessPermissions() - appendLog( - if (wirelessPermissionsReady) { - "Wireless startup permissions granted" - } else { - "Wireless startup permissions denied" - }, - ) - updateHotspotStatusBlock() - maybeStartCarPlay() - } - private val manualHotspotChannelPermission = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> - val granted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || - hasFineLocationPermission() - manualHotspotChannelPermissionNote = if (granted) { - null - } else { - "Hotspot channel scan unavailable; using Auto" - } - appendLog( - if (granted) { - "Hotspot channel scan permission granted" - } else { - "Hotspot channel scan permission denied; continuing with Auto" - }, - ) - if (!granted) { - AirPlayPersistence.saveManualHotspotChannelPermissionPromptDismissed(this, true) - } - updateHotspotStatusBlock() - maybeStartCarPlay() - } - private val microphonePermission = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - microphoneAvailable = granted - microphonePermissionResolved = true - appendLog(if (granted) "Microphone permission granted" else "Microphone permission denied") - requestStartupPrerequisites() - } - private val locationPermission = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> - awaitingLocationPermission = false - locationPermissionAvailable = hasFineLocationPermission() - if (locationPermissionAvailable) { - manualHotspotChannelPermissionNote = null - appendLog("Location permission granted") - } else if (locationReportingEnabled) { - locationReportingEnabled = false - if (!menuOpen) { - AirPlayPersistence.saveLocationReportingEnabled( - this@CarPlayHostActivity, - false, - ) - } - locationReportingSwitch?.isChecked = false - val approximateOnly = - grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true - appendLog( - if (approximateOnly) { - "Precise location permission denied; location reporting disabled" - } else { - "Location permission denied; location reporting disabled" - }, - ) - } - if (!locationPermissionAvailable && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - wirelessHotspotMode == WirelessHotspotMode.MANUAL - ) { - // Don't immediately ask for the same denied permission again for optional channel detection. - manualHotspotChannelPermissionAsked = true - manualHotspotChannelPermissionNote = getString(UiR.string.host_precise_location_not_granted_using_auto_for_channel) - AirPlayPersistence.saveManualHotspotChannelPermissionPromptDismissed(this, true) - } - updateResolutionMenu() - updateHotspotStatusBlock() - if (!menuOpen) requestStartupPrerequisites() - } - - private val imagePicker = - registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> - if (uri == null) { - externalActivityInProgress = false - return@registerForActivityResult - } - imageCrop.launch( - Intent(this, ImageCropActivity::class.java) - .setData(uri) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION), - ) - } - private val imageCrop = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - externalActivityInProgress = false - if (result.resultCode == RESULT_OK) { - updateAirPlayIconPreview() - appendLog("Custom AirPlay icon updated") - } - } - - private var videoView: TextureView? = null - private var gestureOverlay: View? = null - private var settingsMenu: View? = null - private var mfiTargetGroup: RadioGroup? = null - private var mfiI2cFields: View? = null - private var mfiRemoteFields: View? = null - private var mfiErrorView: TextView? = null - private var mfiI2cPathInput: EditText? = null - private var remoteMfiServerInput: EditText? = null - private var remoteMfiTokenInput: EditText? = null - private var settingsBaseline: SettingsBaseline? = null - private var locationReportingSwitch: Switch? = null - private var statusView: TextView? = null - private var statusScrollView: ScrollView? = null - private var stageStatusView: TextView? = null - private var resolutionValueView: TextView? = null - private var resolutionPreviewView: TextView? = null - private var hotspotStatusView: TextView? = null - private var manualHotspotFields: View? = null - private var manualHotspotErrorView: TextView? = null - private var iconPreviewView: ImageView? = null - private var iconStatusView: TextView? = null - private var safeAreaSummaryView: TextView? = null - private var safeAreaEditor: View? = null - private var safeAreaEditorView: SafeAreaEditorView? = null - private var safeAreaEditSize: DisplaySize? = null - private var safeAreaEditorActive = false - private var externalActivityInProgress = false - private var sink: AndroidMediaSink? = null - private var controller: CarPlayController? = null - private var currentSurface: Surface? = null - private var currentSurfaceTexture: SurfaceTexture? = null - private var activeDisplaySize: DisplaySize? = null - private var pendingDisplaySize: DisplaySize? = null - private var displayScaleTenths = CarPlayDisplayScale.DEFAULT_TENTHS - private var uiScalePercent = CarPlayUiScale.DEFAULT - private var displayDiagnosticAttempt: String? = null - private var hevcEnabled = true - private var hevcSoftwareDecoderEnabled = false - private var advancedAudioChannelMappingSupported = false - private var advancedAudioChannelMapping = false - private var debugLogsEnabled = false - private var autoStartOnBoot = false - private var model = AirPlayPersistence.DEFAULT_MODEL - private var simulatedManufacturerName = AirPlayPersistence.DEFAULT_SIMULATED_MANUFACTURER_NAME - private var fps = AirPlayDisplaySettings.DEFAULT_FPS - private var streamTechnology = com.shilapi.xcertplay.web.StreamTechnology.WEBRTC - private var widthPhysicalMm = AirPlayDisplaySettings.DEFAULT_WIDTH_PHYSICAL_MM - private var physicalSizeBasis = AirPlayDisplaySettings.DEFAULT_PHYSICAL_SIZE_BASIS - private var maximumDetectedWidthPixels = 0 - private var maximumDetectedHeightPixels = 0 - private var rightHandDrive = false - private var safeAreaDrawOutside = true - private var locationReportingEnabled = false - private var locationPermissionAvailable = false - private var microphoneAvailable = false - private var microphonePermissionResolved = false - private var microphoneSource = CarPlayMicrophoneSource.ANDROID - private var wirelessEnabled = false - private var mfiTarget = MfiTarget.USB_CH341 - private var mfiI2cPath = AirPlayPersistence.DEFAULT_MFI_I2C_PATH - private var remoteMfiServer = "" - private var remoteMfiToken = "" - private var wirelessPermissionsReady = false - private var wirelessHotspotMode = WirelessHotspotMode.WIFI_P2P - private var manualHotspotChannelPermissionAsked = false - private var manualHotspotChannelPermissionNote: String? = null - private var manualHotspotSsid = "" - private var manualHotspotPassphrase = "" - private var manualHotspotBand = ManualHotspotBand.AUTO - private var manualHotspotChannel = 0 - private var manualHotspotSecurity = ManualHotspotSecurity.OPEN - private var awaitingVpnConsent = false - private var awaitingWirelessPermissions = false - private var awaitingLocationPermission = false - private var vpnReady = false - private var hotspotStatus = HotspotStatus(state = UiText(UiR.string.ui_disabled)) - private var menuOpen = false - private var latestStage = "Preparing CarPlay" - private var darkMode = false - private var activeAirPlaySession: AirPlaySession? = null - private val activeScreenStreamTypes = mutableSetOf() - private var handshakeResetInProgress = false - private var startAfterHandshakeReset = false - private var restartGeneration = 0 - private var reconnectScheduled = false - private var automaticReconnectAllowed = true - private var sessionLog: SessionLogFile? = null - private var gestureSequenceActive = false - private var gestureTracking = false - private var gestureStartX = 0f - private var gestureStartY = 0f - private val shuttingDown = AtomicBoolean(false) - private val mainHandler = Handler(Looper.getMainLooper()) - private val teardownExecutor: ExecutorService = Executors.newSingleThreadExecutor() - private val logLines = ArrayDeque() - private val expireOldLogLines = Runnable { refreshLogView(System.currentTimeMillis()) } - private val applyDisplaySize = Runnable { - val size = pendingDisplaySize ?: return@Runnable - pendingDisplaySize = null - applyDisplaySize(size) - } - - private val textureListener = object : TextureView.SurfaceTextureListener { - override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { - val existing = currentSurface - val surface = if ( - existing != null && - currentSurfaceTexture === texture && - existing.isValid - ) { - existing - } else { - Surface(texture).also { - existing?.release() - currentSurface = it - currentSurfaceTexture = texture - } - } - appendLog(if (existing === surface) "Texture surface reused" else "Texture surface created") - attachSurface(surface) - scheduleDisplaySize(width, height) - } - - override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { - scheduleDisplaySize(width, height) - } - - override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { - if (currentSurfaceTexture !== texture) return true - currentSurface?.let { surface -> - sink?.clearSurface(SCREEN_TYPE_MAIN, surface) - sink?.clearSurface(SCREEN_TYPE_ALT, surface) - surface.release() - } - currentSurface = null - currentSurfaceTexture = null - appendLog("Texture surface destroyed") - return true - } - - override fun onSurfaceTextureUpdated(texture: SurfaceTexture) = Unit - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - showSystemBars() - if (projectionSessions.hasSession() && - projectionSessions.snapshot()?.descriptor?.id != ProjectionSourceIds.CARPLAY) { - showDiPlayHome("home"); finish(); return - } - if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED") { - AirPlayPersistence.saveWirelessEnabled(this, false) - } - val authenticationReady = runCatching { DiPlayBootstrap.ensure(this) }.isSuccess - if (!authenticationReady) { - // Do not retain a partially initialized host after USB/other external launches. - showDiPlayHome("settings") - finish() - return - } - startForegroundService(Intent(this, DiPlaySessionService::class.java)) - window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - initializeSessionLog() - darkMode = isDarkMode(resources.configuration.uiMode) - advancedAudioChannelMappingSupported = - resources.getBoolean(R.bool.config_advanced_audio_channel_mapping) - airPlayIdentity = AirPlayPersistence.loadIdentity(this) - loadPersistedSettings() - locationPermissionAvailable = hasFineLocationPermission() - setContentView(buildContentView().also(ServerWindow::fitContent)) - activeDisplaySize = DisplaySize(1280, 720) - WebSession.setBrowserViewportListener { width, height -> - mainHandler.post { scheduleBrowserDisplaySize(width, height) } - } - showSystemBars() - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (menuOpen) { - if (safeAreaEditorActive) closeSafeAreaEditor() else cancelSettingsEdits() - } else { - showDiPlayHome("service") - } - } - }, - ) - - appendLog( - "Host started; MFI target=${mfiTargetLabel(mfiTarget)}; transport=${if (wirelessEnabled) "wireless" else "wired"}", - ) - val reusedBackgroundSession = adoptBackgroundSession() - microphoneAvailable = - checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - microphonePermissionResolved = microphoneAvailable - if (reusedBackgroundSession) { - updateDebugOverlays() - } else if (AirPlayPersistence.loadMicrophoneSource(this) == CarPlayMicrophoneSource.DISABLED || - microphonePermissionResolved || WebSession.audio.microphoneRequested) { - microphonePermissionResolved = true - requestStartupPrerequisites() - } else { - microphonePermission.launch(Manifest.permission.RECORD_AUDIO) - } - } - - private fun loadPersistedSettings() { - microphoneSource = AirPlayPersistence.loadMicrophoneSource(this) - displayScaleTenths = AirPlayPersistence.loadDisplayScaleTenths(this) - // Size is now chosen only through CarPlaySize; ignore the canvas scale older builds stored. - uiScalePercent = CarPlayUiScale.DEFAULT - hevcEnabled = AirPlayPersistence.loadHevcEnabled(this) - hevcSoftwareDecoderEnabled = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - AirPlayPersistence.loadHevcSoftwareDecoderEnabled(this) - advancedAudioChannelMapping = - advancedAudioChannelMappingSupported && - AirPlayPersistence.loadAdvancedAudioChannelMapping(this) - debugLogsEnabled = AirPlayPersistence.loadDebugLogsEnabled(this) - autoStartOnBoot = AirPlayPersistence.loadAutoStartOnBoot(this) - model = AirPlayPersistence.loadModel(this) - simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) - fps = AirPlayPersistence.loadFps(this) - streamTechnology = AirPlayPersistence.loadStreamTechnology(this) - widthPhysicalMm = AirPlayPersistence.loadWidthPhysicalMm(this) - physicalSizeBasis = AirPlayPersistence.loadPhysicalSizeBasis(this) - AirPlayPersistence.loadMaximumDetectedDisplay(this).let { (width, height) -> - maximumDetectedWidthPixels = width - maximumDetectedHeightPixels = height - } - rightHandDrive = AirPlayPersistence.loadRightHandDrive(this) - safeAreaDrawOutside = AirPlayPersistence.loadSafeAreaDrawOutside(this) - locationReportingEnabled = AirPlayPersistence.loadLocationReportingEnabled(this) - locationPermissionAvailable = hasFineLocationPermission() - mfiTarget = AirPlayPersistence.loadMfiTarget(this) - mfiI2cPath = AirPlayPersistence.loadMfiI2cPath(this) - remoteMfiServer = AirPlayPersistence.loadRemoteMfiServer(this) - remoteMfiToken = AirPlayPersistence.loadRemoteMfiToken(this) - loadPersistedWirelessSettings() - manualHotspotChannelPermissionAsked = - AirPlayPersistence.loadManualHotspotChannelPermissionPromptDismissed(this) - if (manualHotspotChannelPermissionAsked && !hasFineLocationPermission()) { - manualHotspotChannelPermissionNote = getString(UiR.string.host_channel_detection_permission_skipped_using_auto) - } - wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions() - } - - private fun loadPersistedWirelessSettings() { - wirelessEnabled = AirPlayPersistence.loadWirelessEnabled(this) - wirelessHotspotMode = AirPlayPersistence.loadWirelessHotspotMode(this) - manualHotspotSsid = AirPlayPersistence.loadManualHotspotSsid(this) - manualHotspotPassphrase = AirPlayPersistence.loadManualHotspotPassphrase(this) - manualHotspotBand = AirPlayPersistence.loadManualHotspotBand(this) - manualHotspotChannel = AirPlayPersistence.loadManualHotspotChannel(this) - manualHotspotSecurity = AirPlayPersistence.loadManualHotspotSecurity(this) - } - - private fun requestStartupPrerequisites() { - if (locationReportingEnabled && !locationPermissionAvailable) { - requestLocationPermission() - return - } - if (wirelessEnabled) { - requestWirelessPermissions() - } else { - requestVpnConsent() - } - } - - private fun requestLocationPermission() { - if (locationPermissionAvailable || awaitingLocationPermission) return - awaitingLocationPermission = true - locationPermission.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ), - ) - } - - private fun hasFineLocationPermission(): Boolean = - checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - - /** Precise location is optional on Android 13+: it only enables Wi-Fi scan-result channel detection. */ - private fun requestManualHotspotChannelPermissionIfNeeded(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - wirelessHotspotMode != WirelessHotspotMode.MANUAL || - hasFineLocationPermission() || manualHotspotChannelPermissionAsked - ) return false - - manualHotspotChannelPermissionAsked = true - MaterialAlertDialogBuilder(this) - .setTitle(getString(UiR.string.host_improve_hotspot_channel_detection)) - .setMessage( - getString(UiR.string.ui_android_requires_precise_location_permission_to_read_wi_fi), - ) - .setNegativeButton(getString(UiR.string.host_continue_with_auto)) { _, _ -> - manualHotspotChannelPermissionNote = getString(UiR.string.host_hotspot_channel_scan_skipped_using_auto) - AirPlayPersistence.saveManualHotspotChannelPermissionPromptDismissed(this, true) - appendLog("Hotspot channel scan skipped; continuing with Auto") - updateHotspotStatusBlock() - maybeStartCarPlay() - } - .setPositiveButton(getString(UiR.string.host_allow)) { _, _ -> - manualHotspotChannelPermission.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ), - ) - } - .setOnCancelListener { - manualHotspotChannelPermissionNote = getString(UiR.string.host_hotspot_channel_scan_skipped_using_auto) - AirPlayPersistence.saveManualHotspotChannelPermissionPromptDismissed(this, true) - appendLog("Hotspot channel scan skipped; continuing with Auto") - updateHotspotStatusBlock() - maybeStartCarPlay() - } - .show() - return true - } - - private fun requestVpnConsent() { - val consent = CarPlayVpnService.prepare(this) - if (consent == null) { - vpnReady = true - maybeStartCarPlay() - } else { - awaitingVpnConsent = true - vpnConsent.launch(consent) - } - } - - private fun requestWirelessPermissions() { - val permissions = requiredWirelessPermissions() - if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) { - wirelessPermissionsReady = true - updateHotspotStatusBlock() - maybeStartCarPlay() - return - } - wirelessPermissionsReady = false - updateHotspotStatusBlock() - awaitingWirelessPermissions = true - wirelessPermissions.launch(permissions.toTypedArray()) - } - - private fun hasRequiredWirelessPermissions(): Boolean = - requiredWirelessPermissions().all { - checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED - } - - private fun requiredWirelessPermissions(): List = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> listOf( - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> listOf( - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - ) - else -> listOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - ) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED" && wirelessEnabled) { - shutdown(false, "switching to USB") { - AirPlayPersistence.saveWirelessEnabled(this, false) - startActivity(Intent(this, CarPlayHostActivity::class.java)) - } - finish() - } - } - - override fun onResume() { - super.onResume() - var transportChanged = false - if (!isFinishing && !shuttingDown.get() && controller == null && - !menuOpen && !handshakeResetInProgress - ) { - // The singleTask host can return from settings before a controller exists. - // Refresh the backend and its credentials before any startup/permission checks. - val previousWireless = wirelessEnabled - loadPersistedWirelessSettings() - transportChanged = previousWireless != wirelessEnabled - locationReportingEnabled = AirPlayPersistence.loadLocationReportingEnabled(this) - val selectedMicrophone = AirPlayPersistence.loadMicrophoneSource(this) - if (microphoneSource != selectedMicrophone) { - microphoneSource = selectedMicrophone - microphonePermissionResolved = selectedMicrophone == CarPlayMicrophoneSource.DISABLED || - checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED || - WebSession.audio.microphoneRequested - if (!microphonePermissionResolved) microphonePermission.launch(Manifest.permission.RECORD_AUDIO) - } - } - locationPermissionAvailable = hasFineLocationPermission() - if (locationPermissionAvailable) { - manualHotspotChannelPermissionNote = null - } else if (manualHotspotChannelPermissionAsked && - wirelessHotspotMode == WirelessHotspotMode.MANUAL - ) { - manualHotspotChannelPermissionNote = getString(UiR.string.host_precise_location_unavailable_using_auto_for_channel) - } - if (locationReportingEnabled && !locationPermissionAvailable && !menuOpen) { - requestLocationPermission() - } - wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions() - updateHotspotStatusBlock() - if (transportChanged && microphonePermissionResolved && !awaitingVpnConsent && - !awaitingWirelessPermissions && !awaitingLocationPermission - ) { - requestStartupPrerequisites() - } else { - maybeStartCarPlay() - } - showSystemBars() - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (hasFocus) showSystemBars() - } - - override fun onStop() { - // The controller, USB/iAP2 link, and VPN attachment intentionally outlive the UI. - super.onStop() - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - val nextDarkMode = isDarkMode(newConfig.uiMode) - if (nextDarkMode != darkMode) { - darkMode = nextDarkMode - syncAirPlayDarkMode() - } - showSystemBars() - setContentView(buildContentView().also(ServerWindow::fitContent)) - scrollLogsToBottom() - videoView?.post { - val view = videoView ?: return@post - scheduleDisplaySize(view.width, view.height) - } - } - - override fun onDestroy() { - WebSession.setBrowserViewportListener(null) - mainHandler.removeCallbacks(applyDisplaySize) - mainHandler.removeCallbacks(expireOldLogLines) - currentSurface?.let { surface -> - sink?.clearSurface(SCREEN_TYPE_MAIN, surface) - sink?.clearSurface(SCREEN_TYPE_ALT, surface) - surface.release() - } - currentSurface = null - currentSurfaceTexture = null - sessionLog?.append("Activity destroyed") - sessionLog?.close() - sessionLog = null - super.onDestroy() - } - - private fun buildContentView(): View { - val ui = ServerUi(this) - return ui.content { body -> - body.addView(ui.text(getString(UiR.string.host_connecting_to_iphone), 28, ServerUi.TEXT, true)) - body.addView(ui.gap(24)) - stageStatusView = ui.text(WebSession.stage(this), 16, ServerUi.TEXT) - body.addView(stageStatusView) - body.addView(ui.gap(24)) - body.addView(com.google.android.material.progressindicator.LinearProgressIndicator(this).apply { isIndeterminate = true }) - body.addView(ui.gap(24)) - body.addView(ui.button(getString(UiR.string.host_back_to_service_dashboard)) { showDiPlayHome("service") }, ui.secondaryButtonLayout()) - } - } - - private fun buildSettingsMenu(): View { - val overlay = FrameLayout(this).apply { - setBackgroundColor(Color.BLACK) - isClickable = true - } - val panel = FrameLayout(this).apply { - setBackgroundColor(MENU_BACKGROUND) - } - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - setPadding(dp(48), dp(36), dp(48), dp(36)) - } - content.addView( - menuText(getString(UiR.string.host_carplay_settings), 32f, Color.WHITE, bold = true).apply { - setPadding(dp(56), 0, 0, 0) - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - content.addView( - settingsCategoryHeader(getString(UiR.string.host_connection)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(32) }, - ) - - content.addView( - buildMfiTargetSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(14) }, - ) - - val wirelessRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - wirelessRow.addView( - menuText(getString(UiR.string.host_wireless_carplay), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - val wirelessSwitch = Switch(this).apply { - isChecked = wirelessEnabled - contentDescription = getString(UiR.string.host_wireless_carplay_transport) - showText = false - thumbTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT, MENU_SECONDARY), - ) - trackTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), - ) - setOnCheckedChangeListener { _, checked -> - if (wirelessEnabled == checked) return@setOnCheckedChangeListener - wirelessEnabled = checked - hotspotStatus = HotspotStatus(state = UiText(if (wirelessEnabled) UiR.string.ui_stopped else UiR.string.ui_disabled)) - updateHotspotStatusBlock() - appendLog( - "Wireless CarPlay ${if (wirelessEnabled) "enabled" else "disabled"}; applies when settings close", - ) - requestStartupPrerequisites() - } - } - wirelessRow.addView( - wirelessSwitch, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - content.addView( - wirelessRow, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(30) }, - ) - - content.addView( - buildHotspotModeSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(30) }, - ) - - content.addView( - menuText(getString(UiR.string.host_hotspot_status), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(18) }, - ) - val hotspotStatusView = menuText("", 16f, MENU_ACCENT) - content.addView( - hotspotStatusView, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - - content.addView( - settingsCategoryHeader(getString(UiR.string.host_location)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(36) }, - ) - content.addView( - buildLocationReportingSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - - content.addView( - settingsCategoryHeader(getString(UiR.string.host_startup)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(36) }, - ) - content.addView( - settingsSwitchRow( - label = getString(UiR.string.host_auto_start_on_boot), - checked = autoStartOnBoot, - description = getString(UiR.string.host_start_carplay_automatically_after_device_boot), - ) { checked -> - autoStartOnBoot = checked - appendLog("Boot auto-start ${if (checked) "enabled" else "disabled"}") - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - - if (advancedAudioChannelMappingSupported) { - content.addView( - settingsCategoryHeader(getString(UiR.string.host_audio)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(36) }, - ) - content.addView( - settingsSwitchRow( - label = getString(UiR.string.host_advanced_audio_channel_mapping), - checked = advancedAudioChannelMapping, - description = getString(UiR.string.host_route_aaos_audio_buses_by_carplay_audio_type), - ) { checked -> - advancedAudioChannelMapping = checked - appendLog( - "Advanced audio channel mapping ${if (checked) "enabled" else "disabled"}; applies when settings close", - ) - updateResolutionMenu() - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - } - - content.addView( - settingsCategoryHeader(getString(UiR.string.host_identity_appearance)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(36) }, - ) - content.addView( - buildIdentitySettingsSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - content.addView( - buildAirPlayIconSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(26) }, - ) - content.addView( - buildDrivingSideSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(26) }, - ) - content.addView( - settingsCategoryHeader(getString(UiR.string.host_display_video)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(40) }, - ) - - val resolutionHeader = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - resolutionHeader.addView( - menuText(getString(UiR.string.host_resolution), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - val resolutionValue = menuText( - CarPlayDisplayScale.label(displayScaleTenths), - 28f, - MENU_ACCENT, - bold = true, - ) - resolutionHeader.addView( - resolutionValue, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - content.addView( - resolutionHeader, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(14) }, - ) - - val seekBar = SeekBar(this).apply { - max = CarPlayDisplayScale.MAX_TENTHS - CarPlayDisplayScale.MIN_TENTHS - progress = displayScaleTenths - CarPlayDisplayScale.MIN_TENTHS - splitTrack = false - progressTintList = ColorStateList.valueOf(MENU_ACCENT) - thumbTintList = ColorStateList.valueOf(MENU_ACCENT) - setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - displayScaleTenths = CarPlayDisplayScale.sanitize( - CarPlayDisplayScale.MIN_TENTHS + progress, - ) - updateResolutionMenu() - } - - override fun onStartTrackingTouch(seekBar: SeekBar) = Unit - override fun onStopTrackingTouch(seekBar: SeekBar) = Unit - }, - ) - } - content.addView( - seekBar, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - - val range = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - } - range.addView( - menuText("0.3x", 15f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - range.addView( - menuText("1.0x", 15f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - content.addView( - range, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - - content.addView( - buildStepSliderSection( - title = getString(UiR.string.host_frame_rate), - values = ( - AirPlayDisplaySettings.MIN_FPS..AirPlayDisplaySettings.MAX_FPS - step AirPlayDisplaySettings.FPS_STEP - ).toList(), - selectedValue = fps, - label = { "$it fps" }, - onValueChanged = { value -> - fps = value - updateResolutionMenu() - }, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(24) }, - ) - - content.addView( - settingsChoiceRow( - label = getString(UiR.string.host_physical_size_basis), - options = listOf( - AirPlayPhysicalSizeBasis.WIDTH to getString(UiR.string.host_widest_width), - AirPlayPhysicalSizeBasis.HEIGHT to getString(UiR.string.host_longest_height), - ), - selected = physicalSizeBasis, - ) { value -> - physicalSizeBasis = value - updateResolutionMenu() - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(24) }, - ) - - content.addView( - buildStepSliderSection( - title = getString(UiR.string.host_physical_length), - values = ( - AirPlayDisplaySettings.MIN_WIDTH_PHYSICAL_MM.. - AirPlayDisplaySettings.MAX_WIDTH_PHYSICAL_MM - step AirPlayDisplaySettings.WIDTH_PHYSICAL_MM_STEP - ).toList(), - selectedValue = widthPhysicalMm, - label = { "$it mm" }, - onValueChanged = { value -> - widthPhysicalMm = value - updateResolutionMenu() - }, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(16) }, - ) - - content.addView(settingsChoiceRow( - label = getString(UiR.string.host_streaming_technology), - options = com.shilapi.xcertplay.web.StreamTechnology.entries.map { it to getString(it.labelRes) }, - selected = streamTechnology, - ) { streamTechnology = it }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(24) }) - content.addView(menuText(getString(UiR.string.host_webrtc_supports_h_264_hevc_passthrough_and_falls_back), 14f, MENU_SECONDARY), - LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - .apply { topMargin = dp(8); bottomMargin = dp(16) }) - - val hevcRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - hevcRow.addView( - menuText(getString(UiR.string.host_hevc_h_265), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - val hevcSwitch = Switch(this).apply { - isChecked = hevcEnabled - contentDescription = getString(UiR.string.host_hevc_h_265_video_transport) - showText = false - thumbTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT, MENU_SECONDARY), - ) - trackTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), - ) - setOnCheckedChangeListener { _, checked -> - if (hevcEnabled == checked) return@setOnCheckedChangeListener - hevcEnabled = checked - appendLog( - "HEVC (H.265) ${if (hevcEnabled) "enabled" else "disabled"}; applies when settings close", - ) - updateResolutionMenu() - } - } - hevcRow.addView( - hevcSwitch, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - content.addView( - hevcRow, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(30) }, - ) - - val softwareHevcRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - softwareHevcRow.addView( - menuText(getString(UiR.string.host_hevc_software_decoder), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - val softwareHevcSwitch = Switch(this).apply { - isChecked = hevcSoftwareDecoderEnabled - contentDescription = getString(UiR.string.host_use_software_hevc_decoder) - showText = false - thumbTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT, MENU_SECONDARY), - ) - trackTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), - ) - setOnCheckedChangeListener { _, checked -> - if (hevcSoftwareDecoderEnabled == checked) return@setOnCheckedChangeListener - hevcSoftwareDecoderEnabled = checked - appendLog( - "HEVC software decoder ${if (hevcSoftwareDecoderEnabled) "enabled" else "disabled"}; applies when settings close", - ) - updateResolutionMenu() - } - } - softwareHevcRow.addView( - softwareHevcSwitch, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - content.addView( - softwareHevcRow, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(16) }, - ) - } - - content.addView( - buildSafeAreaSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(30) }, - ) - - content.addView( - settingsCategoryHeader(getString(UiR.string.host_diagnostics)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(40) }, - ) - content.addView( - buildDebugLogsSection(), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - content.addView( - settingsCategoryHeader(getString(UiR.string.host_android_9_compatibility)), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(40) }, - ) - content.addView( - menuText( - getString(UiR.string.ui_the_following_settings_are_unavailable_and_hidden_on_android), - 16f, - MENU_SECONDARY, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - } - - val preview = menuText("", 17f, MENU_SECONDARY) - content.addView( - preview, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(30) }, - ) - - val save = Button(this).apply { - text = getString(UiR.string.host_save_and_reconnect) - isAllCaps = false - textSize = 17f - setTextColor(MENU_BUTTON_TEXT) - backgroundTintList = ColorStateList.valueOf(MENU_ACCENT) - minHeight = dp(52) - setOnClickListener { saveSettingsAndReconnect() } - } - content.addView( - save, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(46) }, - ) - - val exitApplicationButton = Button(this).apply { - text = getString(UiR.string.host_exit_application) - isAllCaps = false - textSize = 17f - setTextColor(Color.WHITE) - backgroundTintList = ColorStateList.valueOf(MENU_DANGER) - minHeight = dp(52) - setOnClickListener { exitApplication() } - } - content.addView( - exitApplicationButton, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - - val scroll = ScrollView(this).apply { - isFillViewport = true - addView( - content, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - } - panel.addView( - scroll, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, - ), - ) - panel.addView( - Button(this).apply { - text = "X" - isAllCaps = false - textSize = 22f - setTextColor(Color.WHITE) - backgroundTintList = ColorStateList.valueOf(MENU_TRACK_OFF) - contentDescription = getString(UiR.string.host_discard_changes_and_exit_settings) - minWidth = 0 - minHeight = 0 - setPadding(0, 0, 0, 0) - setOnClickListener { cancelSettingsEdits() } - }, - FrameLayout.LayoutParams(dp(48), dp(48), Gravity.TOP or Gravity.START).apply { - leftMargin = dp(16) - topMargin = dp(16) - }, - ) - overlay.addView( - panel, - FrameLayout.LayoutParams( - minOf(resources.displayMetrics.widthPixels, MAX_SETTINGS_MENU_WIDTH_PX), - FrameLayout.LayoutParams.MATCH_PARENT, - Gravity.CENTER, - ), - ) - overlay.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> - val desiredWidth = minOf(view.width, MAX_SETTINGS_MENU_WIDTH_PX) - val params = panel.layoutParams - if (params.width != desiredWidth) { - params.width = desiredWidth - panel.layoutParams = params - } - } - - resolutionValueView = resolutionValue - resolutionPreviewView = preview - this.hotspotStatusView = hotspotStatusView - updateHotspotStatusBlock() - updateResolutionMenu() - return overlay - } - - private fun persistMenuSettings() { - AirPlayPersistence.saveWirelessEnabled(this, wirelessEnabled) - AirPlayPersistence.saveMfiTarget(this, mfiTarget) - AirPlayPersistence.saveMfiI2cPath(this, mfiI2cPath) - AirPlayPersistence.saveRemoteMfiServer(this, remoteMfiServer) - AirPlayPersistence.saveRemoteMfiToken(this, remoteMfiToken) - AirPlayPersistence.saveWirelessHotspotMode(this, wirelessHotspotMode) - AirPlayPersistence.saveManualHotspotSsid(this, manualHotspotSsid) - AirPlayPersistence.saveManualHotspotPassphrase(this, manualHotspotPassphrase) - AirPlayPersistence.saveManualHotspotBand(this, manualHotspotBand) - AirPlayPersistence.saveManualHotspotChannel(this, manualHotspotChannel) - AirPlayPersistence.saveManualHotspotSecurity(this, manualHotspotSecurity) - AirPlayPersistence.saveLocationReportingEnabled(this, locationReportingEnabled) - AirPlayPersistence.saveAutoStartOnBoot(this, autoStartOnBoot) - AirPlayPersistence.saveAdvancedAudioChannelMapping(this, advancedAudioChannelMapping) - AirPlayPersistence.saveDisplayScaleTenths(this, displayScaleTenths) - AirPlayPersistence.saveFps(this, fps) - AirPlayPersistence.saveStreamTechnology(this, streamTechnology) - AirPlayPersistence.saveWidthPhysicalMm(this, widthPhysicalMm) - AirPlayPersistence.savePhysicalSizeBasis(this, physicalSizeBasis) - AirPlayPersistence.saveHevcEnabled(this, hevcEnabled) - AirPlayPersistence.saveHevcSoftwareDecoderEnabled(this, hevcSoftwareDecoderEnabled) - AirPlayPersistence.saveModel(this, model) - AirPlayPersistence.saveSimulatedManufacturerName(this, simulatedManufacturerName) - AirPlayPersistence.saveDebugLogsEnabled(this, debugLogsEnabled) - AirPlayPersistence.saveRightHandDrive(this, rightHandDrive) - AirPlayPersistence.saveSafeAreaDrawOutside(this, safeAreaDrawOutside) - } - - private fun captureSettingsBaseline(): SettingsBaseline { - val safeAreaSize = currentActivitySize() - val customIconBytes = try { - AirPlayPersistence.loadCustomAirPlayIconFile(this)?.readBytes() - } catch (error: Exception) { - Log.w(TAG, "Could not read the current AirPlay icon for settings rollback", error) - null - } - return SettingsBaseline( - safeAreaSize = safeAreaSize, - safeAreaRect = safeAreaSize?.let { - AirPlayPersistence.loadSafeAreaRect(this, it.width, it.height) - }, - customIconBytes = customIconBytes, - ) - } - - private fun restoreSettingsBaseline() { - val baseline = settingsBaseline ?: return - loadPersistedSettings() - baseline.safeAreaSize?.let { size -> - baseline.safeAreaRect?.let { rect -> - AirPlayPersistence.saveSafeAreaRect( - this, - size.width, - size.height, - rect, - commit = true, - ) - } ?: AirPlayPersistence.clearSafeAreaRect( - this, - size.width, - size.height, - commit = true, - ) - } - try { - baseline.customIconBytes?.let { bytes -> - AirPlayPersistence.saveCustomAirPlayIcon(this, bytes) - } ?: AirPlayPersistence.clearCustomAirPlayIcon(this) - } catch (error: Exception) { - Log.w(TAG, "Could not restore the previous AirPlay icon", error) - } - settingsBaseline = null - locationPermissionAvailable = hasFineLocationPermission() - hotspotStatus = HotspotStatus(state = UiText(if (wirelessEnabled) UiR.string.ui_stopped else UiR.string.ui_disabled)) - syncMfiSettingsControls() - updateManualHotspotFields() - updateAirPlayIconPreview() - updateSafeAreaSummary() - updateHotspotStatusBlock() - updateResolutionMenu() - updateDebugOverlays() - showSystemBars() - refreshDisplaySizeAfterLayout() - } - - private fun buildMfiTargetSection(): View { - val section = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - val targetChoice = settingsChoiceRow( - label = getString(UiR.string.host_mfi_certificate_signing_target), - options = listOf( - MfiTarget.USB_CH341 to "USB/CH341", - MfiTarget.I2C to "I2C", - MfiTarget.REMOTE to getString(UiR.string.host_remote), - ), - selected = mfiTarget, - ) { target -> - if (mfiTarget == target) return@settingsChoiceRow - mfiTarget = target - updateMfiTargetFields() - appendLog("MFI target: ${mfiTargetLabel(target)}; applies when settings close") - } - mfiTargetGroup = (targetChoice as ViewGroup).getChildAt(1) as RadioGroup - section.addView( - targetChoice, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - - val i2cFields = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - addView( - settingsInputRow( - "I2C device", - mfiI2cPath, - onInputCreated = { mfiI2cPathInput = it }, - ) { value -> - mfiI2cPath = value - mfiErrorView?.visibility = View.GONE - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - addView( - menuText(getString(UiR.string.host_linux_device_path_for_example_dev_i2c_1), 14f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(4) }, - ) - } - section.addView( - i2cFields, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - mfiI2cFields = i2cFields - - val remoteFields = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - addView( - settingsInputRow( - getString(UiR.string.host_server_address), - remoteMfiServer, - onInputCreated = { remoteMfiServerInput = it }, - ) { value -> - remoteMfiServer = value - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - addView( - settingsInputRow( - getString(UiR.string.host_token_optional), - remoteMfiToken, - password = true, - onInputCreated = { remoteMfiTokenInput = it }, - ) { value -> - remoteMfiToken = value - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - addView( - menuText( - getString(UiR.string.ui_address_must_start_with_http_or_https_token_is), - 14f, - MENU_SECONDARY, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(4) }, - ) - } - section.addView( - remoteFields, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - mfiRemoteFields = remoteFields - val error = menuText("", 14f, MENU_DANGER).apply { - visibility = View.GONE - } - section.addView( - error, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - mfiErrorView = error - updateMfiTargetFields() - return section - } - - private fun updateMfiTargetFields() { - mfiI2cFields?.visibility = if (mfiTarget == MfiTarget.I2C) View.VISIBLE else View.GONE - mfiRemoteFields?.visibility = if (mfiTarget == MfiTarget.REMOTE) View.VISIBLE else View.GONE - mfiErrorView?.visibility = View.GONE - } - - private fun syncMfiSettingsControls() { - mfiTargetGroup?.let { group -> - val button = (0 until group.childCount) - .map { group.getChildAt(it) } - .filterIsInstance() - .firstOrNull { it.tag == mfiTarget } - button?.let { group.check(it.id) } - } - if (mfiI2cPathInput?.text?.toString() != mfiI2cPath) { - mfiI2cPathInput?.setText(mfiI2cPath) - } - if (remoteMfiServerInput?.text?.toString() != remoteMfiServer) { - remoteMfiServerInput?.setText(remoteMfiServer) - } - if (remoteMfiTokenInput?.text?.toString() != remoteMfiToken) { - remoteMfiTokenInput?.setText(remoteMfiToken) - } - updateMfiTargetFields() - } - - private fun mfiTargetLabel(target: MfiTarget): String = when (target) { - MfiTarget.LOCAL -> getString(UiR.string.host_local_offline) - MfiTarget.USB_CH341 -> "USB/CH341" - MfiTarget.I2C -> "I2C" - MfiTarget.REMOTE -> getString(UiR.string.host_remote) - } - - private fun buildIdentitySettingsSection(): View { - val section = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - section.addView( - settingsInputRow(getString(UiR.string.host_simulated_manufacturer_name), simulatedManufacturerName) { value -> - simulatedManufacturerName = value - updateResolutionMenu() - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - section.addView( - settingsInputRow(getString(UiR.string.host_model), model) { value -> - model = value - updateResolutionMenu() - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(10) }, - ) - return section - } - - private fun settingsCategoryHeader(title: String): TextView = - menuText(title, 16f, MENU_ACCENT, bold = true) - - private fun buildLocationReportingSection(): View = - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - val row = LinearLayout(this@CarPlayHostActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - row.addView( - menuText(getString(UiR.string.host_report_location_to_iphone), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - val switch = Switch(this@CarPlayHostActivity).apply { - isChecked = locationReportingEnabled - contentDescription = getString(UiR.string.host_report_android_location_to_the_iphone) - showText = false - thumbTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT, MENU_SECONDARY), - ) - trackTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), - ) - setOnCheckedChangeListener { _, checked -> - onLocationReportingChanged(checked) - } - } - locationReportingSwitch = switch - row.addView( - switch, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - addView( - row, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - addView( - menuText( - getString(UiR.string.host_sends_precise_android_location_as_carplay_gps_data_when), - 14f, - MENU_SECONDARY, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - } - - private fun onLocationReportingChanged(checked: Boolean) { - if (locationReportingEnabled == checked) return - locationReportingEnabled = checked - appendLog( - "Location reporting ${if (locationReportingEnabled) "enabled" else "disabled"}; applies when settings close", - ) - updateResolutionMenu() - if (locationReportingEnabled && !locationPermissionAvailable) { - requestLocationPermission() - } - } - - private fun buildDebugLogsSection(): View = - settingsSwitchRow( - label = getString(UiR.string.host_debug_logs), - checked = debugLogsEnabled, - description = getString(UiR.string.host_show_on_screen_debug_logs), - ) { checked -> - debugLogsEnabled = checked - appendLog("Debug logs ${if (debugLogsEnabled) "enabled" else "disabled"}") - updateDebugOverlays() - } - - private fun buildStepSliderSection( - title: String, - values: List, - selectedValue: Int, - label: (Int) -> String, - onValueChanged: (Int) -> Unit, - ): View { - val section = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - val header = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - header.addView( - menuText(title, 20f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - val selectedIndex = values.indexOf(selectedValue) - .takeIf { it >= 0 } - ?: 0 - val valueView = menuText(label(values[selectedIndex]), 22f, MENU_ACCENT, bold = true) - header.addView( - valueView, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - section.addView( - header, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - val seekBar = SeekBar(this).apply { - max = (values.size - 1).coerceAtLeast(0) - progress = selectedIndex - splitTrack = false - progressTintList = ColorStateList.valueOf(MENU_ACCENT) - thumbTintList = ColorStateList.valueOf(MENU_ACCENT) - setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - val value = values.getOrNull(progress) ?: return - valueView.text = label(value) - if (fromUser) onValueChanged(value) - } - - override fun onStartTrackingTouch(seekBar: SeekBar) = Unit - override fun onStopTrackingTouch(seekBar: SeekBar) = Unit - }, - ) - } - section.addView( - seekBar, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - return section - } - - private fun buildAirPlayIconSection(): View { - val section = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - section.addView( - menuText(getString(UiR.string.host_simulated_manufacturer_icon), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - val preview = ImageView(this).apply { - scaleType = ImageView.ScaleType.CENTER_CROP - background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = dp(8).toFloat() - setColor(MENU_TRACK_OFF) - } - } - row.addView( - preview, - LinearLayout.LayoutParams(dp(72), dp(72)), - ) - val actions = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - actions.addView( - Button(this).apply { - text = getString(UiR.string.host_choose_image) - isAllCaps = false - setOnClickListener { - externalActivityInProgress = true - imagePicker.launch("image/*") - } - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - actions.addView( - Button(this).apply { - text = getString(UiR.string.host_use_default_icon) - isAllCaps = false - setOnClickListener { - AirPlayPersistence.clearCustomAirPlayIcon(this@CarPlayHostActivity) - updateAirPlayIconPreview() - } - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - row.addView( - actions, - LinearLayout.LayoutParams( - 0, - ViewGroup.LayoutParams.WRAP_CONTENT, - 1f, - ).apply { marginStart = dp(16) }, - ) - section.addView( - row, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(10) }, - ) - val status = menuText("", 14f, MENU_SECONDARY) - section.addView( - status, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - iconPreviewView = preview - iconStatusView = status - updateAirPlayIconPreview() - return section - } - - private fun buildDrivingSideSection(): View { - val section = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - section.addView( - menuText(getString(UiR.string.host_driving_side), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - val group = RadioGroup(this).apply { - orientation = RadioGroup.HORIZONTAL - } - val left = RadioButton(this).apply { - id = View.generateViewId() - text = getString(UiR.string.host_left_hand_drive) - setTextColor(Color.WHITE) - isChecked = !rightHandDrive - } - val right = RadioButton(this).apply { - id = View.generateViewId() - text = getString(UiR.string.host_right_hand_drive) - setTextColor(Color.WHITE) - isChecked = rightHandDrive - } - group.addView(left) - group.addView(right) - group.setOnCheckedChangeListener { _, checkedId -> - rightHandDrive = checkedId == right.id - updateResolutionMenu() - } - section.addView( - group, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - return section - } - - private fun buildSafeAreaSection(): View { - val section = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - section.addView( - menuText(getString(UiR.string.host_safe_area), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - val summary = menuText("", 15f, MENU_ACCENT) - section.addView( - summary, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - val buttons = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - } - buttons.addView( - Button(this).apply { - text = getString(UiR.string.host_set) - isAllCaps = false - setOnClickListener { openSafeAreaEditor() } - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - buttons.addView( - Button(this).apply { - text = getString(UiR.string.host_reset) - isAllCaps = false - setOnClickListener { resetSafeAreaForCurrentSize() } - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - marginStart = dp(12) - }, - ) - section.addView( - buttons, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(10) }, - ) - section.addView( - settingsSwitchRow( - label = getString(UiR.string.host_draw_outside_safe_area), - checked = safeAreaDrawOutside, - description = getString(UiR.string.host_allow_carplay_ui_outside_the_safe_area), - ) { checked -> - safeAreaDrawOutside = checked - updateResolutionMenu() - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(12) }, - ) - safeAreaSummaryView = summary - updateSafeAreaSummary() - return section - } - - private fun buildSafeAreaEditor(): View { - val overlay = FrameLayout(this).apply { - setBackgroundColor(Color.BLACK) - isClickable = true - } - val editor = SafeAreaEditorView(this) - overlay.addView( - editor, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, - ), - ) - overlay.addView( - menuText(getString(UiR.string.host_safe_area), 24f, Color.WHITE, bold = true).apply { - setPadding(dp(16), dp(12), dp(16), dp(8)) - }, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT, - Gravity.TOP or Gravity.START, - ), - ) - val controls = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER - setPadding(dp(16), dp(10), dp(16), dp(16)) - } - controls.addView( - Button(this).apply { - text = getString(UiR.string.ui_cancel) - isAllCaps = false - setOnClickListener { closeSafeAreaEditor() } - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - controls.addView( - Button(this).apply { - text = getString(UiR.string.host_save) - isAllCaps = false - setOnClickListener { saveSafeAreaEditor() } - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - marginStart = dp(12) - }, - ) - overlay.addView( - controls, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM, - ), - ) - safeAreaEditorView = editor - return overlay - } - - private fun settingsInputRow( - label: String, - value: String, - password: Boolean = false, - numeric: Boolean = false, - onInputCreated: ((EditText) -> Unit)? = null, - onChanged: (String) -> Unit, - ): View = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView( - menuText(label, 18f, MENU_SECONDARY).apply { - gravity = Gravity.CENTER_VERTICAL - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - addView( - EditText(this@CarPlayHostActivity).apply { - setText(value) - textSize = 18f - setTextColor(Color.WHITE) - setHintTextColor(MENU_SECONDARY) - backgroundTintList = ColorStateList.valueOf(MENU_ACCENT) - minHeight = dp(48) - isSingleLine = true - inputType = when { - numeric -> InputType.TYPE_CLASS_NUMBER - password -> InputType.TYPE_CLASS_TEXT or - InputType.TYPE_TEXT_VARIATION_PASSWORD or - InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - else -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - } - addTextChangedListener(afterTextChanged(onChanged)) - onInputCreated?.invoke(this) - }, - LinearLayout.LayoutParams( - 0, - ViewGroup.LayoutParams.WRAP_CONTENT, - 1f, - ).apply { marginStart = dp(12) }, - ) - } - - private fun settingsSwitchRow( - label: String, - checked: Boolean, - description: String, - onChanged: (Boolean) -> Unit, - ): View = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView( - menuText(label, 18f, MENU_SECONDARY), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - addView( - Switch(this@CarPlayHostActivity).apply { - isChecked = checked - contentDescription = description - showText = false - thumbTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT, MENU_SECONDARY), - ) - trackTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), - ) - setOnCheckedChangeListener { _, value -> onChanged(value) } - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - } - - private fun afterTextChanged(onChanged: (String) -> Unit): TextWatcher = - object : TextWatcher { - override fun beforeTextChanged( - text: CharSequence?, - start: Int, - count: Int, - after: Int, - ) = Unit - - override fun onTextChanged( - text: CharSequence?, - start: Int, - before: Int, - count: Int, - ) = Unit - - override fun afterTextChanged(text: Editable?) { - onChanged(text?.toString().orEmpty()) - } - } - - private fun buildHotspotModeSection(): View { - val section = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - section.addView( - menuText(getString(UiR.string.host_wi_fi_session), 20f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - - val group = RadioGroup(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, dp(8), 0, 0) - } - val modes = buildList { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - add(WirelessHotspotMode.WIFI_P2P to "Wi-Fi P2P (5 GHz)") - } - add(WirelessHotspotMode.LOCAL_ONLY_HOTSPOT to "LocalOnlyHotspot") - add(WirelessHotspotMode.MANUAL to getString(UiR.string.host_manual_hotspot)) - } - var selectedId = View.NO_ID - for ((mode, label) in modes) { - val button = RadioButton(this).apply { - id = View.generateViewId() - text = label - textSize = 18f - setTextColor(MENU_SECONDARY) - buttonTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT, MENU_SECONDARY), - ) - tag = mode - isChecked = wirelessHotspotMode == mode - } - if (wirelessHotspotMode == mode) selectedId = button.id - group.addView( - button, - RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - } - if (selectedId != View.NO_ID) group.check(selectedId) - group.setOnCheckedChangeListener { radioGroup, checkedId -> - val selected = radioGroup.findViewById(checkedId) - ?.tag as? WirelessHotspotMode - ?: return@setOnCheckedChangeListener - if (wirelessHotspotMode == selected) return@setOnCheckedChangeListener - wirelessHotspotMode = selected - hotspotStatus = HotspotStatus(state = UiText(if (wirelessEnabled) UiR.string.ui_stopped else UiR.string.ui_disabled)) - updateHotspotStatusBlock() - updateManualHotspotFields() - appendLog( - "Wi-Fi session mode: ${hotspotModeLabel(wirelessHotspotMode)}; applies when settings close", - ) - } - section.addView( - group, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - - val manualFields = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - manualFields.addView( - settingsInputRow(getString(UiR.string.host_hotspot_ssid), manualHotspotSsid) { value -> - manualHotspotSsid = value - manualHotspotErrorView?.visibility = View.GONE - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - - manualFields.addView( - settingsChoiceRow( - label = getString(UiR.string.host_band), - options = listOf( - ManualHotspotBand.AUTO to getString(UiR.string.host_auto), - ManualHotspotBand.GHZ_2_4 to "2.4 GHz", - ManualHotspotBand.GHZ_5 to "5 GHz", - ), - selected = manualHotspotBand, - ) { value -> - manualHotspotBand = value - manualHotspotErrorView?.visibility = View.GONE - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(10) }, - ) - - manualFields.addView( - settingsInputRow( - label = getString(UiR.string.host_channel_0_auto), - value = manualHotspotChannel.toString(), - numeric = true, - ) { value -> - manualHotspotChannel = value.toIntOrNull() ?: -1 - manualHotspotErrorView?.visibility = View.GONE - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(10) }, - ) - - manualFields.addView( - menuText( - getString(UiR.string.ui_android_13_precise_location_enables_hotspot_channel_detection_if), - 14f, - MENU_SECONDARY, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(4) }, - ) - - manualFields.addView( - settingsInputRow( - label = getString(UiR.string.host_hotspot_password), - value = manualHotspotPassphrase, - password = true, - ) { value -> - manualHotspotPassphrase = value - manualHotspotErrorView?.visibility = View.GONE - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(10) }, - ) - - manualFields.addView( - settingsChoiceRow( - label = getString(UiR.string.host_security), - options = listOf( - ManualHotspotSecurity.OPEN to getString(UiR.string.host_open), - ManualHotspotSecurity.WPA2 to "WPA2", - ManualHotspotSecurity.WPA3_TRANSITION to getString(UiR.string.ui_wpa3_transition), - ManualHotspotSecurity.WPA3 to "WPA3", - ), - selected = manualHotspotSecurity, - ) { value -> - manualHotspotSecurity = value - manualHotspotErrorView?.visibility = View.GONE - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(10) }, - ) - - val error = menuText("", 14f, Color.rgb(0xff, 0x7a, 0x7a)).apply { - visibility = View.GONE - } - manualFields.addView( - error, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - - section.addView( - manualFields, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }, - ) - manualHotspotFields = manualFields - manualHotspotErrorView = error - updateManualHotspotFields() - return section - } - - private fun updateManualHotspotFields() { - val visible = wirelessHotspotMode == WirelessHotspotMode.MANUAL - manualHotspotFields?.visibility = if (visible) View.VISIBLE else View.GONE - if (!visible) manualHotspotErrorView?.visibility = View.GONE - } - - private fun validateMfiSettings(): Boolean { - val error = when { - mfiTarget == MfiTarget.I2C && mfiI2cPath.isBlank() -> - "I2C device path is required" - mfiTarget == MfiTarget.REMOTE && remoteMfiServer.isBlank() -> - getString(UiR.string.host_remote_server_address_is_required) - mfiTarget == MfiTarget.REMOTE && - !remoteMfiServer.trim().startsWith("http://") && - !remoteMfiServer.trim().startsWith("https://") -> - getString(UiR.string.host_remote_server_address_must_start_with_http_or_https) - '\u0000' in mfiI2cPath -> "I2C device path contains U+0000" - '\u0000' in remoteMfiServer -> getString(UiR.string.host_remote_server_address_contains_u_0000) - '\u0000' in remoteMfiToken -> getString(UiR.string.host_remote_token_contains_u_0000) - else -> null - } - mfiErrorView?.text = error.orEmpty() - mfiErrorView?.visibility = if (error == null) View.GONE else View.VISIBLE - return error == null - } - - private fun validateManualHotspotSettings(): Boolean { - if (wirelessHotspotMode != WirelessHotspotMode.MANUAL) return true - val error = when { - manualHotspotSsid.isBlank() -> getString(UiR.string.host_hotspot_ssid_is_required) - manualHotspotSsid.encodeToByteArray().size > 32 -> - getString(UiR.string.host_hotspot_ssid_must_be_at_most_32_utf_8) - '\u0000' in manualHotspotSsid -> getString(UiR.string.host_hotspot_ssid_contains_u_0000) - manualHotspotChannel !in 0..196 -> getString(UiR.string.host_channel_must_be_0_or_1_196) - manualHotspotChannel != 0 && - !isManualHotspotChannelCompatible(manualHotspotBand, manualHotspotChannel) -> - getString(UiR.string.host_channel_is_not_valid_for_the_selected_band) - '\u0000' in manualHotspotPassphrase -> getString(UiR.string.host_hotspot_password_contains_u_0000) - manualHotspotSecurity == ManualHotspotSecurity.OPEN && - manualHotspotPassphrase.isNotEmpty() -> - getString(UiR.string.host_password_must_be_empty_when_security_is_open) - manualHotspotSecurity != ManualHotspotSecurity.OPEN && - manualHotspotPassphrase.length !in 8..63 -> - "WPA2/WPA3 password must be 8-63 characters" - else -> null - } - manualHotspotErrorView?.text = error.orEmpty() - manualHotspotErrorView?.visibility = if (error == null) View.GONE else View.VISIBLE - return error == null - } - - private fun hotspotModeLabel(mode: WirelessHotspotMode): String = when (mode) { - WirelessHotspotMode.WIFI_P2P -> "Wi-Fi P2P (5 GHz)" - WirelessHotspotMode.LOCAL_ONLY_HOTSPOT -> "LocalOnlyHotspot" - WirelessHotspotMode.MANUAL -> getString(UiR.string.host_manual_hotspot) - } - - private fun menuText( - text: String, - sizeSp: Float, - color: Int, - bold: Boolean = false, - ): TextView = TextView(this).apply { - this.text = text - textSize = sizeSp - setTextColor(color) - typeface = if (bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT - includeFontPadding = false - } - - private fun updateHotspotStatus(status: CarPlayStatus) { - if (!wirelessEnabled) return - hotspotStatus = when (status) { - CarPlayStatus.StartingHotspot -> HotspotStatus(state = UiText(UiR.string.host_starting)) - is CarPlayStatus.HotspotReady -> HotspotStatus( - state = UiText(UiR.string.host_ready), - ssid = status.ssid, - band = status.band, - channel = status.channel, - backend = status.backend, - ) - CarPlayStatus.WaitingForPairedIphone -> - hotspotStatus.copy(state = UiText(UiR.string.host_waiting_for_paired_iphone)) - CarPlayStatus.ConnectingBluetooth -> - hotspotStatus.copy(state = UiText(UiR.string.host_connecting_bluetooth)) - CarPlayStatus.RunningWireless -> - hotspotStatus.copy(state = UiText(UiR.string.host_running)) - CarPlayStatus.WirelessActiveFallback -> - hotspotStatus.copy(state = UiText(UiR.string.host_active_bluetooth_control)) - CarPlayStatus.WirelessActive -> - hotspotStatus.copy(state = UiText(UiR.string.host_active)) - CarPlayStatus.AttachingNetwork -> - hotspotStatus.copy(state = UiText(UiR.string.host_starting_airplay_service)) - is CarPlayStatus.Failed -> hotspotStatus.copy(state = UiText(UiR.string.host_error)) - else -> return - } - updateHotspotStatusBlock() - } - - private fun updateHotspotStatusBlock() { - if (!wirelessEnabled) { - hotspotStatusView?.text = getString(UiR.string.host_wireless_hotspot_off) - return - } - val status = hotspotStatus - hotspotStatusView?.text = buildString { - append(getString(UiR.string.host_wireless_hotspot)).append(status.state.resolve(this@CarPlayHostActivity)) - status.ssid?.let { append("\nSSID: ").append(it) } - status.backend?.let { append(getString(UiR.string.ui_nbackend)).append(it) } - status.band?.let { append(getString(UiR.string.ui_nband)).append(it) } - status.channel?.let { - append(getString(UiR.string.host_hotspot_channel)).append(if (it == 0) getString(UiR.string.host_auto) else it.toString()) - } - if (wirelessHotspotMode == WirelessHotspotMode.MANUAL) { - manualHotspotChannelPermissionNote?.let { append('\n').append(it) } - } - } - } - - @Suppress("UNCHECKED_CAST") - private fun settingsChoiceRow( - label: String, - options: List>, - selected: T, - onSelected: (T) -> Unit, - ): View = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - addView( - menuText(label, 18f, MENU_SECONDARY), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - val group = RadioGroup(this@CarPlayHostActivity).apply { - orientation = RadioGroup.VERTICAL - setPadding(0, dp(4), 0, 0) - } - var selectedId = View.NO_ID - for ((value, text) in options) { - val button = RadioButton(this@CarPlayHostActivity).apply { - id = View.generateViewId() - this.text = text - textSize = 17f - setTextColor(MENU_SECONDARY) - buttonTintList = ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(MENU_ACCENT, MENU_SECONDARY), - ) - tag = value - isChecked = value == selected - } - if (value == selected) selectedId = button.id - group.addView( - button, - RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - } - if (selectedId != View.NO_ID) group.check(selectedId) - group.setOnCheckedChangeListener { radioGroup, checkedId -> - val value = radioGroup.findViewById(checkedId)?.tag as? T ?: return@setOnCheckedChangeListener - onSelected(value) - } - addView( - group, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - } - - private fun updateResolutionMenu() { - resolutionValueView?.text = CarPlayDisplayScale.label(displayScaleTenths) - val native = activeDisplaySize ?: currentActivitySize() - val resolution = if (native == null) { - getString(UiR.string.host_handshake_resolution_waiting_for_display) - } else { - val negotiated = CarPlayDisplayScale.apply( - AirPlayDisplayConfig( - widthPixels = native.width, - heightPixels = native.height, - widthPhysicalMm = widthPhysicalMm, - fps = fps, - ), - displayScaleTenths, - ) - getString(UiR.string.host_negotiated_resolution, native.width, native.height, negotiated.widthPixels, negotiated.heightPixels) - } - val transport = if (!hevcEnabled) { - "H.264" - } else { - getString(UiR.string.host_hevc_decoder_type, if (hevcSoftwareDecoderEnabled) getString(UiR.string.ui_software) else getString(UiR.string.ui_hardware)) - } - resolutionPreviewView?.text = buildString { - append(resolution).append('\n') - append(getString(UiR.string.host_identity)).append(normalizedManufacturer()).append(" / ") - .append(normalizedModel()).append('\n') - append(getString(UiR.string.host_simulated_manufacturer_name_2)).append(simulatedManufacturerName.ifBlank { getString(UiR.string.host_empty) }).append('\n') - append(getString(UiR.string.host_frame_rate_2)).append(fps).append(" fps\n") - append(getString(UiR.string.host_detected_maximum)) - .append(maximumDetectedWidthPixels).append(" x ") - .append(maximumDetectedHeightPixels).append(" px\n") - append(getString(UiR.string.host_physical_reference)) - .append( - when (physicalSizeBasis) { - AirPlayPhysicalSizeBasis.WIDTH -> getString(UiR.string.ui_widest_width) - AirPlayPhysicalSizeBasis.HEIGHT -> getString(UiR.string.ui_longest_height) - }, - ) - .append(" = ").append(widthPhysicalMm).append(" mm\n") - native?.let { size -> - val physical = resolvePhysicalSize(size) - append(getString(UiR.string.host_carplay_physical_size)) - .append(physical.widthMm).append(" x ") - .append(physical.heightMm).append(" mm\n") - } - append(getString(UiR.string.host_driving_side_2)).append(if (rightHandDrive) getString(UiR.string.host_right) else getString(UiR.string.host_left)).append('\n') - append(getString(UiR.string.host_video_transport)).append(transport).append('\n') - append(getString(UiR.string.host_location_reporting)) - .append(if (locationReportingEnabled) getString(UiR.string.ui_enabled) else getString(UiR.string.ui_disabled_2)) - .append('\n') - if (advancedAudioChannelMappingSupported) { - append(getString(UiR.string.host_audio_channel_mapping)) - .append(if (advancedAudioChannelMapping) getString(UiR.string.host_aaos_buses) else getString(UiR.string.host_mobile_compatible)) - .append('\n') - } - append(safeAreaSummary()) - } - } - - private data class CanvasSupport(val supported: Boolean, val reason: String, val details: String) - - private fun largerCanvasSupport(display: AirPlayDisplayConfig): CanvasSupport = try { - val mime = if (hevcEnabled) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC - // Match MediaCodec.createDecoderByType's first suitable decoder; do not silently force - // an enlarged stream through a software decoder on a slower head unit. - val decoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.firstOrNull { - !it.isEncoder && it.supportedTypes.any { type -> type.equals(mime, ignoreCase = true) } - } - if (decoder == null) { - CanvasSupport(false, "no_decoder", "Decoder capability mime=$mime result=no_decoder") - } else { - val hardware = if (Build.VERSION.SDK_INT >= 29) decoder.isHardwareAccelerated - else !decoder.name.startsWith("OMX.google.") && !decoder.name.startsWith("c2.android.") - val video = decoder.getCapabilitiesForType(mime).videoCapabilities - val sizeSupported = video?.isSizeSupported(display.widthPixels, display.heightPixels) == true - val rateSupported = sizeSupported && video?.areSizeAndRateSupported( - display.widthPixels, display.heightPixels, display.fps.toDouble()) == true - val reason = when { - !hardware -> "software_decoder" - hevcEnabled && hevcSoftwareDecoderEnabled -> "software_hevc_selected" - video == null -> "no_video_capabilities" - !sizeSupported -> "canvas_dimensions_unsupported" - !rateSupported -> "frame_rate_unsupported" - else -> "supported" - } - CanvasSupport(reason == "supported", reason, - "Decoder capability codec=${decoder.name} mime=$mime hardware=$hardware sizeSupported=$sizeSupported rateSupported=$rateSupported " + - "widths=${video?.supportedWidths} heights=${video?.supportedHeights} alignment=${video?.widthAlignment}x${video?.heightAlignment} " + - "fpsRange=${video?.supportedFrameRates} result=$reason") - } - } catch (error: Exception) { - CanvasSupport(false, "capability_query_${error.javaClass.simpleName}", - "Decoder capability query failed error=${error.javaClass.simpleName}") - } - - private fun createAirPlayConfig(size: DisplaySize): AirPlayConfig { - val physical = resolvePhysicalSize(size) - val baseDisplay = AirPlayDisplayConfig( - widthPixels = size.width, - heightPixels = size.height, - widthPhysicalMm = physical.widthMm, - heightPhysicalMm = physical.heightMm, - fps = fps, - ) - val resolutionDisplay = CarPlayDisplayScale.apply(baseDisplay, displayScaleTenths) - val requestedPercent = uiScalePercent - var scaledDisplay = CarPlayUiScale.apply(resolutionDisplay, uiScalePercent) - val candidate = scaledDisplay - val support = when { - uiScalePercent >= CarPlayUiScale.DEFAULT -> CanvasSupport(true, "not_enlarging", "Decoder capability enlargement check not required") - scaledDisplay === resolutionDisplay -> CanvasSupport(false, "canvas_4k_limit", "Decoder capability check skipped: canvas exceeds enlargement limit") - else -> largerCanvasSupport(scaledDisplay) - } - if (!support.supported) { - scaledDisplay = resolutionDisplay - uiScalePercent = CarPlayUiScale.DEFAULT - AirPlayPersistence.saveUiScalePercent(this, uiScalePercent) - appendLog("Larger CarPlay canvas unavailable reason=${support.reason}; using Default icon and text size") - runOnUiThread { - android.widget.Toast.makeText(this, - getString(UiR.string.host_this_head_unit_cannot_use_the_smaller_size_at), - android.widget.Toast.LENGTH_LONG).show() - } - } - appendLog("CarPlay size=${CarPlayUiScale.label(uiScalePercent)} canvas=${scaledDisplay.widthPixels}x${scaledDisplay.heightPixels}") - val display = scaledDisplay.copy( - safeArea = AirPlaySafeArea.toInsets( - mapping = AirPlayPersistence.loadSafeAreaRect(this, size.width, size.height), - activityWidthPixels = size.width, - activityHeightPixels = size.height, - displayWidthPixels = scaledDisplay.widthPixels, - displayHeightPixels = scaledDisplay.heightPixels, - ), - safeAreaDrawOutside = if (AirPlayPersistence.loadSafeMargins(this)?.isFullScreen == false) false else safeAreaDrawOutside, - ) - val requestSummary = "Display request selected=${CarPlayUiScale.label(requestedPercent)} percent=$requestedPercent surface=${size.width}x${size.height} resolution=${displayScaleTenths * 10}% " + - "base=${resolutionDisplay.widthPixels}x${resolutionDisplay.heightPixels} candidate=${candidate.widthPixels}x${candidate.heightPixels} fps=$fps " + - "codec=${if (hevcEnabled) "HEVC" else "H.264"} softwareHevc=$hevcSoftwareDecoderEnabled" - val effectiveSummary = "Display effective percent=$uiScalePercent canvas=${display.widthPixels}x${display.heightPixels} decision=${support.reason} " + - "physical=${physical.widthMm}x${physical.heightMm}mm safeArea=${display.safeArea} drawOutside=${display.safeAreaDrawOutside}" - displayDiagnosticAttempt = DisplayDiagnosticSnapshot.begin(this, requestSummary, support.details, effectiveSummary) - appendLog(requestSummary) - appendLog(support.details) - appendLog(effectiveSummary) - return AirPlayConfig( - deviceName = "WheelPlay", - deviceId = DiPlayBootstrap.deviceId(airPlayIdentity), - btMac = DiPlayBluetooth.localAddress(this) ?: DiPlayBootstrap.deviceId(airPlayIdentity), - sourceVersion = "950.7.1", - main = display, - rightHandDrive = rightHandDrive, - hevc = hevcEnabled, - microphone = AirPlayPersistence.loadMicrophoneSource(this) != CarPlayMicrophoneSource.DISABLED, - microphoneOpus = com.shilapi.xcertplay.media.OpusEncoderSupport.isAvailable(), - manufacturer = normalizedManufacturer(), - model = normalizedModel(), - oemLabel = normalizedManufacturer(), - icons = listOf(loadAirPlayIcon()), - ) - } - - private fun loadAirPlayIcon(): AirPlayIcon { - val customBytes = try { - AirPlayPersistence.loadCustomAirPlayIconFile(this)?.readBytes() - } catch (_: Exception) { - null - } - if (customBytes != null) { - decodeAirPlayIcon(customBytes)?.let { return it } - AirPlayPersistence.clearCustomAirPlayIcon(this) - } - return decodeAirPlayIcon(defaultAirPlayIconBytes()) - ?: throw IllegalStateException("Packaged AirPlay icon is invalid") - } - - private fun decodeAirPlayIcon(encoded: ByteArray): AirPlayIcon? { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(encoded, 0, encoded.size, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0 || - bounds.outWidth != bounds.outHeight - ) { - return null - } - return AirPlayIcon(bounds.outWidth, bounds.outHeight, encoded) - } - - private fun defaultAirPlayIconBytes(): ByteArray = - // Shown in CarPlay's app list as the "back to the car" button. - resources.openRawResource(R.raw.ic_car_home).use { it.readBytes() } - - private fun updateAirPlayIconPreview() { - val preview = iconPreviewView ?: return - val custom = AirPlayPersistence.loadCustomAirPlayIconFile(this) - var customBitmap: Bitmap? = null - if (custom != null) { - customBitmap = BitmapFactory.decodeFile(custom.absolutePath) - if (customBitmap == null) { - AirPlayPersistence.clearCustomAirPlayIcon(this) - } - } - val bitmap = customBitmap ?: BitmapFactory.decodeResource(resources, R.raw.ic_car_home) - preview.setImageBitmap(bitmap) - iconStatusView?.text = - if (customBitmap != null) getString(UiR.string.host_custom_1_1_icon) else getString(UiR.string.host_default_nio_icon) - } - - private fun currentActivitySize(): DisplaySize? { - val view = videoView - if (view != null && view.width > 0 && view.height > 0) { - return DisplaySize(view.width, view.height) - } - return activeDisplaySize - } - - private fun resolvePhysicalSize(size: DisplaySize): AirPlayPhysicalSizeMm = - AirPlayDisplaySettings.resolvePhysicalSizeMm( - currentWidthPixels = size.width, - currentHeightPixels = size.height, - // The browser canvas fills its display at every pixel resolution. Treating it as - // a fraction of a previous viewport shrinks the reported screen and enlarges iOS UI. - maximumWidthPixels = size.width, - maximumHeightPixels = size.height, - referenceMillimeters = widthPhysicalMm, - basis = physicalSizeBasis, - ) - - private fun safeAreaSummary(): String { - val size = currentActivitySize() ?: return getString(UiR.string.host_safe_area_waiting_for_activity_size) - val mapping = AirPlayPersistence.loadSafeAreaRect(this, size.width, size.height) - return if (mapping == null) { - getString(UiR.string.host_safe_area_fullscreen, size.width, size.height) - } else { - getString(UiR.string.host_safe_area_coordinates, mapping.width, mapping.height, mapping.left, mapping.top, size.width, size.height) - } - } - - private fun updateSafeAreaSummary() { - safeAreaSummaryView?.text = safeAreaSummary() - } - - private fun openSafeAreaEditor() { - val size = currentActivitySize() - if (size == null) { - appendLog("Safe area editor is unavailable before display layout") - return - } - val editorView = safeAreaEditorView ?: return - val initial = AirPlayPersistence.loadSafeAreaRect(this, size.width, size.height) - ?: AirPlaySafeArea.default(size.width, size.height) - safeAreaEditSize = size - safeAreaEditorActive = true - // Keep the current activity size; changing system bars here would remap the safe area. - settingsMenu?.visibility = View.GONE - safeAreaEditor?.visibility = View.VISIBLE - editorView.setRect(initial, size.width, size.height) - appendLog( - "Safe area editor opened for ${size.width}x${size.height}; drag the four boundaries", - ) - } - - private fun closeSafeAreaEditor() { - if (!safeAreaEditorActive) return - safeAreaEditorActive = false - safeAreaEditSize = null - safeAreaEditor?.visibility = View.GONE - settingsMenu?.visibility = View.VISIBLE - updateSafeAreaSummary() - updateResolutionMenu() - appendLog("Safe area editor closed") - } - - private fun saveSafeAreaEditor() { - val size = safeAreaEditSize ?: currentActivitySize() ?: return - val rect = safeAreaEditorView?.currentRectForSource() ?: return - AirPlayPersistence.saveSafeAreaRect(this, size.width, size.height, rect) - appendLog( - "Safe area saved for ${size.width}x${size.height}: ${rect.width}x${rect.height} at (${rect.left}, ${rect.top})", - ) - closeSafeAreaEditor() - } - - private fun resetSafeAreaForCurrentSize() { - val size = currentActivitySize() - if (size == null) { - appendLog("Safe area reset is unavailable before display layout") - return - } - AirPlayPersistence.clearSafeAreaRect(this, size.width, size.height) - updateSafeAreaSummary() - updateResolutionMenu() - appendLog("Safe area reset to full screen for ${size.width}x${size.height}") - } - - private fun refreshDisplaySizeAfterLayout() { - videoView?.post { - val view = videoView ?: return@post - scheduleDisplaySize(view.width, view.height) - } - } - - private fun normalizedManufacturer(): String = - simulatedManufacturerName.trim().ifBlank { AirPlayPersistence.DEFAULT_SIMULATED_MANUFACTURER_NAME } - - private fun normalizedModel(): String = - model.trim().ifBlank { AirPlayPersistence.DEFAULT_MODEL } - - private fun createMediaSink( - videoWidth: Int, - videoHeight: Int, - controllerGeneration: Int, - ): AndroidMediaSink { - // Capture this session's log: late decoder shutdown must not write into a new session. - val diagnosticLog = sessionLog - val videoGeneration = WebSession.beginVideo(videoWidth, videoHeight) - val source = com.shilapi.xcertplay.web.WebVideoSource(streamTechnology) - WebSession.attachVideoSource(videoGeneration, source) - val output = OffscreenVideoOutput(videoWidth, videoHeight, - wanted = { WebSession.hasViewer && source.jpegNeeded }, - encoding = { AirPlayPersistence.imageEncoding(this, WebSession.imageFormat) }, - publish = { bytes, format, quality -> WebSession.publish(videoGeneration, bytes, format, quality) }) - return AndroidMediaSink( - surface = output.surface, - encodedVideo = source, - frameDecoderFactory = { key -> - try { - com.shilapi.xcertplay.web.NativeVideoDecoder(key, platform = true) - .also { source.setProcessorName(it.name) } - } catch (_: LinkageError) { null } - }, - remoteAudio = WebSession.audio.createRoute(), - onClosed = { output.closeAndAwait(); WebSession.endVideo(videoGeneration) }, - videoWidth = videoWidth, - videoHeight = videoHeight, - preferSoftwareHevcDecoder = hevcSoftwareDecoderEnabled, - advancedAudioChannelMapping = advancedAudioChannelMapping, - onScreenStreamActiveChanged = { type, active -> - onScreenStreamStateChanged(controllerGeneration, type, active) - }, - mediaBufferMillis = AirPlayPersistence.loadMediaBufferMillis(this), - onAudioDiagnostic = { message -> - diagnosticLog?.append(formattedLogLine(message, System.currentTimeMillis())) - }, - ) - } - - private fun createMediaEngine(sink: AndroidMediaSink): CarPlayMediaEngine = - CarPlayMediaEngine( - sink = sink, - microphoneEnabled = AirPlayPersistence.loadMicrophoneSource(this) != CarPlayMicrophoneSource.DISABLED, - audioCaptureDirectory = audioCaptureDirectory(), - ) - - private fun createSessionListener(controllerGeneration: Int): AirPlaySessionListener = - object : AirPlaySessionListener { - override fun onSessionActive(session: AirPlaySession) { - runOnUiThread { - if (controllerGeneration != restartGeneration) { - return@runOnUiThread - } - activeAirPlaySession = session - CarPlayBackgroundSession.active = true - reconnectAttempts = 0 - syncAirPlayDarkMode() - if (menuOpen) return@runOnUiThread - appendLog("AirPlay session active") - } - } - - override fun onSessionEnded(session: AirPlaySession) { - runOnUiThread { - if (controllerGeneration != restartGeneration) return@runOnUiThread - if (activeAirPlaySession === session) activeAirPlaySession = null - CarPlayBackgroundSession.active = false - WebSession.videoActive = false - if (menuOpen || controllerGeneration != restartGeneration) { - return@runOnUiThread - } - activeScreenStreamTypes.clear() - WebSession.videoActive = false - setLocalizedConnectionStage(UiText(UiR.string.host_carplay_session_ended_reconnecting)) - appendLog("AirPlay session ended; reconnecting from scratch") - reconnectAfterLoss("AirPlay session ended") - } - } - - override fun onTransportError(message: String) { - runOnUiThread { - if (menuOpen || controllerGeneration != restartGeneration) { - return@runOnUiThread - } - activeScreenStreamTypes.clear() - WebSession.videoActive = false - setLocalizedConnectionStage(UiText(UiR.string.host_transport_error_reconnecting)) - appendLog("CarPlay transport error: $message; reconnecting from scratch") - reconnectAfterLoss("CarPlay transport error: $message") - } - } - - override fun onDebugLog(message: String) { - if (DiagnosticRedactor.redact(message) == null) return - runOnUiThread { - if (controllerGeneration != restartGeneration) { - return@runOnUiThread - } - DisplayDiagnosticSnapshot.record(this@CarPlayHostActivity, displayDiagnosticAttempt, message) - if (menuOpen) return@runOnUiThread - if (message.startsWith(PROTOCOL_TRACE_PREFIX)) { - appendFileLog(message) - } else { - appendLog(message) - } - } - } - } - - private fun createStatusReporter( - controllerGeneration: Int, - ): (CarPlayStatus) -> Unit = { status -> - if (!menuOpen && controllerGeneration == restartGeneration) { - if (status is CarPlayStatus.Failed && !status.automaticRetry) automaticReconnectAllowed = false - updateHotspotStatus(status) - val description = status.describe() - setConnectionStage(description) - when (status) { - is CarPlayStatus.Failed -> if (status.wifiResetRequired) { - wifiRecoveryButton?.visibility = View.VISIBLE - } else { - wifiRecoveryButton?.visibility = View.GONE - if (status.automaticRetry) reconnectAfterLoss(description) - } - else -> Unit - } - } - } - - private fun adoptBackgroundSession(): Boolean { - val snapshot = CarPlayBackgroundSession.snapshot() ?: return false - displayDiagnosticAttempt = DisplayDiagnosticSnapshot.currentAttempt(this) - controller = snapshot.controller - sink = snapshot.sink - CarPlayBackgroundSession.store(snapshot.controller, snapshot.sink, snapshot.width, snapshot.height, this) { completion -> - runOnUiThread { - shutdown(false, "WheelPlay disconnect", completion) - finish() - } - } - if (snapshot.width > 0 && snapshot.height > 0) { - activeDisplaySize = DisplaySize(snapshot.width, snapshot.height) - } - if (snapshot.controller.isClosed()) { - restartCarPlay("Replacing closed background CarPlay controller") - return true - } - val generation = restartGeneration - snapshot.controller.attachUi( - createSessionListener(generation), - createStatusReporter(generation), - ) - snapshot.sink.setScreenStreamActiveChangedListener { type, active -> - onScreenStreamStateChanged(restartGeneration, type, active) - } - currentSurface?.let(::attachSurface) - val serviceReused = snapshot.controller.hasActiveAirPlayAttachment() - appendLog( - if (serviceReused) { - "Reusing existing background CarPlay service" - } else { - "Reusing existing background CarPlay session" - }, - ) - setConnectionStage( - if (serviceReused) { - getString(UiR.string.host_carplay_service_already_running) - } else { - getString(UiR.string.host_carplay_session_already_running) - }, - ) - updateDebugOverlays() - showDiPlayHome("service") - return true - } - - private fun startCarPlay(size: DisplaySize) { - if (CarPlayBackgroundSession.hasSession() && !CarPlayBackgroundSession.isOwner(this)) return - if (shuttingDown.get() || menuOpen || handshakeResetInProgress || controller != null) return - automaticReconnectAllowed = true - if (DiPlayBootstrap.importing) { - setLocalizedConnectionStage(UiText(UiR.string.ui_authentication_resources_are_being_imported_reconnect_when_finished)) - return - } - if (runCatching { DiPlayBootstrap.ensure(this) }.isFailure) { - setLocalizedConnectionStage(UiText(UiR.string.ui_authentication_resources_are_not_ready_import_files_or_a)) - return - } - if (!CarPlayBackgroundSession.reserve(this, { WebSession.stageDetail(this) }) { completion -> - runOnUiThread { shutdown(false, "WheelPlay disconnect", completion); finish() } - }) { - showDiPlayHome("home") - finish() - return - } - val controllerGeneration = restartGeneration - // The settings page can change branding while this activity remains in the back stack. - simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) - val config = createRuntimeConfig() - val airPlayConfig = createAirPlayConfig(size) - val locationProvider: Iap2LocationProvider = WebSession.location.createProvider( - if (locationReportingEnabled) { - AndroidCarPlayLocationProvider(this) - } else { - null - }, - ) - appendLog( - "Starting CarPlay controller at ${size.width}x${size.height} -> ${airPlayConfig.main.widthPixels}x${airPlayConfig.main.heightPixels} " + - "(${CarPlayDisplayScale.label(displayScaleTenths)}) physical=${airPlayConfig.main.widthPhysicalMm}x" + - "${airPlayConfig.main.heightPhysicalMm}mm video=${if (airPlayConfig.hevc) "HEVC" else "H.264"} decoder=${if (airPlayConfig.hevc && hevcSoftwareDecoderEnabled) "software" else "hardware"} microphone=${airPlayConfig.microphone} " + - "androidLocation=${if (locationReportingEnabled) "enabled" else "disabled"} browserLocation=opt-in mfi=${mfiTargetLabel(config.mfiTarget)}", - ) - Log.i( - TAG, - "starting controller display=${size.width}x${size.height} negotiated=${airPlayConfig.main.widthPixels}x${airPlayConfig.main.heightPixels} " + - "scale=${CarPlayDisplayScale.label(displayScaleTenths)} hevc=${airPlayConfig.hevc} " + - "softwareHevc=${airPlayConfig.hevc && hevcSoftwareDecoderEnabled} microphone=${airPlayConfig.microphone} " + - "androidLocation=$locationReportingEnabled browserLocation=opt-in mfi=${config.mfiTarget}", - ) - val renderer = try { - createMediaSink( - videoWidth = airPlayConfig.main.widthPixels, - videoHeight = airPlayConfig.main.heightPixels, - controllerGeneration = controllerGeneration, - ) - } catch (error: Exception) { - Log.e(TAG, "Offscreen video initialization failed", error) - if (error is com.shilapi.xcertplay.projection.ProjectionCleanupException) - CarPlayBackgroundSession.teardownFinished(false) - setLocalizedConnectionStage(UiText(UiR.string.host_this_device_cannot_create_an_offscreen_decoder_surface_check)) - return - } - sink = renderer - currentSurface?.let(::attachSurface) - val media = createMediaEngine(renderer) - val pairings = AirPlayPersistence.loadPairings(this) { id, key -> - AirPlayPersistence.savePairing(this, id, key) - } - val next = CarPlayController( - context = this, - config = config, - airPlayConfig = airPlayConfig, - identity = airPlayIdentity, - pairings = pairings, - listener = createSessionListener(controllerGeneration), - media = media, - reportStatus = createStatusReporter(controllerGeneration), - loadPairRecord = { AirPlayPersistence.loadLockdownRecord(this) }, - savePairRecord = { record -> AirPlayPersistence.saveLockdownRecord(this, record) }, - clearPairRecord = { AirPlayPersistence.clearLockdownRecord(this) }, - locationProvider = locationProvider, - ) - controller = next - CarPlayBackgroundSession.store(next, renderer, size.width, size.height, this) { completion -> - runOnUiThread { - shutdown(terminateProcess = false, reason = "WheelPlay disconnect", completion = completion) - finish() - } - } - try { - startForegroundService(Intent(this, DiPlaySessionService::class.java)) - next.start() - showDiPlayHome("service") - } catch (error: RuntimeException) { - appendLog("Connection could not start: ${error.javaClass.simpleName}") - shutdown(false, "foreground service could not start") - setLocalizedConnectionStage(UiText(UiR.string.host_could_not_start_carplay_return_to_wheelplay_and_check)) - } - } - - private fun syncAirPlayDarkMode() { - if (activeAirPlaySession != null) WebSession.refreshAppearance(this) - } - - private fun audioCaptureDirectory(): File? { - if (!File(filesDir, AUDIO_CAPTURE_MARKER).isFile) return null - return File(filesDir, AUDIO_CAPTURE_DIRECTORY) - } - - private fun scheduleDisplaySize(width: Int, height: Int) { - if (width <= 0 || height <= 0 || shuttingDown.get()) return - val browserSize = WebSession.currentBrowserViewport.takeIf { WebSession.adaptiveBrowserSize } - val size = browserSize?.let { DisplaySize(it.first, it.second) } ?: DisplaySize(1280, 720) - if (size == activeDisplaySize || size == pendingDisplaySize) return - pendingDisplaySize = size - mainHandler.removeCallbacks(applyDisplaySize) - mainHandler.postDelayed(applyDisplaySize, DISPLAY_CHANGE_DEBOUNCE_MILLIS) - } - - private fun scheduleBrowserDisplaySize(width: Int?, height: Int?) { - if (shuttingDown.get()) return - val size = if (width == null || height == null) { - DisplaySize(1280, 720) - } else { - val scale = minOf(1.0, 3840.0 / width, 2160.0 / height) - fun even(value: Double) = (value.toInt().coerceAtLeast(2) / 2) * 2 - DisplaySize(even(width * scale), even(height * scale)) - } - if (size == activeDisplaySize || size == pendingDisplaySize) return - activeDisplaySize?.let { active -> - if (kotlin.math.abs(size.width - active.width) <= 8 && - kotlin.math.abs(size.height - active.height) <= 8 && - kotlin.math.abs(size.width.toDouble() / size.height - active.width.toDouble() / active.height) < 0.01 - ) return - } - appendLog("Adaptive browser display requested: ${size.width}x${size.height}") - pendingDisplaySize = size - mainHandler.removeCallbacks(applyDisplaySize) - mainHandler.postDelayed(applyDisplaySize, BROWSER_DISPLAY_CHANGE_DEBOUNCE_MILLIS) - } - - private fun applyDisplaySize(size: DisplaySize) { - if (shuttingDown.get() || size == activeDisplaySize) return - val previous = activeDisplaySize - activeDisplaySize = size - recordDetectedMaximum(size) - updateResolutionMenu() - if (previous == null) { - appendLog("Display detected: ${size.width}x${size.height}") - maybeStartCarPlay() - } else if (controller == null) { - maybeStartCarPlay() - } else if (menuOpen || handshakeResetInProgress) { - appendLog( - "Display updated while handshake is reset: ${previous.width}x${previous.height} -> ${size.width}x${size.height}", - ) - } else { - restartCarPlay( - "Display changed ${previous.width}x${previous.height} -> ${size.width}x${size.height}", - ) - } - } - - private fun recordDetectedMaximum(size: DisplaySize) { - val width = maxOf(maximumDetectedWidthPixels, size.width) - val height = maxOf(maximumDetectedHeightPixels, size.height) - if (width == maximumDetectedWidthPixels && height == maximumDetectedHeightPixels) return - maximumDetectedWidthPixels = width - maximumDetectedHeightPixels = height - AirPlayPersistence.saveMaximumDetectedDisplay(this, width, height) - } - - private fun maybeStartCarPlay() { - if (shuttingDown.get()) return - if (CarPlayBackgroundSession.hasSession() && !CarPlayBackgroundSession.isOwner(this)) { - if (!adoptBackgroundSession()) mainHandler.postDelayed({ maybeStartCarPlay() }, 500) - return - } - if (controller == null && adoptBackgroundSession()) return - val size = activeDisplaySize ?: return - val transportReady = if (wirelessEnabled) wirelessPermissionsReady else vpnReady - val locationReady = !locationReportingEnabled || locationPermissionAvailable - if ( - !transportReady || - !locationReady || - !microphonePermissionResolved || - shuttingDown.get() || - menuOpen || - handshakeResetInProgress || - controller != null - ) { - return - } - if (requestManualHotspotChannelPermissionIfNeeded()) return - startCarPlay(size) - } - - private fun reconnectAfterLoss(reason: String) { - if (!automaticReconnectAllowed) return - if (!CarPlayBackgroundSession.isOwner(this)) return - if (shuttingDown.get() || menuOpen || handshakeResetInProgress) return - if (reconnectScheduled) return - reconnectScheduled = true - val generation = restartGeneration - val delayMillis = if (reason.contains("AirPlay iAP tunnel", ignoreCase = true)) { - IAP_TUNNEL_RECONNECT_DELAY_MILLIS - } else { - (RECONNECT_DELAY_MILLIS * (1L shl reconnectAttempts.coerceAtMost(4))).coerceAtMost(30_000L) - } - reconnectAttempts += 1 - appendLog("$reason; retrying in ${delayMillis}ms") - mainHandler.postDelayed( - { - reconnectScheduled = false - if ( - !automaticReconnectAllowed || shuttingDown.get() || - menuOpen || - handshakeResetInProgress || - generation != restartGeneration - ) { - return@postDelayed - } - restartCarPlay("Reconnecting after $reason") - }, - delayMillis, - ) - } - - /** Full-stack fallback when an AirPlay-only reconnect is unavailable. */ - private fun restartCarPlay(reason: String) { - if (!CarPlayBackgroundSession.isOwner(this)) return - if (shuttingDown.get() || menuOpen || handshakeResetInProgress) return - val size = activeDisplaySize ?: return - appendLog(reason) - activeScreenStreamTypes.clear() - setConnectionStage(reason) - Log.i(TAG, "$reason; rebuilding stack at ${size.width}x${size.height}") - val generation = ++restartGeneration - handshakeResetInProgress = true - val oldController = controller - val oldSink = sink - CarPlayBackgroundSession.clear(oldController, keepOwner = true) - controller = null - sink = null - teardownExecutor.execute { - val clean = runCatching { - oldController?.close() - val transportStopped = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true - if (transportStopped) oldSink?.close(awaitWorkers = true) - transportStopped && CarPlayBackgroundSession.teardownClean - }.getOrDefault(false) - if (!clean) { - CarPlayBackgroundSession.teardownFinished(false) - return@execute - } - runOnUiThread { - if (!shuttingDown.get() && generation == restartGeneration) { - handshakeResetInProgress = false - startCarPlay(size) - } - } - } - } - - private fun showDiPlayHome(page: String = "home") { - controller?.sendTouch(emptyList()) - startActivity(Intent(this, DiPlayActivity::class.java) - .putExtra("page", page).addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_NO_ANIMATION)) - overridePendingTransition(0, 0) - } - - private fun openSettingsMenu() = showDiPlayHome("settings") - - private fun saveSettingsAndReconnect() { - if (!menuOpen) return - if (!validateMfiSettings()) return - if (!validateManualHotspotSettings()) return - persistMenuSettings() - settingsBaseline = null - finishSettingsMenu("Settings saved") - } - - private fun cancelSettingsEdits() { - if (!menuOpen) return - restoreSettingsBaseline() - finishSettingsMenu("Settings changes discarded") - } - - private fun finishSettingsMenu(prefix: String) { - if (!menuOpen) return - menuOpen = false - settingsMenu?.visibility = View.GONE - gestureOverlay?.visibility = View.VISIBLE - updateDebugOverlays() - logLines.clear() - appendLog( - "$prefix; starting a fresh handshake at ${CarPlayDisplayScale.label(displayScaleTenths)} with " + - (if (hevcEnabled) getString(UiR.string.host_hevc_h_265) else "H.264") + - ", MFI ${mfiTargetLabel(mfiTarget)}, Wi-Fi session ${hotspotModeLabel(wirelessHotspotMode)}", - ) - if (handshakeResetInProgress) { - startAfterHandshakeReset = true - } else { - maybeStartCarPlay() - } - } - - private fun exitApplication() { - if (shuttingDown.get()) return - restoreSettingsBaseline() - finishAndRemoveTask() - shutdown(terminateProcess = true, reason = "settings exit application") - } - - private fun shutdown(terminateProcess: Boolean, reason: String, completion: () -> Unit = {}) { - if (!shuttingDown.compareAndSet(false, true)) { completion(); return } - restartGeneration += 1 - mainHandler.removeCallbacks(applyDisplaySize) - val oldController = controller - val oldSink = sink - CarPlayBackgroundSession.clear(oldController) - controller = null - sink = null - Log.i(TAG, "shutdown reason=$reason terminateProcess=$terminateProcess") - teardownExecutor.execute { - val clean = runCatching { - oldController?.close() - val transportStopped = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true - if (transportStopped) oldSink?.close(awaitWorkers = true) - transportStopped && CarPlayBackgroundSession.teardownClean - }.getOrDefault(false) - if (terminateProcess) { - applicationContext.stopService(Intent(applicationContext, CarPlayVpnService::class.java)) - } - Log.i(TAG, "shutdown complete clean=$clean") - if (terminateProcess) applicationContext.stopService(Intent(applicationContext, DiPlaySessionService::class.java)) - teardownExecutor.shutdown() - mainHandler.post { - completion() - CarPlayBackgroundSession.teardownFinished(clean) - } - if (terminateProcess) Process.killProcess(Process.myPid()) - } - } - - private fun attachSurface(surface: Surface) { - // Video belongs to the offscreen server output, never an Activity window. - } - - private fun onHostTouch(view: View, event: MotionEvent): Boolean { - if (menuOpen) return true - - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - gestureSequenceActive = false - gestureTracking = false - } - MotionEvent.ACTION_POINTER_DOWN -> { - if (event.pointerCount == THREE_FINGER_COUNT && !gestureSequenceActive) { - gestureSequenceActive = true - gestureTracking = true - gestureStartX = pointerCentroid(event, horizontal = true) - gestureStartY = pointerCentroid(event, horizontal = false) - controller?.sendTouch(emptyList()) - appendLog("Three-finger swipe tracking started") - return true - } - } - } - - if (gestureSequenceActive) { - if (!gestureTracking || event.pointerCount != THREE_FINGER_COUNT) { - if (event.actionMasked == MotionEvent.ACTION_UP || - event.actionMasked == MotionEvent.ACTION_CANCEL - ) { - gestureSequenceActive = false - gestureTracking = false - } else if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) { - gestureTracking = false - } - return true - } - if (event.actionMasked == MotionEvent.ACTION_MOVE) { - val deltaX = Math.abs(pointerCentroid(event, horizontal = true) - gestureStartX) - val deltaY = pointerCentroid(event, horizontal = false) - gestureStartY - if ( - deltaY >= dp(THREE_FINGER_SWIPE_DISTANCE_DP) && - deltaY >= deltaX * THREE_FINGER_SWIPE_DIRECTION_RATIO - ) { - gestureSequenceActive = false - gestureTracking = false - openSettingsMenu() - return true - } - } - return true - } - - val contacts = CarPlayTouchMapper.contacts(event, view.width, view.height) - val queued = controller?.sendTouch(contacts) ?: false - when (event.actionMasked) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_POINTER_DOWN, - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP, - MotionEvent.ACTION_CANCEL -> Log.i( - TAG, - "touch action=${MotionEvent.actionToString(event.actionMasked)} pointers=${event.pointerCount} queued=$queued", - ) - } - return true - } - - private fun pointerCentroid(event: MotionEvent, horizontal: Boolean): Float { - var total = 0f - for (index in 0 until event.pointerCount) { - total += if (horizontal) event.getX(index) else event.getY(index) - } - return total / event.pointerCount - } - - private fun onScreenStreamStateChanged(generation: Int, type: Int, active: Boolean) { - runOnUiThread { - if (shuttingDown.get() || generation != restartGeneration) return@runOnUiThread - if (active) { - activeScreenStreamTypes.add(type) - } else { - activeScreenStreamTypes.remove(type) - } - WebSession.videoActive = activeScreenStreamTypes.isNotEmpty() - updateDebugOverlays() - } - } - - private fun setStatus(message: String) { - runOnUiThread { - setConnectionStage(message) - appendLog(message) - } - } - - private fun setConnectionStage(message: String) { - latestStage = message - WebSession.setStage(friendlyStage(message)) - stageStatusView?.text = WebSession.stage(this) - updateDebugOverlays() - } - - private fun setLocalizedConnectionStage(message: UiText) { - latestStage = message.resolve(this) - WebSession.setStage(message) - stageStatusView?.text = message.resolve(this) - updateDebugOverlays() - } - - private fun updateDebugOverlays() { - statusScrollView?.visibility = View.GONE - connectionPanel?.visibility = if (activeScreenStreamTypes.isEmpty()) View.VISIBLE else View.GONE - } - - private fun friendlyStage(message: String): UiText = when { - message.contains("Turn on Wi-Fi", true) -> UiText(UiR.string.host_turn_on_wi_fi_in_this_device_s_system) - message.contains("Allow precise Location", true) -> UiText(UiR.string.host_allow_precise_location_in_the_app_permissions) - message.contains("Allow Nearby devices", true) -> UiText(UiR.string.host_allow_access_to_nearby_devices_in_the_app_permissions) - message.contains("createGroup failed", true) -> UiText(UiR.string.host_cannot_start_carplay_wi_fi_check_wi_fi_and) - message.contains("needs a reset", true) -> UiText(UiR.string.host_the_previous_wi_fi_direct_connection_is_still_running) - message.contains("socket", true) || message.contains("RFCOMM", true) -> UiText(UiR.string.host_cannot_connect_to_the_iphone_yet_unlock_it_and) - message.contains("unsupported", true) || message.contains("not supported", true) -> UiText(UiR.string.host_this_device_may_not_support_wireless_carplay_try_a) - message.contains("denied", true) || message.contains("permission", true) -> UiText(UiR.string.host_allow_the_connection_permissions_to_continue) - message.contains("Failed", true) || message.contains("error", true) -> UiText(UiR.string.host_connection_interrupted_retrying) - message.contains("Waiting for iPhone", true) || message.contains("Discovering iPhone", true) -> UiText(UiR.string.host_connect_the_iphone_using_a_usb_data_cable) - message.contains("paired", true) -> UiText(UiR.string.host_looking_for_a_paired_iphone) - message.contains("Bluetooth", true) -> UiText(UiR.string.ui_connecting_to_iphone) - message.contains("reconnect", true) || message.contains("ended", true) -> UiText(UiR.string.host_reconnecting_to_iphone) - message.contains("active", true) || message.contains("running", true) -> UiText(UiR.string.host_carplay_session_started_waiting_for_video) - else -> UiText(UiR.string.host_preparing_carplay) - } - - private fun appendLog(message: String) { - val safe = DiagnosticRedactor.redact(message) ?: return - sessionLog?.append(formattedLogLine(safe, System.currentTimeMillis())) - } - - private fun appendFileLog(message: String) { - sessionLog?.append(formattedLogLine(message, System.currentTimeMillis())) - } - - private fun formattedLogLine(message: String, nowMillis: Long): String = - "${SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date(nowMillis))} $message" - - private fun initializeSessionLog() { - val logFile = File(File(filesDir, "logs"), "diplay.log") - val activeLog = SessionLogFile(logFile) - runCatching { - activeLog.reset( - "WheelPlay log started ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())} pid=${Process.myPid()} path=${logFile.absolutePath}", - ) - } - sessionLog = activeLog - } - - private fun refreshLogView(nowMillis: Long) { - val cutoff = nowMillis - LOG_RETENTION_MILLIS - while (logLines.firstOrNull()?.timestampMillis?.let { it <= cutoff } == true) { - logLines.removeFirst() - } - statusView?.text = logLines.joinToString("\n") { it.text } - scrollLogsToBottom() - - mainHandler.removeCallbacks(expireOldLogLines) - logLines.firstOrNull()?.let { oldest -> - val delay = (oldest.timestampMillis + LOG_RETENTION_MILLIS - nowMillis + 1L) - .coerceAtLeast(1L) - mainHandler.postDelayed(expireOldLogLines, delay) - } - } - - private fun scrollLogsToBottom() { - statusScrollView?.post { - statusScrollView?.fullScroll(View.FOCUS_DOWN) - } - } - - private fun showSystemBars() = ServerWindow.showSystemBars(window) - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - private fun CarPlayStatus.describe(): String = when (this) { - CarPlayStatus.DiscoveringMfi -> "Preparing MFi authentication" - CarPlayStatus.WaitingForMfi -> "Waiting for MFi coprocessor" - CarPlayStatus.RequestingMfiPermission -> "Requesting MFi USB permission" - CarPlayStatus.MfiReady -> "MFi authentication ready" - CarPlayStatus.StartingHotspot -> "Starting wireless hotspot" - is CarPlayStatus.HotspotReady -> - "Hotspot ready: $backend, $ssid, $band, channel ${if (channel == 0) "auto" else channel}" - CarPlayStatus.WaitingForPairedIphone -> "Waiting for paired iPhone" - CarPlayStatus.ConnectingBluetooth -> "Connecting Bluetooth" - CarPlayStatus.RunningWireless -> "Wireless CarPlay control running" - CarPlayStatus.WirelessActiveFallback -> "Wireless CarPlay active with Bluetooth control" - CarPlayStatus.WirelessActive -> "Wireless CarPlay active" - CarPlayStatus.DiscoveringIphone -> "Discovering iPhone" - CarPlayStatus.WaitingForIphone -> "Waiting for iPhone over USB" - CarPlayStatus.RequestingIphonePermission -> "Requesting iPhone USB permission" - CarPlayStatus.WaitingForReenumeration -> "Waiting for iPhone re-enumeration" - CarPlayStatus.SelectingConfiguration -> "Selecting CarPlay configuration" - CarPlayStatus.OpeningDataPaths -> "Opening USB data paths" - CarPlayStatus.Pairing -> "Pairing with iPhone" - CarPlayStatus.ConnectingControl -> "Connecting iAP2 control" - CarPlayStatus.AttachingNetwork -> - if (wirelessEnabled) "Starting AirPlay service" else "Attaching NCM/AirPlay network" - CarPlayStatus.RunningControl -> "CarPlay control running" - CarPlayStatus.ControlEnded -> "CarPlay control window ended" - is CarPlayStatus.Failed -> "Failed: ${message}" - } - - private companion object { - const val TAG = "xcertplay-usb" - const val SCREEN_TYPE_MAIN = 110 - const val SCREEN_TYPE_ALT = 111 - const val LOG_RETENTION_MILLIS = 5 * 60_000L - const val DISPLAY_CHANGE_DEBOUNCE_MILLIS = 500L - const val BROWSER_DISPLAY_CHANGE_DEBOUNCE_MILLIS = 900L - const val RECONNECT_DELAY_MILLIS = 2_000L - const val IAP_TUNNEL_RECONNECT_DELAY_MILLIS = 15_000L - const val CONTROLLER_CLOSE_TIMEOUT_MILLIS = 4_000L - const val AUDIO_CAPTURE_MARKER = "audio-capture.enabled" - const val AUDIO_CAPTURE_DIRECTORY = "audio-captures" - const val PROTOCOL_TRACE_PREFIX = "TRACE " - const val THREE_FINGER_COUNT = 3 - const val THREE_FINGER_SWIPE_DISTANCE_DP = 72 - const val THREE_FINGER_SWIPE_DIRECTION_RATIO = 1.15f - const val MAX_SETTINGS_MENU_WIDTH_PX = 1200 - val MENU_BACKGROUND = Color.rgb(12, 16, 19) - val MENU_SECONDARY = Color.rgb(170, 180, 190) - val MENU_ACCENT = Color.rgb(127, 205, 154) - val MENU_ACCENT_TRACK = Color.rgb(78, 143, 102) - val MENU_TRACK_OFF = Color.rgb(64, 74, 80) - val MENU_BUTTON_TEXT = Color.rgb(8, 17, 11) - val MENU_DANGER = Color.rgb(190, 45, 45) - val NO_VIDEO_BACKGROUND = Color.rgb(0x16, 0x16, 0x18) - } - - private data class DisplaySize(val width: Int, val height: Int) - private data class LogEntry(val timestampMillis: Long, val text: String) - private data class HotspotStatus( - val state: UiText, - val ssid: String? = null, - val band: String? = null, - val channel: Int? = null, - val backend: String? = null, - ) -} - -/** Process-local hand-off for keeping the CarPlay session alive while no Activity is visible. */ -internal object CarPlayBackgroundSession { - @Volatile var active = false - private var stopAction: (((() -> Unit)) -> Unit)? = null - private var stopping = false - private var owner: Any? = null - @Synchronized fun isOwner(candidate: Any): Boolean = owner === candidate - @Synchronized fun hasSession(): Boolean = stopAction != null || stopping - private val stopWaiters = mutableListOf<() -> Unit>() - - fun stop(completion: () -> Unit = {}) { - val action: (((() -> Unit)) -> Unit)? - synchronized(this) { - if (stopping) { stopWaiters.add(completion); return } - action = stopAction - if (action != null) { stopping = true; stopWaiters.add(completion) } - } - if (action == null) { completion(); return } - action.invoke { - val callbacks = synchronized(this) { - stopping = false - stopWaiters.toList().also { stopWaiters.clear() } - } - callbacks.forEach { it() } - } - } - - data class Snapshot( - val controller: CarPlayController, - val sink: AndroidMediaSink, - val width: Int, - val height: Int, - ) - - @Volatile var teardownClean = true - private set - - @Synchronized fun reserve(owner: Any, status: () -> String?, stop: (() -> Unit) -> Unit): Boolean { - if (projection != null) return this.owner === owner - val adapter = CarPlaySourceSession(status) - if (!projectionSessions.adopt(adapter, - ProjectionResource.entries.toSet())) return false - projection = adapter - this.owner = owner - stopAction = stop - teardownClean = true - return true - } - - fun teardownFinished(clean: Boolean) { - val adapter = synchronized(this) { - teardownClean = clean - projection.also { if (clean) projection = null } - } - adapter?.completed(clean) - if (clean) adapter?.let { projectionSessions.ended(it) } - } - - private var projection: CarPlaySourceSession? = null - private var controller: CarPlayController? = null - private var sink: AndroidMediaSink? = null - private var width = 0 - private var height = 0 - - @Synchronized - fun snapshot(): Snapshot? { - val currentController = controller ?: return null - val currentSink = sink ?: return null - return Snapshot(currentController, currentSink, width, height) - } - - private val listeners = java.util.concurrent.CopyOnWriteArrayList<(Snapshot?) -> Unit>() - fun subscribe(listener: (Snapshot?) -> Unit): AutoCloseable { - listeners.add(listener) - listener(snapshot()) - return AutoCloseable { listeners.remove(listener) } - } - - fun store(controller: CarPlayController, sink: AndroidMediaSink, width: Int, height: Int, owner: Any, stop: (() -> Unit) -> Unit) { - synchronized(this) { - this.stopAction = stop - this.owner = owner - this.controller = controller - this.sink = sink - projection?.bind(controller, sink) - this.width = width - this.height = height - } - projection?.let { projectionSessions.changed(it) } - listeners.forEach { it(snapshot()) } - } - - fun clear(expected: CarPlayController? = null, keepOwner: Boolean = false) { - synchronized(this) { - if (expected != null && controller !== expected) return - controller = null - sink = null - projection?.bind(null, null) - if (!keepOwner) { stopAction = null; owner = null } - active = false - width = 0 - height = 0 - } - listeners.forEach { it(snapshot()) } - } -} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt deleted file mode 100644 index aecf106..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ /dev/null @@ -1,1473 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// UI copy and visual language adapted from DiAuto. See docs/THIRD_PARTY_NOTICES.md. -package com.shilapi.xcertplay - -import com.shilapi.xcertplay.projection.message -import com.shilapi.xcertplay.projection.projectionFailure -import com.shilapi.xcertplay.projection.ProjectionSourceIds -import com.shilapi.xcertplay.projection.projectionSessions -import com.shilapi.xcertplay.projection.plugin.projectionPlugins -import com.shilapi.xcertplay.projection.plugin.InstalledProjectionPlugin -import com.shilapi.xcertplay.projection.plugin.RemotePluginSession -import com.shilapi.xcertplay.projection.ProjectionSession -import moe.feng.wheelplay.plugin.ProjectionPlugin -import com.shilapi.xcertplay.projection.registerProjectionSources -import com.shilapi.xcertplay.projection.androidauto.LocalAndroidAuto -import moe.feng.wheelplay.projection.desktop.* -import androidx.core.view.isVisible -import com.shilapi.xcertplay.projection.SourceAvailability -import rikka.shizuku.Shizuku -import com.shilapi.xcertplay.projection.vnc.VncSource -import com.shilapi.xcertplay.projection.vnc.VncEndpoint - -import com.shilapi.xcertplay.shared.R as UiR -import com.shilapi.xcertplay.i18n.* - -import android.Manifest -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.shilapi.xcertplay.web.WebSession -import com.shilapi.xcertplay.web.RootAccess -import com.shilapi.xcertplay.web.LanAddresses -import com.shilapi.xcertplay.web.TeslaHttpCompatibility -import com.shilapi.xcertplay.web.LanTls -import com.shilapi.xcertplay.web.BrowserExperienceLink -import com.shilapi.xcertplay.network.CarPlayVpnService -import com.shilapi.xcertplay.network.TeslaHttpMode -import io.github.g00fy2.quickie.QRResult -import io.github.g00fy2.quickie.ScanQRCode -import android.bluetooth.BluetoothManager -import android.content.Intent -import android.content.pm.PackageManager -import android.content.res.Configuration -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.Settings -import android.view.View -import android.view.ViewGroup -import android.widget.* -import androidx.compose.ui.platform.ComposeView -import com.shilapi.xcertplay.settings.* -import androidx.lifecycle.ViewModelProvider -import androidx.appcompat.app.AppCompatActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts -import com.shilapi.xcertplay.host.R -import com.shilapi.xcertplay.orchestration.WirelessHotspotMode -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** Connection and preference pages in the shared server navigation. */ -class DiPlayActivity : AppCompatActivity() { - private class CertificateDocument : ActivityResultContracts.OpenDocument() { - override fun createIntent(context: android.content.Context, input: Array): Intent = - super.createIntent(context, input).addCategory(Intent.CATEGORY_OPENABLE) - } - private val ui by lazy { ServerUi(this) } - private val handler = Handler(Looper.getMainLooper()) - private var page = "service" - private val sourceDetailPages = SourcePage.entries.map { it.id }.toSet() - private val pluginFailureLabels = mutableMapOf>() - private val sourceStatuses = mutableMapOf() - private val sourceStopButtons = mutableMapOf() - private val settingsModel by lazy { ViewModelProvider(this)[SettingsViewModel::class.java] } - private var settingsView: ComposeView? = null - private val settingsDialogs = SettingsDialogHost() - private lateinit var shell: ServerUi.Shell - private var webAddress: TextView? = null - private var webInterface: TextView? = null - private var webBusy = false - private var awaitingTeslaVpnConsent = false - private var vncDialog: android.app.AlertDialog? = null - private var desktopDialog: android.app.AlertDialog? = null - private var localAndroidAutoDialog: android.app.AlertDialog? = null - private val localAndroidAutoSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (!isFinishing && !isDestroyed) showLocalAndroidAutoSetup() - } - private val teslaVpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val requested = awaitingTeslaVpnConsent - awaitingTeslaVpnConsent = false - if (requested && TeslaHttpCompatibility.config(this).mode == TeslaHttpMode.VPN) { - applyTeslaHttpEnabled(result.resultCode == RESULT_OK) - if (result.resultCode != RESULT_OK) toast(getString(UiR.string.ui_vpn_permission_canceled_rootless_mode_remains_disabled)) - } - } - private var pendingPemCertificate: ByteArray? = null - private val pemCertificatePicker = registerForActivityResult(CertificateDocument()) { uri -> - if (uri != null) { - webBusy = true - Thread({ - val result = runCatching { readCertificateFile(uri) } - runOnUiThread { - webBusy = false - if (isFinishing || isDestroyed) { result.getOrNull()?.fill(0); return@runOnUiThread } - result.onSuccess { pendingPemCertificate = it; pemKeyPicker.launch(arrayOf("*/*")) } - .onFailure { toast(getString(UiR.string.error_certificate_read, it.userMessage(this))) } - } - }, "https-certificate-read").start() - } - } - private val pemKeyPicker = registerForActivityResult(CertificateDocument()) { uri -> - val certificate = pendingPemCertificate - pendingPemCertificate = null - if (uri == null || certificate == null) certificate?.fill(0) - else textInput(getString(UiR.string.ui_pem_private_key_password_leave_blank_if_unencrypted), "", true, onCancel = { certificate.fill(0) }) { value -> - val password = value.toCharArray() - configureWeb(getString(UiR.string.ui_https_certificate_imported_reconnect_your_browser)) { - try { - val key = readCertificateFile(uri) - try { LanTls.preparePem(certificate, key, password).use { WebSession.installCertificate(this, it) } } - finally { key.fill(0) } - } finally { certificate.fill(0); password.fill('\u0000') } - } - } - } - private val pkcs12Picker = registerForActivityResult(CertificateDocument()) { uri -> - if (uri != null) textInput(getString(UiR.string.ui_pkcs_12_file_password_optional), "", true) { value -> - choosePkcs12Identity(uri, value.toCharArray()) - } - } - private var otherAddresses: LinearLayout? = null - private var otherAddressesToggle: ServerUi.DisclosureRow? = null - private var addressesExpanded = false - private var displayedAddresses: List? = null - private var webCode: TextView? = null - private var webPairingControls: LinearLayout? = null - private var quickBrowserButton: Button? = null - private var webViewer: TextView? = null - private var webStage: TextView? = null - private var sessionConnectButton: Button? = null - private var homeSourceButton: Button? = null - private var homeStopButton: Button? = null - private var homeAudioHint: TextView? = null - private enum class SourcePage(val id: String) { - CARPLAY("source-carplay"), - ANDROID_AUTO("source-android-auto"), - ICCOA("source-iccoa"), - VNC("source-vnc"), - DESKTOP("source-desktop"), - DIAGNOSTICS("source-diagnostics"), - PLUGINS("source-plugins"), - } - private data class HomeSource(val id: String, val name: String, val page: SourcePage) - private var setupError: String? = null - private var status: TextView? = null - private var connectButton: com.google.android.material.button.MaterialButton? = null - private var disconnectButton: Button? = null - private var lastRunning: Boolean? = null - private var pendingWireless = false - private var initialLaunch = true - private var notificationTransport = true - private var exportInProgress = false - private var pendingAndroidAutoSourceId: String? = null - private val androidAutoMicrophonePermission = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - val sourceId = pendingAndroidAutoSourceId ?: return@registerForActivityResult - pendingAndroidAutoSourceId = null - if (!granted) toast(getString(R.string.aa_microphone_permission_denied)) - startProjectionSourceNow(sourceId) - } - private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { - connect(notificationTransport) - } - private val tick = object : Runnable { - override fun run() { refreshStatus(); refreshService(); refreshSettingsRuntime(); handler.postDelayed(this, 1000) } - } - private val bluetoothPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) choosePhone() else permissionHelp(getString(UiR.string.ui_nearby_devices), getString(UiR.string.ui_allow_access_to_nearby_devices_to_connect_to_a)) - } - private val export = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> - if (uri != null) exportDiagnostics(uri) - } - - private val oemImagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> - if (uri != null) runCatching { - oemImageCrop.launch(Intent(this, ImageCropActivity::class.java) - .setData(uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) - }.onFailure { toast(getString(UiR.string.ui_could_not_open_the_icon_editor_try_again)) } - } - private val oemImageCrop = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - render() - toast(getString(UiR.string.ui_vehicle_icon_saved_applies_on_the_next_iphone_connection)) - } - } - - private val authenticationImport by lazy { - ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory(application))[AuthenticationImportModel::class.java] - } - private var selectedPrivateKey: Uri? = null - private val selectAuthenticationKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) { - selectedPrivateKey = uri - launchAuthenticationPicker { selectAuthenticationCertificate.launch(arrayOf("*/*")) } - } - } - private val selectAuthenticationCertificate = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - val key = selectedPrivateKey - selectedPrivateKey = null - if (uri != null && key != null) authenticationImport.import(key = key, certificate = uri) - } - private val selectAuthenticationApk = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) authenticationImport.import(apk = uri) - } - - private val scan = registerForActivityResult(ScanQRCode()) { result -> - val payload = when (result) { - is QRResult.QRSuccess -> result.content.rawValue?.take(257) - QRResult.QRUserCanceled -> null - QRResult.QRMissingPermission -> { - permissionHelp(getString(UiR.string.ui_camera), getString(UiR.string.ui_qr_pairing_requires_camera_permission_you_can_also_enter)) - null - } - is QRResult.QRError -> { - toast(getString(UiR.string.ui_scan_failed_check_camera_permission_or_close_other_apps)) - null - } - } ?: return@registerForActivityResult - val request = WebSession.inspectPairing(payload) - if (request == null) { - toast(getString(UiR.string.ui_invalid_or_expired_qr_code_refresh_this_server_s)) - } else { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_allow_this_head_unit_to_connect)) - .setMessage(getString(UiR.string.pairing_permission_message, request.peer)) - .setNegativeButton(getString(UiR.string.ui_cancel), null) - .setPositiveButton(getString(UiR.string.ui_allow_connection)) { _, _ -> - toast(if (WebSession.approvePairing(payload)) getString(UiR.string.ui_authorized_the_head_unit_web_page_will_connect_automatically) else getString(UiR.string.ui_request_expired_refresh_the_web_page_and_scan_again)) - }.show() - } - } - private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) scanPairing() else permissionHelp(getString(UiR.string.ui_camera), getString(UiR.string.ui_qr_pairing_requires_camera_permission_you_can_also_enter)) - } - private fun scanPairing() { - if (!WebSession.running) { toast(getString(UiR.string.ui_wait_for_the_web_service_to_start_before_scanning)); return } - if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { - toast(getString(UiR.string.ui_this_device_has_no_camera_enter_the_pairing_code)); return - } - if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { - cameraPermission.launch(Manifest.permission.CAMERA); return - } - scan.launch(null) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - Shizuku.addRequestPermissionResultListener(desktopPermissionResult) - if (TeslaHttpCompatibility.config(this).let { it.enabled && it.mode == TeslaHttpMode.ROOT }) RootAccess.checkOnStartup() - com.shilapi.xcertplay.hud.BydNavigationOutputs.onAppOpened(applicationContext) - ServerWindow.showSystemBars(window) - refreshAuthentication() - selectedPrivateKey = savedInstanceState?.getString("selectedPrivateKey")?.let(Uri::parse) - pendingAndroidAutoSourceId = savedInstanceState?.getString("pendingAndroidAutoSourceId") - awaitingTeslaVpnConsent = savedInstanceState?.getBoolean("awaitingTeslaVpnConsent") ?: false - page = savedInstanceState?.getString("page") ?: intent.getStringExtra("page") - ?: if (setupError == null) "service" else "settings" - if (savedInstanceState == null && page == "settings" && setupError != null) { - settingsModel.open(SettingsCategory.AUTHENTICATION) - } - createShell() - startWebServiceIfReady() - handleWirelessRecovery() - authenticationImport.state.observe(this) { state -> - refreshAuthentication() - render(); refreshService() - state.result?.let { - startWebServiceIfReady() - toast(it); authenticationImport.consumeResult() - } - } - if (savedInstanceState == null) handleAuthenticationIntent(intent) - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - when { - page == "settings" && settingsModel.state.value.category != null -> settingsModel.open(null) - page in sourceDetailPages -> navigate("phone") - page != "service" -> navigate("service") - else -> moveTaskToBack(true) - } - } - }) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent); setIntent(intent) - if (!handleAuthenticationIntent(intent)) navigate(intent.getStringExtra("page") ?: "service") - } - override fun onSaveInstanceState(outState: Bundle) { - outState.putString("page", page) - outState.putString("selectedPrivateKey", selectedPrivateKey?.toString()) - outState.putString("pendingAndroidAutoSourceId", pendingAndroidAutoSourceId) - outState.putBoolean("awaitingTeslaVpnConsent", awaitingTeslaVpnConsent) - super.onSaveInstanceState(outState) - } - override fun onConfigurationChanged(newConfig: Configuration) { - settingsView?.disposeComposition() - super.onConfigurationChanged(newConfig) - createShell() - } - override fun onResume() { - super.onResume(); projectionPlugins(this).refresh(); refreshAuthentication(); render(); ServerWindow.showSystemBars(window); handler.removeCallbacks(tick); handler.post(tick) - WebSession.setPhoneConnectHandler { - if (setupError != null) setupError!! - else { - connect(AirPlayPersistence.loadWirelessEnabled(this)) - getString(UiR.string.ui_iphone_connection_requested_complete_any_device_selection_or_permission) - } - } - // Back from the car settings: refresh the car hotspot reminder on the home page. - if (!initialLaunch) { refreshPhone(); refreshService() } - if (initialLaunch) { - initialLaunch = false - if (setupError == null && !DiPlayBootstrap.importing && selectedPrivateKey == null && - intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_VIEW && - !projectionSessions.hasSession() && - selectedHomeSource().id == ProjectionSourceIds.CARPLAY && DiPlayPreferences.autoConnect(this) && intent.getStringExtra("page") == null) { - handler.post { connect(AirPlayPersistence.loadWirelessEnabled(this)) } - } - } - } - override fun onPause() { WebSession.setPhoneConnectHandler(null); handler.removeCallbacks(tick); super.onPause() } - override fun onDestroy() { Shizuku.removeRequestPermissionResultListener(desktopPermissionResult); desktopDialog?.dismiss(); vncDialog?.dismiss(); localAndroidAutoDialog?.dismiss(); settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } - - private fun normalizedPage(value: String) = when (value) { - "home" -> "phone" - "wireless-recovery" -> SourcePage.CARPLAY.id - "about" -> "settings" - "phone", "settings" -> value - in sourceDetailPages -> value - else -> "service" - } - - private fun navigate(destination: String) { - val target = normalizedPage(destination) - if (target == "settings") { - settingsModel.open(if (setupError != null) SettingsCategory.AUTHENTICATION else null) - } - shell.select(target) - page = target - if (destination == "wireless-recovery") confirmWirelessReset() - } - - private fun createShell() { - val initial = normalizedPage(page) - shell = ui.Shell(initial, { page = it }, sourceDetailPages.associateWith { "phone" }) - page = initial - shell.put("service", ui.content(::service)) - refreshPhone() - settingsView = ComposeView(this).apply { - id = R.id.settings_compose - setContent { SettingsRoute(settingsModel, settingsActions, settingsDialogs) { shell.setPageScroll("settings", it) } } - }.also { shell.put("settings", it) } - refreshSettings() - setContentView(shell.root) - refreshService() - } - - private fun refreshPhone() { - status = null; connectButton = null; disconnectButton = null; lastRunning = null - sourceStatuses.clear(); sourceStopButtons.clear(); pluginFailureLabels.clear() - shell.put("phone", ui.content(::sourceIndex)) - shell.put(SourcePage.CARPLAY.id, ui.content(::carPlaySource)) - shell.put(SourcePage.ANDROID_AUTO.id, ui.content(::androidAutoSource)) - shell.put(SourcePage.ICCOA.id, ui.content(::iccoaSource)) - shell.put(SourcePage.VNC.id, ui.content(::vncSource)) - shell.put(SourcePage.DESKTOP.id, ui.content(::desktopSource)) - shell.put(SourcePage.DIAGNOSTICS.id, ui.content(::diagnosticSources)) - shell.put(SourcePage.PLUGINS.id, ui.content(::pluginSources)) - refreshStatus() - } - - private fun render() { - refreshPhone() - refreshSettings() - } - - private fun refreshSettings() { - settingsModel.reload() - refreshSettingsRuntime() - } - - private fun refreshSettingsRuntime() { - val session = CarPlayBackgroundSession.snapshot() - val audioStatus = when { - session == null -> getString(UiR.string.ui_current_no_iphone_connected) - AirPlayPersistence.loadBrowserAudioPlayback(this) && WebSession.audio.playbackReady && session.sink.isRemoteMediaAvailable() -> getString(UiR.string.ui_current_output_head_unit_browser) - AirPlayPersistence.loadBrowserAudioPlayback(this) -> getString(UiR.string.ui_current_output_android_browser_not_ready_or_offline) - else -> getString(UiR.string.ui_current_output_android) - } - val source = AirPlayPersistence.loadMicrophoneSource(this) - val microphoneStatus = when { - source == CarPlayMicrophoneSource.DISABLED -> getString(UiR.string.ui_current_microphone_disabled) - session == null -> getString(UiR.string.ui_current_no_iphone_connected) - source == CarPlayMicrophoneSource.BROWSER && WebSession.audio.microphoneReady -> getString(UiR.string.ui_input_browser_captured_on_demand) - checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED -> getString(UiR.string.ui_android_microphone_permission_is_missing_voice_input_is_unavailable) - source == CarPlayMicrophoneSource.BROWSER -> getString(UiR.string.ui_input_android_browser_microphone_not_ready) - else -> getString(UiR.string.ui_input_android_captured_on_demand) - } - val compatibility = TeslaHttpCompatibility.status - val teslaConfig = TeslaHttpCompatibility.config(this) - val teslaMessage = if (!teslaConfig.enabled) getString(UiR.string.ui_disabled) - else if (!WebSession.running && setupError != null) getString(UiR.string.ui_starts_after_authentication_resources_are_imported) - else compatibility.error ?: compatibility.address?.let { - if (compatibility.mode == TeslaHttpMode.VPN) getString(UiR.string.status_rootless_address_configured, it) - else getString(UiR.string.status_root_routing_enabled, it, compatibility.downstreams.joinToString()) - } ?: getString(UiR.string.status_preparing_mode, getString(teslaConfig.mode.labelRes)) - settingsModel.updateRuntime(SettingsRuntime( - authenticationError = setupError, importing = DiPlayBootstrap.importing, webBusy = webBusy, - awaitingVpnConsent = awaitingTeslaVpnConsent, exporting = exportInProgress, - rootGranted = RootAccess.granted, rootChecking = RootAccess.checking, rootStatus = RootAccess.status.resolve(this), - teslaStatus = teslaMessage, audioStatus = audioStatus, microphoneStatus = microphoneStatus, - version = version(), fingerprint = WebSession.tls?.fingerprint - )) - } - - private val settingsActions by lazy { - SettingsActions( - choice = { setting, index -> changeSettings { applySettingsEffect(settingsModel.choose(setting, index)) }?.let(::toast) }, - toggle = { setting, checked -> changeSettings { applySettingsEffect(settingsModel.toggle(setting, checked)) }?.let(::toast) }, - text = { setting, value -> changeSettings { applySettingsEffect(settingsModel.edit(setting, value)) } }, - credentials = { ssid, password -> changeSettings { settingsModel.saveCredentials(ssid, password) } }, - margins = { margins -> changeSettings { applySettingsEffect(settingsModel.saveMargins(margins)) }?.let(::toast) }, - resetBranding = { includeName -> changeSettings { settingsModel.resetBranding(includeName) }?.let(::toast) }, - command = { command -> changeSettings { settingsCommand(command) }?.let(::toast) }, - renameBrowser = { id, name -> changeSettings { settingsModel.renameBrowser(id, name) }?.let(::toast) }, - removeBrowser = { id -> changeSettings { settingsModel.removeBrowser(id) }?.let(::toast) } - ) - } - - private fun changeSettings(action: () -> Unit): String? = runCatching { - action(); refreshSettings(); refreshService() - }.exceptionOrNull()?.let { it.userMessage(this) } - - private fun applySettingsEffect(effect: SettingsEffect) { - when (effect) { - SettingsEffect.None -> Unit - is SettingsEffect.Reconnect -> if (CarPlayBackgroundSession.hasSession()) { - val wireless = effect.wireless ?: AirPlayPersistence.loadWirelessEnabled(this) - if (effect.stopBeforeConnect) { - // Release the old backend before checking the new hotspot: its P2P group - // can prevent the user from enabling the system hotspot. - CarPlayBackgroundSession.stop { runOnUiThread { connect(wireless) } } - } else connect(wireless) - } - is SettingsEffect.WebUpdate -> configureWeb(effect.message, effect.operation) - is SettingsEffect.TeslaEnabled -> setTeslaHttpEnabled(effect.enabled) - } - } - - private fun settingsCommand(command: SettingsCommand) { - when (command) { - SettingsCommand.SELECT_PHONE -> choosePhone() - SettingsCommand.HOTSPOT_SETTINGS -> openCarWifiSettings() - SettingsCommand.REQUEST_ROOT -> configureWeb(getString(UiR.string.ui_root_access_granted)) { RootAccess.request()?.resolve(this).also { TeslaHttpCompatibility.retry() } } - SettingsCommand.RETRY_ROOT -> TeslaHttpCompatibility.retry() - SettingsCommand.ENABLE_VPN -> setTeslaHttpEnabled(true) - SettingsCommand.SELECT_ICON -> oemImagePicker.launch("image/*") - SettingsCommand.IMPORT_APK -> if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationApk.launch(arrayOf("*/*")) } - SettingsCommand.IMPORT_FILES -> if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationKey.launch(arrayOf("*/*")) } - SettingsCommand.SELECT_PEM -> pemCertificatePicker.launch(arrayOf("*/*")) - SettingsCommand.SELECT_PKCS12 -> pkcs12Picker.launch(arrayOf("*/*")) - SettingsCommand.APP_PERMISSIONS -> openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) - SettingsCommand.BLUETOOTH_SETTINGS -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) - SettingsCommand.WIRELESS_HELP -> wirelessHelp() - SettingsCommand.EXPORT_REPORT -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) exportDiagnostics() else chooseReportDestination() - SettingsCommand.DIPLAY -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/shihabal3amri/DiPlay"))) - SettingsCommand.SOURCE -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/fython/wheelplay"))) - SettingsCommand.DOWNLOAD -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://shihabal3amri.github.io/DiPlay/"))) - } - } - - private fun service(content: LinearLayout) { - content.addView(label(getString(R.string.app_name), 21, TEXT, true).apply { setPadding(0, 0, 0, dp(24)) }) - content.addView(label(getString(UiR.string.home_projection_headline), 28, TEXT, true)) - content.addView(space(24)) - ui.card(content, getString(UiR.string.ui_head_unit_access), R.drawable.ic_server_server) { body -> - body.addView(label(getString(UiR.string.ui_local_network_address), 14, MUTED)) - webAddress = label(getString(UiR.string.ui_starting_service), 21, ACCENT, true).apply { - setTextIsSelectable(true); setPadding(0, dp(8), 0, dp(8)) - } - body.addView(webAddress) - webInterface = label("", 14, MUTED) - body.addView(webInterface) - quickBrowserButton = button(getString(UiR.string.ui_try_in_this_device_s_browser), false, R.drawable.ic_server_launch) { - openLocalBrowserExperience() - }.apply { isEnabled = false } - body.addView(quickBrowserButton, ui.secondaryButtonLayout(12)) - otherAddressesToggle = ui.DisclosureRow(getString(UiR.string.ui_other_addresses)) { - addressesExpanded = !addressesExpanded - updateAddressExpansion(animate = true) - }.apply { visibility = View.GONE } - body.addView(otherAddressesToggle, ui.secondaryButtonLayout(4)) - otherAddresses = column().apply { visibility = View.GONE } - body.addView(otherAddresses) - displayedAddresses = null - } - ui.card(content, getString(UiR.string.ui_browser_pairing), R.drawable.ic_server_qr) { body -> - webPairingControls = column().apply { - addView(label(getString(UiR.string.ui_scan_the_qr_code_on_the_head_unit_web), 16, MUTED)) - addView(button(getString(UiR.string.ui_scan_qr_code_to_pair), true, R.drawable.ic_server_scan) { scanPairing() }, matchButton(12)) - addView(label(getString(UiR.string.ui_or_enter_the_pairing_code_on_the_web_page), 14, MUTED).apply { setPadding(0, dp(16), 0, 0) }) - webCode = label("— — — — — —", 32, TEXT, true).apply { - letterSpacing = .12f; setTextIsSelectable(true); setPadding(0, dp(8), 0, dp(16)) - } - addView(webCode) - } - body.addView(webPairingControls) - webViewer = label(getString(UiR.string.ui_waiting_for_head_unit_browser), 16, MUTED) - body.addView(webViewer) - } - ui.card(content, getString(UiR.string.home_source_title), R.drawable.ic_server_phone) { body -> - homeSourceButton = button("", false) { chooseHomeSource() } - body.addView(homeSourceButton, ui.secondaryButtonLayout()) - webStage = label("", 16, TEXT).apply { setPadding(0, dp(12), 0, 0) } - body.addView(webStage) - sessionConnectButton = button(getString(UiR.string.ui_connect), false, R.drawable.ic_server_phone) { - val source = selectedHomeSource() - when (source.id) { - ProjectionSourceIds.CARPLAY -> connect(AirPlayPersistence.loadWirelessEnabled(this)) - ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.ANDROID_DESKTOP -> navigate(source.page.id) - ProjectionSourceIds.VNC -> configureVnc() - else -> startProjectionSource(source.id) - } - } - body.addView(sessionConnectButton, ui.secondaryButtonLayout(12)) - homeStopButton = button("", false) { - homeStopButton?.isEnabled = false - val active = projectionSessions.snapshot() - if (projectionSessions.hasSession()) projectionSessions.stop(active).whenComplete { _, failure -> runOnUiThread { - if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) - refreshStatus(); refreshService() - } } else CarPlayBackgroundSession.stop { runOnUiThread { refreshStatus(); refreshService() } } - } - body.addView(homeStopButton, ui.secondaryButtonLayout(12)) - body.addView(button(getString(UiR.string.home_source_settings), false) { - navigate(selectedHomeSource().page.id) - }, ui.secondaryButtonLayout(16)) - } - homeAudioHint = label(getString(UiR.string.ui_audio_and_microphone_use_this_device_by_default_enable), 14, MUTED) - content.addView(homeAudioHint) - content.addView(button(getString(UiR.string.ui_stop_service), false) { - startService(Intent(this, DiPlaySessionService::class.java).setAction(DiPlaySessionService.ACTION_STOP)) - finishAndRemoveTask() - }, ui.secondaryButtonLayout(24)) - } - - private fun homeSources(): List = listOf( - HomeSource(ProjectionSourceIds.CARPLAY, getString(UiR.string.source_carplay), SourcePage.CARPLAY), - HomeSource(ProjectionSourceIds.ANDROID_AUTO, "Android Auto", SourcePage.ANDROID_AUTO), - HomeSource(ProjectionSourceIds.VNC, "VNC", SourcePage.VNC), - HomeSource(ProjectionSourceIds.ANDROID_DESKTOP, getString(UiR.string.desktop_title), SourcePage.DESKTOP), - ) + projectionPlugins(this).let { registry -> - registry.entries().filter { registry.available(it) }.map { plugin -> - HomeSource(plugin.id, plugin.name, - if (plugin.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK) SourcePage.ICCOA else SourcePage.PLUGINS) - } - } - - private fun selectedHomeSource(): HomeSource { - val sources = homeSources() - val saved = DiPlayPreferences.homeSource(this) - return sources.find { it.id == saved } ?: sources.first().also { - DiPlayPreferences.saveHomeSource(this, it.id) - } - } - - private fun chooseHomeSource() { - projectionPlugins(this).refresh() - val selected = selectedHomeSource() - val sources = homeSources() - android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.home_choose_source)) - .setSingleChoiceItems(sources.map { it.name }.toTypedArray(), sources.indexOf(selected)) { dialog, index -> - // Selecting a target never starts or interrupts a projection session. - DiPlayPreferences.saveHomeSource(this, sources[index].id) - dialog.dismiss(); refreshService() - }.setNegativeButton(android.R.string.cancel, null).show() - } - - private fun refreshHomeSource() { - val source = selectedHomeSource() - val active = projectionSessions.snapshot() - val carPlayRunning = CarPlayBackgroundSession.hasSession() - val runtimeState = projectionSessions.stateSnapshot() - val activeName = active?.descriptor?.displayName ?: if (carPlayRunning) getString(UiR.string.source_carplay) - else runtimeState.source?.displayName?.takeIf { projectionSessions.hasSession() } - val selectedActive = (source.id == ProjectionSourceIds.CARPLAY && carPlayRunning) || active?.let { - it.descriptor.id == source.id || (source.id in setOf(ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.VNC) && belongsToSource(it.descriptor.id, source.id)) - } == true - homeSourceButton?.text = getString(UiR.string.home_switch_source, source.name) - webStage?.text = when { - active != null -> getString(UiR.string.home_active_source, active.descriptor.displayName, - runtimeState.failure?.message(this) ?: active.statusText ?: getString(UiR.string.projection_active, active.descriptor.displayName)) - carPlayRunning -> getString(UiR.string.home_active_source, getString(UiR.string.source_carplay), WebSession.stage(this)) - source.id == ProjectionSourceIds.CARPLAY -> setupError ?: when { - DiPlayPreferences.phoneAddress(this) != null -> getString(UiR.string.status_selected_phone, DiPlayPreferences.phoneName(this)) - else -> getString(UiR.string.ui_waiting_to_connect_to_iphone) - } - projectionPlugins(this).lastFailure(source.id) != null -> - getString(UiR.string.projection_failed, projectionPlugins(this).lastFailure(source.id)) - runtimeState.source?.id == source.id -> runtimeState.message(this) - else -> getString(UiR.string.projection_waiting) - } - sessionConnectButton?.apply { - text = getString(if (source.id in setOf(ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.VNC, ProjectionSourceIds.ANDROID_DESKTOP)) UiR.string.home_configure_connect else UiR.string.ui_connect) - visibility = if (selectedActive || (source.id == ProjectionSourceIds.CARPLAY && DiPlayPreferences.phoneAddress(this@DiPlayActivity) == null)) View.GONE else View.VISIBLE - isEnabled = source.id != ProjectionSourceIds.CARPLAY || (setupError == null && !DiPlayBootstrap.importing) - } - homeStopButton?.apply { - visibility = if (activeName != null) View.VISIBLE else View.GONE - text = getString(UiR.string.home_stop_source, activeName.orEmpty()) - isEnabled = activeName != null - } - homeAudioHint?.visibility = if (source.id == ProjectionSourceIds.CARPLAY) View.VISIBLE else View.GONE - } - - private fun refreshService() { - val addresses = if (WebSession.running && WebSession.error == null) WebSession.addresses(this) else emptyList() - val primary = addresses.firstOrNull() - val addressText = WebSession.error ?: if (WebSession.running) primary?.url - ?: getString(UiR.string.ui_no_local_network_address_found_connect_to_wi_fi) else getString(UiR.string.ui_starting_service) - if (webAddress?.text?.toString() != addressText) webAddress?.text = addressText - webInterface?.text = primary?.let { it.description(this) + if (it.hostname.isNotEmpty()) getString(UiR.string.address_direct_ip, it.ipUrl) else "" }.orEmpty() - webInterface?.visibility = if (primary == null) View.GONE else View.VISIBLE - if (displayedAddresses != addresses) { - displayedAddresses = addresses - otherAddresses?.removeAllViews() - addresses.drop(1).forEachIndexed { index, entry -> - otherAddresses?.addView(label(entry.description(this), 14, MUTED)) - otherAddresses?.addView(label(entry.url, 16, TEXT).apply { - setTextIsSelectable(true); setPadding(0, dp(4), 0, if (index < addresses.size - 2) dp(16) else 0) - }) - } - updateAddressExpansion() - } - webCode?.text = if (WebSession.running) WebSession.code else "— — — — — —" - quickBrowserButton?.isEnabled = WebSession.running && BrowserExperienceLink.local(WebSession.code, WebSession.httpPort) != null - val viewerConnected = WebSession.hasViewer - webPairingControls?.visibility = if (viewerConnected) View.GONE else View.VISIBLE - webViewer?.text = if (viewerConnected) getString(UiR.string.ui_head_unit_browser_connected) else getString(UiR.string.ui_waiting_for_head_unit_browser) - refreshHomeSource() - } - - private fun openLocalBrowserExperience() { - val url = BrowserExperienceLink.local(WebSession.code, WebSession.httpPort) - if (!WebSession.running || url == null) { - toast(getString(UiR.string.ui_wait_for_the_web_service_to_start_before_opening)) - return - } - runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } - .onFailure { toast(getString(UiR.string.ui_no_browser_available_to_open_this_page)) } - } - - private fun updateAddressExpansion(animate: Boolean = false) { - val count = ((displayedAddresses?.size ?: 0) - 1).coerceAtLeast(0) - otherAddressesToggle?.visibility = if (count > 0) View.VISIBLE else View.GONE - otherAddressesToggle?.setExpanded(addressesExpanded, animate) - otherAddresses?.visibility = if (count > 0 && addressesExpanded) View.VISIBLE else View.GONE - } - - private fun sourceIndex(content: LinearLayout) { - content.addView(label(getString(UiR.string.source_tab), 30, TEXT, true).apply { setPadding(0, 0, 0, dp(24)) }) - ui.sourceCard(content, getString(UiR.string.source_carplay), getString(UiR.string.source_carplay_summary), R.drawable.ic_server_phone) { navigate(SourcePage.CARPLAY.id) } - ui.sourceCard(content, "Android Auto", getString(UiR.string.source_aa_summary), R.drawable.ic_server_launch) { navigate(SourcePage.ANDROID_AUTO.id) } - val carLinkPlugins = projectionPlugins(this).entriesForSource(ProjectionPlugin.SOURCE_ICCOA_CARLINK) - val carLinkSummary = if (carLinkPlugins.isEmpty()) getString(UiR.string.source_iccoa_summary) - else getString(UiR.string.plugin_iccoa_installed, carLinkPlugins.joinToString { it.name }) - ui.sourceCard(content, "ICCOA CarLink", carLinkSummary, R.drawable.ic_server_wifi) { navigate(SourcePage.ICCOA.id) } - ui.sourceCard(content, "VNC", getString(UiR.string.vnc_summary), R.drawable.ic_server_server) { navigate(SourcePage.VNC.id) } - ui.sourceCard(content, getString(UiR.string.desktop_title), getString(UiR.string.desktop_summary), R.drawable.ic_server_phone) { navigate(SourcePage.DESKTOP.id) } - ui.sourceCard(content, getString(UiR.string.plugin_sources), getString(UiR.string.plugin_summary), R.drawable.ic_server_launch) { navigate(SourcePage.PLUGINS.id) } - ui.sourceCard(content, getString(UiR.string.source_diagnostics), getString(UiR.string.source_diagnostics_summary), R.drawable.ic_server_server) { navigate(SourcePage.DIAGNOSTICS.id) } - } - - private fun pluginSources(content: LinearLayout) { - sourceHeading(content, getString(UiR.string.plugin_sources)) - val plugins = projectionPlugins(this) - content.addView(label(getString(UiR.string.plugin_install_hint), 15, MUTED)) - content.addView(button(getString(UiR.string.plugin_refresh), false) { - plugins.refresh(); refreshPhone() - }, matchButton(12)) - sourceSessionControls(content, ProjectionSourceIds.PLUGIN) - if (plugins.entries().isEmpty()) content.addView(label(getString(UiR.string.plugin_none), 16, MUTED)) - plugins.entries().forEach { pluginCard(content, it) } - } - - private fun pluginCard(content: LinearLayout, plugin: InstalledProjectionPlugin) { - val plugins = projectionPlugins(this) - ui.card(content, plugin.name, R.drawable.ic_server_launch) { body -> - body.addView(label(plugin.component.packageName, 13, MUTED)) - val failureLabel = label("", 15, WARNING).apply { visibility = View.GONE } - pluginFailureLabels.getOrPut(plugin.id) { mutableListOf() }.add(failureLabel) - body.addView(failureLabel) - val enabled = plugins.enabled(plugin) - body.addView(label(plugin.problem ?: getString(if (enabled) UiR.string.plugin_enabled else UiR.string.plugin_disabled), 15, MUTED)) - if (plugin.problem == null || enabled) body.addView(button(getString(if (enabled) UiR.string.plugin_disable else UiR.string.plugin_enable), false) { - if (enabled) { plugins.setEnabled(plugin, false); refreshPhone() } - else android.app.AlertDialog.Builder(this) - .setTitle(getString(UiR.string.plugin_enable)) - .setMessage(getString(UiR.string.plugin_enable_prompt, plugin.name, plugin.component.packageName)) - .setPositiveButton(android.R.string.ok) { _, _ -> - runCatching { plugins.setEnabled(plugin, true) }.onFailure { toast(getString(UiR.string.plugin_unavailable)) } - refreshPhone() - }.setNegativeButton(android.R.string.cancel, null).show() - }, matchButton(12)) - if (plugin.settings != null) body.addView(button(getString(UiR.string.plugin_settings), false) { - runCatching { startActivity(Intent().setComponent(plugin.settings)) } - .onFailure { toast(getString(UiR.string.plugin_unavailable)) } - }, matchButton(12)) - body.addView(button(getString(UiR.string.plugin_connect), true) { startProjectionSource(plugin.id) }.apply { - isEnabled = enabled && plugin.problem == null - }, matchButton(12)) - } - } - - private fun sourceHeading(content: LinearLayout, title: String) { - content.addView(button(getString(UiR.string.source_back), false, R.drawable.ic_settings_back) { navigate("phone") }, - LinearLayout.LayoutParams(-2, -2).apply { bottomMargin = dp(16) }) - content.addView(label(title, 30, TEXT, true).apply { setPadding(0, 0, 0, dp(24)) }) - } - - private fun carPlaySource(content: LinearLayout) { - sourceHeading(content, getString(UiR.string.source_carplay)) - content.addView(label(getString(UiR.string.ui_this_device_receives_carplay_the_head_unit_browser_provides), 16, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) - status = label(getString(UiR.string.ui_waiting_for_connection), 20, ACCENT, true) - ui.card(content, getString(UiR.string.ui_connection_status), R.drawable.ic_server_phone) { it.addView(status) } - ui.card(content, getString(UiR.string.ui_wireless_connection), R.drawable.ic_server_wifi) { body -> - body.addView(label(getString(UiR.string.ui_pair_the_iphone_with_this_android_device_over_bluetooth), 15, MUTED)) - body.addView(ui.preference("iPhone", selectedPhoneName()) { choosePhone() }, matchButton()) - connectButton = button(getString(UiR.string.ui_connect_iphone), true, R.drawable.ic_server_phone) { connect(true) } - body.addView(connectButton, matchButton(12)) - disconnectButton = button(getString(UiR.string.ui_disconnect_iphone), false) { - disconnectButton?.isEnabled = false - CarPlayBackgroundSession.stop { runOnUiThread { - startWebServiceIfReady() - WebSession.setStage(UiText(UiR.string.ui_iphone_disconnected)) - refreshStatus(); refreshService() - } } - }.apply { visibility = View.GONE } - body.addView(disconnectButton, ui.secondaryButtonLayout(12)) - if (carHotspotOff()) { - body.addView(label(getString(UiR.string.hotspot_disabled_hint, AirPlayPersistence.loadManualHotspotSsid(this)), 15, WARNING).apply { setPadding(0, dp(16), 0, 0) }) - body.addView(button(getString(UiR.string.ui_open_hotspot_settings), false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12)) - } - } - ui.card(content, getString(UiR.string.ui_usb_connection), R.drawable.ic_server_usb) { body -> - body.addView(label(getString(UiR.string.ui_connect_the_iphone_to_this_device_with_a_data), 15, MUTED)) - body.addView(button(getString(UiR.string.ui_connect_over_usb), false) { connect(false) }, ui.secondaryButtonLayout(16)) - } - setupError?.let { content.addView(label(it, 16, WARNING)) } - } - - private fun androidAutoSource(content: LinearLayout) { - sourceHeading(content, "Android Auto") - sourceSessionControls(content, ProjectionSourceIds.ANDROID_AUTO) - ui.card(content, getString(UiR.string.aa_local_title), R.drawable.ic_server_phone) { body -> - body.addView(label(getString(UiR.string.aa_local_summary), 15, MUTED)) - body.addView(button(getString(UiR.string.aa_local_connect), true) { connectLocalAndroidAuto() }, matchButton(12)) - body.addView(button(getString(UiR.string.aa_local_help), false) { showLocalAndroidAutoSetup() }, ui.secondaryButtonLayout(12)) - } - ui.card(content, getString(UiR.string.ui_usb_connection), R.drawable.ic_server_usb) { body -> - body.addView(label(getString(UiR.string.aa_usb_hint), 15, MUTED)) - body.addView(button(getString(UiR.string.aa_usb_connect), false) { startProjectionSource(ProjectionSourceIds.ANDROID_AUTO_USB) }, matchButton(12)) - } - ui.card(content, getString(UiR.string.aa_remote_title), R.drawable.ic_server_wifi) { body -> - body.addView(label(getString(UiR.string.aa_tcp_hint), 15, MUTED)) - body.addView(button(getString(UiR.string.aa_remote_connect), false) { configureAndroidAutoTcp() }, matchButton(12)) - } - } - - private fun iccoaSource(content: LinearLayout) { - sourceHeading(content, "ICCOA CarLink") - val plugins = projectionPlugins(this).entriesForSource(ProjectionPlugin.SOURCE_ICCOA_CARLINK) - if (plugins.isEmpty()) { - ui.card(content, getString(UiR.string.plugin_iccoa_missing), R.drawable.ic_server_phone) { body -> - body.addView(label(getString(UiR.string.plugin_iccoa_hint), 16, MUTED)) - body.addView(button(getString(UiR.string.plugin_refresh), false) { - projectionPlugins(this).refresh(); refreshPhone() - }, matchButton(12)) - body.addView(button(getString(UiR.string.plugin_sources), false) { navigate(SourcePage.PLUGINS.id) }, matchButton(12)) - } - } - // Keep stop/status available if the APK disappears while its session is being cleaned up. - if (plugins.isNotEmpty() || projectionSessions.snapshot()?.let { sessionBelongsToSource(it, ProjectionSourceIds.ICCOA) } == true) { - sourceSessionControls(content, ProjectionSourceIds.ICCOA) - } - plugins.forEach { pluginCard(content, it) } - } - - private var desktopStatus: TextView? = null - private var desktopStart: Button? = null - private val desktopPermissionResult = Shizuku.OnRequestPermissionResultListener { code, _ -> - if (code == 40) runOnUiThread { if (!isDestroyed) refreshPhone() } - } - private fun desktopProvider(): AndroidDesktopSource { - registerProjectionSources(this) - return projectionSessions.sources().filterIsInstance().single() - } - private fun desktopSource(content: LinearLayout) { - val source = desktopProvider() - sourceHeading(content, getString(UiR.string.desktop_title)) - sourceSessionControls(content, ProjectionSourceIds.ANDROID_DESKTOP) - ui.card(content, getString(UiR.string.desktop_title), R.drawable.ic_server_phone) { body -> - body.addView(label(getString(UiR.string.desktop_hint), 15, MUTED)) - val config = source.displayConfig() - body.addView(button(getString(UiR.string.desktop_display_summary, config.width, config.height, config.dpi), false) { - configureDesktopDisplay() - }, matchButton(12)) - desktopStatus = label("", 15, MUTED).also { body.addView(it) } - body.addView(button(getString(UiR.string.desktop_open_shizuku), false) { - openSystem(packageManager.getLaunchIntentForPackage(ShizukuDesktopBackend.PACKAGE) - ?: Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/download/"))) - }, matchButton(12)) - body.addView(button(getString(UiR.string.desktop_authorize), false) { - runCatching { - if (Shizuku.pingBinder() && Shizuku.getVersion() >= 13) Shizuku.requestPermission(40) - else toast((source.dependencyStatus() as? SourceAvailability.Unavailable)?.reason.orEmpty()) - }.onFailure { toast(getString(UiR.string.desktop_not_authorized)) } - }, matchButton(12)) - body.addView(button(getString(UiR.string.desktop_choose_app), false) { chooseDesktopApp() }, matchButton(12)) - if (source.selectedApp().isNotEmpty()) body.addView(label(source.selectedApp(), 13, MUTED)) - desktopStart = button(getString(UiR.string.desktop_start), true) { - startProjectionSource(ProjectionSourceIds.ANDROID_DESKTOP) - }.also { body.addView(it, matchButton(12)) } - } - } - private fun configureDesktopDisplay() { - val source = desktopProvider() - val config = source.displayConfig() - val fields = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(24), 0, dp(24), 0) } - fun field(title: Int, value: Int) = EditText(this).apply { - fields.addView(label(getString(title), 14, MUTED)) - contentDescription = getString(title) - inputType = android.text.InputType.TYPE_CLASS_NUMBER - setSingleLine(true); setText(value.toString()); fields.addView(this) - } - val width = field(UiR.string.desktop_width, config.width) - val height = field(UiR.string.desktop_height, config.height) - val dpi = field(UiR.string.desktop_dpi, config.dpi) - val error = label(getString(UiR.string.desktop_display_invalid, DesktopDisplayConfig.MIN_SIZE, - DesktopDisplayConfig.MAX_SIZE, DesktopDisplayConfig.MAX_PIXELS, DesktopDisplayConfig.MIN_DPI, - DesktopDisplayConfig.MAX_DPI), 14, MUTED).apply { isVisible = false; fields.addView(this) } - desktopDialog?.dismiss() - val dialog = android.app.AlertDialog.Builder(this).setTitle(UiR.string.desktop_display_settings) - .setMessage(UiR.string.desktop_display_hint).setView(ScrollView(this).apply { addView(fields) }) - .setPositiveButton(android.R.string.ok, null).setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(UiR.string.desktop_display_reset, null).create() - dialog.setOnDismissListener { if (desktopDialog === dialog) desktopDialog = null } - desktopDialog = dialog - dialog.show() - dialog.getButton(android.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener { - val defaults = DesktopDisplayConfig() - width.setText(defaults.width.toString()); height.setText(defaults.height.toString()); dpi.setText(defaults.dpi.toString()) - error.isVisible = false - } - dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val selected = runCatching { - DesktopDisplayConfig( - width = width.text.toString().toInt(), - height = height.text.toString().toInt(), - dpi = dpi.text.toString().toInt(), - ) - }.getOrNull() - if (selected == null) { error.isVisible = true; return@setOnClickListener } - source.configureDisplay(selected) - dialog.dismiss() - refreshPhone() - } - } - - private fun chooseDesktopApp() { - val apps = packageManager.queryIntentActivities(AndroidDesktopSource.launcherIntent(), 0) - .filter { it.activityInfo.isDesktopLaunchable } - .distinctBy { it.activityInfo.desktopComponent } - .sortedBy { it.loadLabel(packageManager).toString().lowercase() } - if (apps.isEmpty()) { toast(getString(UiR.string.desktop_no_apps)); return } - android.app.AlertDialog.Builder(this).setTitle(UiR.string.desktop_choose_app) - .setItems(apps.map { "${it.loadLabel(packageManager)} (${it.activityInfo.packageName})" }.toTypedArray()) { _, index -> - val info = apps[index].activityInfo - runCatching { desktopProvider().selectApp(info.desktopComponent.flattenToString()) } - .onFailure { toast(getString(UiR.string.desktop_choose_app)) } - refreshPhone() - }.setNegativeButton(android.R.string.cancel, null).show() - } - - private fun vncSource(content: LinearLayout) { - sourceHeading(content, "VNC") - sourceSessionControls(content, ProjectionSourceIds.VNC) - ui.card(content, getString(UiR.string.vnc_desktop), R.drawable.ic_server_server) { body -> - body.addView(label(getString(UiR.string.vnc_hint), 15, MUTED)) - body.addView(button(getString(UiR.string.vnc_connect), true) { configureVnc() }, matchButton(12)) - } - } - - private fun configureVnc() { - registerProjectionSources(this) - val source = projectionSessions.sources().filterIsInstance().single() - val endpoint = source.endpoint() - val fields = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(24), 0, dp(24), 0) } - fun field(title: Int, value: String, type: Int) = EditText(this).apply { - hint = getString(title); contentDescription = hint - inputType = type; setSingleLine(true); setText(value); fields.addView(this) - } - val host = field(UiR.string.vnc_host, endpoint.host, android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS) - val port = field(UiR.string.vnc_port, endpoint.port.toString(), android.text.InputType.TYPE_CLASS_NUMBER) - val password = field(UiR.string.vnc_password, "", android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD) - val readOnly = CheckBox(this).apply { text = getString(UiR.string.vnc_read_only); isChecked = endpoint.readOnly; fields.addView(this) } - vncDialog?.dismiss() - val dialog = android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.vnc_connect)) - .setMessage(getString(UiR.string.vnc_security_hint)).setView(fields) - .setPositiveButton(android.R.string.ok, null).setNegativeButton(android.R.string.cancel, null).create() - dialog.setOnDismissListener { password.text.clear(); if (vncDialog === dialog) vncDialog = null } - vncDialog = dialog - dialog.show() - dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val target = VncEndpoint(host.text.toString().trim(), port.text.toString().toIntOrNull() ?: 0, readOnly.isChecked) - if (!target.valid()) { host.error = getString(UiR.string.vnc_invalid_address); return@setOnClickListener } - val secret = CharArray(password.length()) { password.text[it] } - try { - if (secret.size > 8 || secret.any { it.code !in 1..255 }) { - password.error = getString(UiR.string.vnc_password_hint); return@setOnClickListener - } - source.configure(target, secret) - dialog.dismiss() - startProjectionSource(ProjectionSourceIds.VNC_TCP) - } finally { secret.fill('\u0000') } - } - } - - private fun diagnosticSources(content: LinearLayout) { - sourceHeading(content, getString(UiR.string.source_diagnostics)) - sourceSessionControls(content, ProjectionSourceIds.DEMO) - ui.card(content, getString(UiR.string.source_diagnostics), R.drawable.ic_server_server) { body -> - body.addView(label(getString(UiR.string.projection_demo_hint), 15, MUTED)) - body.addView(button(getString(UiR.string.projection_choose_demo), false) { chooseDiagnosticSource() }, matchButton(12)) - } - } - - private fun belongsToSource(id: String, group: String) = id.startsWith("$group-") - - private fun sessionBelongsToSource(session: ProjectionSession, group: String): Boolean = - if (group == ProjectionSourceIds.ICCOA) (session as? RemotePluginSession)?.plugin?.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK - else session.descriptor.id == group || belongsToSource(session.descriptor.id, group) - - private fun sourceSessionControls(content: LinearLayout, group: String) { - ui.card(content, getString(UiR.string.ui_connection_status), R.drawable.ic_server_phone) { body -> - sourceStatuses[group] = label(getString(UiR.string.projection_waiting), 16, TEXT).also { body.addView(it) } - sourceStopButtons[group] = button(getString(UiR.string.projection_stop), false) { - val session = projectionSessions.snapshot()?.takeIf { sessionBelongsToSource(it, group) } - val state = projectionSessions.stateSnapshot() - val pendingHere = state.source?.id?.let { it == group || belongsToSource(it, group) } == true && projectionSessions.hasSession() - if (session != null || pendingHere) projectionSessions.stop(session).whenComplete { _, failure -> runOnUiThread { - if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) - refreshStatus(); refreshService() - } } - }.also { body.addView(it, ui.secondaryButtonLayout(12)) } - } - } - - private fun chooseDiagnosticSource() { - registerProjectionSources(this) - val sources = projectionSessions.sources().filter { belongsToSource(it.descriptor.id, ProjectionSourceIds.DEMO) } - android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.projection_choose_demo)) - .setItems(sources.map { it.descriptor.displayName }.toTypedArray()) { _, index -> - startProjectionSource(sources[index].descriptor.id) - }.setNegativeButton(android.R.string.cancel, null).show() - } - - private fun configureAndroidAutoTcp() { - val host = android.widget.EditText(this).apply { - setSingleLine(true) - hint = "192.168.1.2" - setText(getSharedPreferences("projection", 0).getString("aa_host", "")) - } - android.app.AlertDialog.Builder(this) - .setTitle(getString(UiR.string.aa_tcp_title)) - .setMessage(getString(UiR.string.aa_tcp_hint)) - .setView(host) - .setPositiveButton(android.R.string.ok) { _, _ -> - val address = host.text.toString().trim() - if (!com.shilapi.xcertplay.projection.androidauto.AapTcpConnection.validAddress(address)) { - toast(getString(UiR.string.aa_invalid_address)) - } else { - getSharedPreferences("projection", 0).edit().putString("aa_host", address).putInt("aa_port", 5277).apply() - startProjectionSource(ProjectionSourceIds.ANDROID_AUTO_TCP) - } - }.setNegativeButton(android.R.string.cancel, null).show() - } - - private fun connectLocalAndroidAuto() { - projectionSessions.snapshot()?.takeIf { it.descriptor.id == LocalAndroidAuto.SOURCE_ID }?.let { - toast(it.statusText ?: getString(UiR.string.projection_active, it.descriptor.displayName)) - return - } - if (!LocalAndroidAuto.isInstalledAndEnabled(this) || - !getSharedPreferences("projection", 0).getBoolean(LocalAndroidAuto.SETUP_SEEN, false)) { - showLocalAndroidAutoSetup() - return - } - registerProjectionSources(this) - startProjectionSource(LocalAndroidAuto.SOURCE_ID) - } - - private fun showLocalAndroidAutoSetup() { - localAndroidAutoDialog?.dismiss() - val available = LocalAndroidAuto.isInstalledAndEnabled(this) - val builder = android.app.AlertDialog.Builder(this) - .setTitle(getString(UiR.string.aa_local_title)) - .setMessage(getString(if (available) UiR.string.aa_local_setup else UiR.string.aa_local_unavailable)) - .setNeutralButton(UiR.string.aa_local_settings) { _, _ -> openLocalAndroidAutoSettings() } - .setNegativeButton(android.R.string.cancel, null) - if (available) builder.setPositiveButton(UiR.string.aa_local_connect) { _, _ -> - getSharedPreferences("projection", 0).edit().putBoolean(LocalAndroidAuto.SETUP_SEEN, true).apply() - connectLocalAndroidAuto() - } - localAndroidAutoDialog = builder.show() - } - - private fun openLocalAndroidAutoSettings() { - for (intent in LocalAndroidAuto.settingsIntents()) { - try { - localAndroidAutoSettings.launch(intent) - return - } catch (_: android.content.ActivityNotFoundException) { - // AA versions expose different settings entry points. - } catch (_: SecurityException) { - // Fall back when an AA activity is not exported. - } - } - toast(getString(UiR.string.aa_local_settings_unavailable)) - } - - private fun startProjectionSource(id: String) { - val homeId = when { - belongsToSource(id, ProjectionSourceIds.ANDROID_AUTO) -> ProjectionSourceIds.ANDROID_AUTO - belongsToSource(id, ProjectionSourceIds.VNC) -> ProjectionSourceIds.VNC - else -> id - } - if (homeSources().any { it.id == homeId }) DiPlayPreferences.saveHomeSource(this, homeId) - if (isAndroidAutoSource(id) && - AirPlayPersistence.loadMicrophoneSource(this) == CarPlayMicrophoneSource.ANDROID && - checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - pendingAndroidAutoSourceId = id - androidAutoMicrophonePermission.launch(Manifest.permission.RECORD_AUDIO) - return - } - startProjectionSourceNow(id) - } - - private fun isAndroidAutoSource(id: String) = belongsToSource(id, ProjectionSourceIds.ANDROID_AUTO) - - private fun startProjectionSourceNow(id: String) { - registerProjectionSources(this) - startWebServiceIfReady() - projectionSessions.switchTo(id).whenComplete { _, failure -> runOnUiThread { - if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) - refreshStatus(); refreshService() - } } - } - - private fun refreshAuthentication() { - setupError = if (DiPlayBootstrap.importing) getString(UiR.string.ui_importing_authentication_resources_please_wait) - else runCatching { DiPlayBootstrap.ensure(this) }.exceptionOrNull()?.let { - getString(UiR.string.ui_authentication_resources_are_not_ready_import_files_or_a_2) - } - } - - private fun startWebServiceIfReady() { - startForegroundService(Intent(this, DiPlaySessionService::class.java)) - } - - private fun canImportAuthentication(): Boolean { - if (DiPlayBootstrap.importing) { toast(getString(UiR.string.ui_importing_authentication_resources_please_wait)); return false } - if (CarPlayBackgroundSession.hasSession()) { toast(getString(UiR.string.ui_disconnect_the_iphone_before_importing_authentication_resources)); return false } - return true - } - - private fun launchAuthenticationPicker(launch: () -> Unit) { - runCatching(launch).onFailure { - selectedPrivateKey = null - toast(getString(UiR.string.ui_could_not_open_the_file_picker_enable_the_system)) - } - } - - private fun handleAuthenticationIntent(incoming: Intent): Boolean { - if (incoming.action != Intent.ACTION_SEND && incoming.action != Intent.ACTION_VIEW) return false - settingsModel.open(SettingsCategory.AUTHENTICATION) - shell.select("settings") - page = "settings" - val uri = AuthenticationImportIntents.apk(incoming) - if (uri == null) { toast(getString(UiR.string.ui_could_not_read_apk_import_it_using_the_file)); return true } - if (!canImportAuthentication()) return true - settingsDialogs.confirm(getString(UiR.string.ui_import_authentication_resources_from_this_apk), getString(UiR.string.ui_wheelplay_reads_only_the_two_authentication_resources_from_the)) { - authenticationImport.import(apk = uri) - } - return true - } - - private fun setTeslaHttpEnabled(enabled: Boolean) { - if (enabled && TeslaHttpCompatibility.config(this).mode == TeslaHttpMode.VPN) { - if (awaitingTeslaVpnConsent) return - try { - val consent = CarPlayVpnService.prepare(this) - if (consent != null) { - awaitingTeslaVpnConsent = true - teslaVpnConsent.launch(consent) - render() - return - } - } catch (error: Exception) { - awaitingTeslaVpnConsent = false - toast(getString(UiR.string.error_vpn_permission_request, error.userMessage(this))) - render() - return - } - } - applyTeslaHttpEnabled(enabled) - } - - private fun applyTeslaHttpEnabled(enabled: Boolean) { - TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(enabled = enabled)) - if (!WebSession.running) startWebServiceIfReady() - handler.post { render(); refreshService() } - } - - // The car hotspot link needs the hotspot on; DiPlay only checks it (turning it on needs ADB-only permission). - private fun carHotspotOff(): Boolean = - AirPlayPersistence.loadWirelessHotspotMode(this) == WirelessHotspotMode.MANUAL && - com.shilapi.xcertplay.network.CarHotspotStatus.isEnabled(this) == false - - private fun carHotspotOffDialog() { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_hotspot_is_off)) - .setMessage(getString(UiR.string.hotspot_enable_message, AirPlayPersistence.loadManualHotspotSsid(this))) - .setPositiveButton(getString(UiR.string.ui_open_system_settings)) { _, _ -> openCarWifiSettings() } - .setNeutralButton(getString(UiR.string.ui_connect)) { _, _ -> connect(true) } - .setNegativeButton(getString(UiR.string.ui_cancel), null).show() - } - - // BYD maps the AOSP tether action to its own hotspot screen; other firmware falls back to Wi-Fi settings. - // BYD shows that screen as a dialog and closes it unless its own settings or the car home screen is on top, - // so the home screen goes first. - private fun openCarWifiSettings() { - val hotspot = Intent("com.android.settings.WIFI_TETHER_SETTINGS") - val target = packageManager.resolveActivity(hotspot, 0)?.activityInfo?.packageName - if (target == null) { - openSystem(Intent(Settings.ACTION_WIRELESS_SETTINGS)) - return - } - if (target == "com.byd.carsettings") { - runCatching { startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)) } - } - if (runCatching { startActivity(hotspot) }.isSuccess) return - openSystem(Intent(Settings.ACTION_WIRELESS_SETTINGS)) - } - - private fun selectedPhoneName() = if (DiPlayPreferences.phoneAddress(this) == null) getString(UiR.string.ui_not_selected) else DiPlayPreferences.phoneName(this) - - private fun readCertificateFile(uri: Uri): ByteArray = contentResolver.openInputStream(uri)?.use(LanTls::readImport) - ?: error(getString(UiR.string.ui_could_not_open_the_selected_file)) - - private fun configureWeb(success: String, action: () -> String?) { - if (webBusy) { toast(getString(UiR.string.ui_updating_settings_please_wait)); return } - webBusy = true - render() - Thread({ - val failure = runCatching(action).getOrElse { getString(UiR.string.error_web_settings_update, it.userMessage(this)) } - runOnUiThread { - webBusy = false - if (!isFinishing && !isDestroyed) { toast(failure ?: success); render(); refreshService() } - } - }, "web-configure").start() - } - - private fun choosePkcs12Identity(uri: Uri, password: CharArray) { - if (webBusy) { password.fill('\u0000'); toast(getString(UiR.string.ui_updating_https_please_wait)); return } - webBusy = true - Thread({ - var bytes: ByteArray? = null - val result = runCatching { - readCertificateFile(uri).also { bytes = it }.let { LanTls.pkcs12Aliases(it, password) } - } - runOnUiThread { - webBusy = false - fun clear() { bytes?.fill(0); password.fill('\u0000') } - if (isFinishing || isDestroyed) { clear(); return@runOnUiThread } - result.onFailure { clear(); toast(getString(UiR.string.ui_could_not_read_pkcs_12_check_the_file_and)) } - .onSuccess { aliases -> - val choose = { alias: String -> - textInput(getString(UiR.string.ui_private_key_password_leave_blank_to_use_file_password), "", true, onCancel = ::clear) { value -> - val keyPassword = if (value.isEmpty()) password.copyOf() else value.toCharArray() - configureWeb(getString(UiR.string.ui_pkcs_12_certificate_imported_reconnect_your_browser)) { - try { LanTls.preparePkcs12(bytes!!, password, alias, keyPassword).use { WebSession.installCertificate(this, it) } } - finally { clear(); keyPassword.fill('\u0000') } - } - } - } - if (aliases.size == 1) choose(aliases.single()) - else settingsDialogs.select(getString(UiR.string.ui_choose_a_pkcs_12_private_key_entry), aliases, ::clear) { index -> choose(aliases[index]) } - } - } - }, "https-pkcs12-read").start() - } - - private fun textInput(title: String, current: String, secret: Boolean, preserveWhitespace: Boolean = false, - onCancel: () -> Unit = {}, save: (String) -> Unit) { - settingsDialogs.input(title, current, secret, preserveWhitespace, onCancel, save) - } - - private fun connect(wireless: Boolean) { - DiPlayPreferences.saveHomeSource(this, ProjectionSourceIds.CARPLAY) - refreshHomeSource() - refreshAuthentication() - if (setupError != null) { toast(setupError!!); return } - if (wireless && carHotspotOff()) { carHotspotOffDialog(); return } - if (wireless && DiPlayPreferences.phoneAddress(this) == null) { - pendingWireless = true; choosePhone(); return - } - val preferences = getSharedPreferences("diplay", MODE_PRIVATE) - if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED && !preferences.getBoolean("notification_asked", false)) { - preferences.edit().putBoolean("notification_asked", true).apply() - notificationTransport = wireless - notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - return - } - val open = { - AirPlayPersistence.saveWirelessEnabled(this, wireless) - openProjection() - } - if (projectionSessions.hasSession()) projectionSessions.stop().whenComplete { _, failure -> runOnUiThread { - if (failure == null) open() - else toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) - } } else open() - } - private fun openProjection() { - navigate("service") - startActivity(Intent(this, CarPlayHostActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_NO_ANIMATION)) - overridePendingTransition(0, 0) - } - private fun choosePhone() { - if (Build.VERSION.SDK_INT >= 31 && checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { - bluetoothPermission.launch(Manifest.permission.BLUETOOTH_CONNECT); return - } - val adapter = getSystemService(BluetoothManager::class.java)?.adapter - if (adapter == null || !adapter.isEnabled) { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_turn_on_bluetooth)) - .setMessage(getString(UiR.string.ui_turn_on_bluetooth_on_this_android_device_and_pair)) - .setPositiveButton(getString(UiR.string.ui_open_bluetooth_settings)) { _, _ -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } - .setNegativeButton(getString(UiR.string.ui_later), null).show(); return - } - val devices = runCatching { adapter.bondedDevices.sortedBy { it.name ?: "" } }.getOrDefault(emptyList()) - if (devices.isEmpty()) { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_pair_your_iphone_first)) - .setMessage(getString(UiR.string.ui_pair_this_android_device_in_settings_bluetooth_on_your)) - .setPositiveButton(getString(UiR.string.ui_open_bluetooth_settings)) { _, _ -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } - .setNegativeButton(getString(UiR.string.ui_ok), null).show(); return - } - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_choose_iphone)) - .setItems(devices.map { device -> - val name = device.name ?: getString(UiR.string.ui_paired_device) - if (devices.count { it.name == device.name } > 1) "$name · ${device.address.takeLast(5)}" else name - }.toTypedArray()) { _, index -> - val device = devices[index] - DiPlayPreferences.savePhone(this, device.address, device.name ?: "iPhone") - val start = pendingWireless; pendingWireless = false - render() - if (start) connect(true) - }.setNeutralButton(getString(UiR.string.ui_pair_another_device)) { _, _ -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } - .setNegativeButton(getString(UiR.string.ui_cancel)) { _, _ -> pendingWireless = false }.show() - } - - private fun wirelessHelp() { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_wireless_connection_help)) - .setMessage(getString(UiR.string.ui_pair_the_iphone_with_this_device_over_bluetooth_keep)) - .setPositiveButton(getString(UiR.string.ui_ok), null) - .setNeutralButton(getString(UiR.string.ui_reset_carplay_wi_fi)) { _, _ -> - confirmWirelessReset() - }.show() - } - - private fun handleWirelessRecovery() { - if (intent.getStringExtra("page") != "wireless-recovery") return - intent.removeExtra("page") - navigate(SourcePage.CARPLAY.id) - confirmWirelessReset() - } - - private fun confirmWirelessReset() { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_reset_carplay_wi_fi_2)) - .setMessage(getString(UiR.string.ui_this_ends_the_current_wi_fi_direct_connection_including)) - .setPositiveButton(getString(UiR.string.ui_reset_and_connect)) { _, _ -> - CarPlayBackgroundSession.stop { runOnUiThread { resetWirelessGroup() } } - }.setNegativeButton(getString(UiR.string.ui_cancel), null).show() - } - - private fun resetWirelessGroup() { - val manager = getSystemService(android.net.wifi.p2p.WifiP2pManager::class.java) - if (manager == null) { toast(getString(UiR.string.ui_this_device_does_not_support_wi_fi_direct)); return } - val channel = manager.initialize(this, mainLooper, null) - try { - manager.requestGroupInfo(channel) { group -> - if (group == null) { channel.close(); connect(true); return@requestGroupInfo } - manager.removeGroup(channel, object : android.net.wifi.p2p.WifiP2pManager.ActionListener { - override fun onSuccess() { - val deadline = android.os.SystemClock.elapsedRealtime() + 4000 - fun waitUntilRemoved() { - manager.requestGroupInfo(channel) { remaining -> - when { - remaining == null -> { channel.close(); if (!isFinishing && !isDestroyed) connect(true) } - android.os.SystemClock.elapsedRealtime() >= deadline -> { - channel.close(); toast(getString(UiR.string.ui_wi_fi_direct_is_still_in_use_close_other)) - } - else -> handler.postDelayed({ waitUntilRemoved() }, 200) - } - } - } - waitUntilRemoved() - } - override fun onFailure(reason: Int) { channel.close(); toast(getString(UiR.string.ui_could_not_reset_wi_fi_direct_close_other_casting)) } - }) - } - } catch (_: SecurityException) { - channel.close(); permissionHelp(getString(UiR.string.ui_wireless_connection_permissions), getString(UiR.string.ui_allow_nearby_devices_permission_first_older_android_versions_also)) - } - } - - private fun refreshStatus() { - val desktop = desktopProvider() - val desktopState = desktop.probe() - desktopStatus?.text = if (desktop.preparing) getString(UiR.string.desktop_preparing) - else (desktopState as? SourceAvailability.Unavailable)?.reason - ?: desktop.lastFailure ?: getString(UiR.string.desktop_ready) - desktopStart?.isEnabled = !desktop.preparing && desktopState is SourceAvailability.Ready && - projectionSessions.snapshot()?.descriptor?.id != ProjectionSourceIds.ANDROID_DESKTOP - - pluginFailureLabels.forEach { (id, labels) -> - val failure = projectionPlugins(this).lastFailure(id) - labels.forEach { label -> - label.text = failure?.let { getString(UiR.string.projection_failed, it) }.orEmpty() - label.visibility = if (failure == null) View.GONE else View.VISIBLE - } - } - val projection = projectionSessions.snapshot() - sourceStatuses.forEach { (group, label) -> - val session = projection?.takeIf { sessionBelongsToSource(it, group) } - val state = projectionSessions.stateSnapshot() - val belongs = state.source?.id?.let { it == group || belongsToSource(it, group) } == true - label.text = when { - belongs && (state.failure != null || session == null) -> state.message(this) - session != null -> session.statusText ?: state.message(this) - else -> getString(UiR.string.projection_waiting) - } - sourceStopButtons[group]?.visibility = if (session != null || (belongs && projectionSessions.hasSession())) View.VISIBLE else View.GONE - } - val running = CarPlayBackgroundSession.hasSession() - status?.text = when { - projectionSessions.snapshot()?.descriptor?.id?.let { it != ProjectionSourceIds.CARPLAY } == true -> - projectionSessions.snapshot()?.descriptor?.displayName - setupError != null -> getString(UiR.string.ui_authentication_setup_needs_attention) - CarPlayBackgroundSession.active -> getString(UiR.string.ui_carplay_connected) - running -> getString(UiR.string.ui_connecting_to_iphone) - DiPlayPreferences.phoneAddress(this) != null -> getString(UiR.string.status_selected_phone, DiPlayPreferences.phoneName(this)) - else -> getString(UiR.string.ui_no_iphone_selected) - } - if (lastRunning != running) { - connectButton?.visibility = if (running) View.GONE else View.VISIBLE - disconnectButton?.visibility = if (running) View.VISIBLE else View.GONE - disconnectButton?.isEnabled = true - lastRunning = running - } - connectButton?.isEnabled = setupError == null && !DiPlayBootstrap.importing - refreshHomeSource() - } - private fun reportFileName() = "WheelPlay-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(Date())}.txt" - - private fun chooseReportDestination() { - // Some head units omit or disable DocumentsUI. Launch itself can throw, before - // the result callback and the background writer's exception handler ever run. - runCatching { export.launch(reportFileName()) }.onFailure { - toast(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - getString(UiR.string.ui_could_not_open_the_save_location_try_saving_to) - else getString(UiR.string.ui_no_file_picker_is_available_on_this_device)) - } - } - - private fun exportDiagnostics(uri: Uri? = null) { - if (exportInProgress) return - exportInProgress = true - refreshSettingsRuntime() - val appContext = applicationContext - val fileName = reportFileName() - Thread({ - val result = runCatching { - val report = buildString { - appendLine("WheelPlay ${version()} · private beta diagnostic report") - appendLine("Android ${Build.VERSION.RELEASE} / API ${Build.VERSION.SDK_INT}") - appendLine("Head unit: ${Build.MANUFACTURER} ${Build.MODEL}") - appendLine("Connection: ${if (AirPlayPersistence.loadWirelessEnabled(appContext)) "wireless" else "USB"}") - appendLine("Authentication: local experimental beta identity; no remote fallback") - appendLine("Saved video preference (may differ from active session): ${if (AirPlayPersistence.loadHevcEnabled(appContext)) "HEVC" else "H.264"}; ${AirPlayPersistence.loadFps(appContext)} fps") - appendLine("CarPlay size: ${com.shilapi.xcertplay.airplay.CarPlaySize.fromWidthMillimeters(AirPlayPersistence.loadWidthPhysicalMm(appContext)).name}") - appendLine("Saved resolution preference (may differ from active session): ${AirPlayPersistence.loadDisplayScaleTenths(appContext) * 10}%") - appendLine("Tesla compatibility: enabled=${TeslaHttpCompatibility.config(appContext).enabled}; mode=${TeslaHttpCompatibility.config(appContext).mode}; addressConfigured=${TeslaHttpCompatibility.status.address != null}; downstreams=${TeslaHttpCompatibility.status.downstreams.joinToString()}") - TeslaHttpCompatibility.status.error?.let { appendLine("Tesla compatibility status: $it") } - appendLine("Session: ${if (CarPlayBackgroundSession.active) "active" else if (CarPlayBackgroundSession.hasSession()) "connecting" else "stopped"}") - appendLine("Head-unit board: ${Build.BOARD}; hardware: ${Build.HARDWARE}; build: ${Build.DISPLAY}") - appendLine() - appendLine("--- Last display negotiation (timestamps distinguish it from current settings) ---") - appendLine(DisplayDiagnosticSnapshot.report(appContext)) - appendLine() - for (name in SessionLogFile.REPORT_NAMES) { - val file = File(appContext.filesDir, "logs/$name") - if (file.isFile) { - appendLine("--- $name ---") - file.useLines { lines -> lines.forEach { line -> DiagnosticRedactor.redact(line)?.let { appendLine(it) } } } - } - } - } - if (uri != null) DiagnosticExportStore.write(appContext.contentResolver, uri, report) - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - DiagnosticExportStore.saveToDownloads(appContext.contentResolver, fileName, report) - } else error("A save location is required") - } - runOnUiThread { - exportInProgress = false - if (isFinishing || isDestroyed) return@runOnUiThread - refreshSettingsRuntime() - if (result.isSuccess) { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_diagnostic_report_saved)) - .setMessage(if (uri == null) "Downloads/WheelPlay/$fileName" else getString(UiR.string.ui_report_saved_to_the_selected_location)) - .setPositiveButton(getString(UiR.string.ui_done), null).show() - } else { - MaterialAlertDialogBuilder(this).setTitle(getString(UiR.string.ui_could_not_save_report)) - .setMessage(getString(UiR.string.ui_check_storage_space_or_choose_another_save_location)) - .setPositiveButton(getString(UiR.string.ui_choose_location)) { _, _ -> chooseReportDestination() } - .setNegativeButton(getString(UiR.string.ui_close), null).show() - } - } - }, "diplay-export").start() - } - private fun permissionHelp(title: String, body: String) { - MaterialAlertDialogBuilder(this).setTitle(title).setMessage(body).setPositiveButton(getString(UiR.string.ui_app_settings)) { _, _ -> - openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) - }.setNegativeButton(getString(UiR.string.ui_later), null).show() - } - private fun openSystem(intent: Intent) { runCatching { startActivity(intent) }.onFailure { toast(getString(UiR.string.ui_open_this_option_in_the_device_s_system_settings)) } } - private fun toast(message: String) { com.google.android.material.snackbar.Snackbar.make(shell.root, message, com.google.android.material.snackbar.Snackbar.LENGTH_LONG).show() } - private fun version() = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.1.0-beta.1" - private fun column() = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; layoutParams = LinearLayout.LayoutParams(-1, -2) } - private fun label(value: String, size: Int, color: Int, bold: Boolean = false) = ui.text(value, size, color, bold) - private fun button(title: String, primary: Boolean, @androidx.annotation.DrawableRes iconRes: Int = 0, click: () -> Unit) = ui.button(title, primary, iconRes, click) - private fun matchButton(top: Int = 0) = LinearLayout.LayoutParams(-1, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(top) } - private fun space(height: Int) = View(this).apply { layoutParams = LinearLayout.LayoutParams(1, dp(height)) } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - companion object { - private val ACCENT = ServerUi.ACCENT - private val TEXT = ServerUi.TEXT - private val MUTED = ServerUi.MUTED - private val WARNING = ServerUi.WARNING - } -} diff --git a/common/src/main/java/com/shilapi/xcertplay/ImageCropActivity.kt b/common/src/main/java/com/shilapi/xcertplay/ImageCropActivity.kt deleted file mode 100644 index 30c41ff..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/ImageCropActivity.kt +++ /dev/null @@ -1,448 +0,0 @@ -package com.shilapi.xcertplay - -import android.app.Activity -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Matrix -import android.graphics.Paint -import android.graphics.RectF -import android.net.Uri -import android.os.Bundle -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView -import com.shilapi.xcertplay.shared.R as UiR -import java.io.ByteArrayOutputStream -import java.util.concurrent.Executors -import kotlin.math.ceil -import kotlin.math.floor -import kotlin.math.max -import kotlin.math.min - -/** Loads one local image and writes a user-positioned 1:1 PNG into the AirPlay icon slot. */ -class ImageCropActivity : Activity() { - private val executor = Executors.newSingleThreadExecutor() - private lateinit var cropView: SquareCropView - private lateinit var statusView: TextView - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val uri = intent.data - if (uri == null) { - finish() - return - } - - cropView = SquareCropView(this) - statusView = - TextView(this).apply { - setTextColor(Color.WHITE) - textSize = 16f - gravity = Gravity.CENTER - setPadding(dp(12), dp(8), dp(12), dp(8)) - text = getString(UiR.string.ui_reading_image) - } - - val controls = - LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.END or Gravity.CENTER_VERTICAL - setPadding(dp(16), dp(10), dp(16), dp(16)) - } - controls.addView( - Button(this).apply { - text = getString(UiR.string.ui_cancel) - isAllCaps = false - setOnClickListener { - setResult(RESULT_CANCELED) - finish() - } - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - controls.addView( - Button(this).apply { - text = getString(UiR.string.ui_save_icon) - isAllCaps = false - setOnClickListener { saveCrop() } - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - .apply { marginStart = dp(12) }, - ) - - val root = - FrameLayout(this).apply { - setBackgroundColor(Color.BLACK) - addView( - cropView, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) - addView( - statusView, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.TOP or Gravity.CENTER_HORIZONTAL, - ) - .apply { topMargin = dp(12) }, - ) - addView( - controls, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM, - ), - ) - } - setContentView(root) - - executor.execute { - val bitmap = - try { - decodeBitmap(uri) - } catch (_: Exception) { - null - } - runOnUiThread { - if (isFinishing || isDestroyed) { - bitmap?.recycle() - return@runOnUiThread - } - if (bitmap == null) { - statusView.text = - getString(UiR.string.ui_could_not_read_image_go_back_and_choose_a) - } else { - cropView.setBitmap(bitmap) - statusView.text = - getString(UiR.string.ui_drag_to_reposition_and_pinch_to_zoom_saved_as) - } - } - } - } - - override fun onDestroy() { - executor.shutdownNow() - cropView.bitmap?.recycle() - super.onDestroy() - } - - private fun saveCrop() { - val cropped = - cropView.cropToSquare() - ?: run { - statusView.text = getString(UiR.string.ui_image_is_not_ready_please_wait) - return - } - val encoded = - ByteArrayOutputStream().use { output -> - if (!cropped.compress(Bitmap.CompressFormat.PNG, 100, output)) { - cropped.recycle() - statusView.text = - getString(UiR.string.ui_could_not_process_image_go_back_and_choose_another) - return - } - cropped.recycle() - output.toByteArray() - } - try { - AirPlayPersistence.saveCustomAirPlayIcon(applicationContext, encoded) - setResult(RESULT_OK, Intent().putExtra(EXTRA_CROP_COMPLETE, true)) - finish() - } catch (_: Exception) { - statusView.text = getString(UiR.string.ui_could_not_save_icon_try_again) - } - } - - private fun decodeBitmap(uri: Uri): Bitmap? { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - - var sampleSize = 1 - while (max(bounds.outWidth, bounds.outHeight) / sampleSize > MAX_DECODE_EDGE) { - sampleSize *= 2 - } - val options = - BitmapFactory.Options().apply { - inSampleSize = sampleSize - inPreferredConfig = Bitmap.Config.ARGB_8888 - } - return contentResolver.openInputStream(uri)?.use { - BitmapFactory.decodeStream(it, null, options) - } - } - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - private class SquareCropView(context: android.content.Context) : View(context) { - private val imagePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) - private val dimPaint = Paint().apply { color = Color.argb(145, 0, 0, 0) } - private val borderPaint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - style = Paint.Style.STROKE - strokeWidth = 2f * resources.displayMetrics.density - } - private val gridPaint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.argb(150, 255, 255, 255) - style = Paint.Style.STROKE - strokeWidth = 1f * resources.displayMetrics.density - } - private val matrix = Matrix() - private val viewport = RectF() - private val mappedBitmap = RectF() - private var minimumScale = 1f - private var maximumScale = 1f - private var lastX = 0f - private var lastY = 0f - private var lastDistance = 0f - - var bitmap: Bitmap? = null - private set - - fun setBitmap(value: Bitmap) { - bitmap?.recycle() - bitmap = value - if (width > 0 && height > 0) configure() - invalidate() - } - - override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { - super.onSizeChanged(width, height, oldWidth, oldHeight) - configure() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val source = bitmap ?: return - canvas.drawColor(Color.BLACK) - canvas.drawRect(0f, 0f, width.toFloat(), viewport.top, dimPaint) - canvas.drawRect(0f, viewport.bottom, width.toFloat(), height.toFloat(), dimPaint) - canvas.drawRect(0f, viewport.top, viewport.left, viewport.bottom, dimPaint) - canvas.drawRect( - viewport.right, - viewport.top, - width.toFloat(), - viewport.bottom, - dimPaint, - ) - - val save = canvas.save() - canvas.clipRect(viewport) - canvas.drawBitmap(source, matrix, imagePaint) - canvas.restoreToCount(save) - - canvas.drawRect(viewport, borderPaint) - val thirdWidth = viewport.width() / 3f - val thirdHeight = viewport.height() / 3f - canvas.drawLine( - viewport.left + thirdWidth, - viewport.top, - viewport.left + thirdWidth, - viewport.bottom, - gridPaint, - ) - canvas.drawLine( - viewport.right - thirdWidth, - viewport.top, - viewport.right - thirdWidth, - viewport.bottom, - gridPaint, - ) - canvas.drawLine( - viewport.left, - viewport.top + thirdHeight, - viewport.right, - viewport.top + thirdHeight, - gridPaint, - ) - canvas.drawLine( - viewport.left, - viewport.bottom - thirdHeight, - viewport.right, - viewport.bottom - thirdHeight, - gridPaint, - ) - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - if (bitmap == null) return true - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - lastX = event.x - lastY = event.y - return true - } - - MotionEvent.ACTION_POINTER_DOWN -> { - lastDistance = pointerDistance(event) - return true - } - - MotionEvent.ACTION_MOVE -> { - if (event.pointerCount >= 2) { - val distance = pointerDistance(event) - if (lastDistance > 0f) { - val factor = distance / lastDistance - matrix.postScale( - factor, - factor, - (event.getX(0) + event.getX(1)) / 2f, - (event.getY(0) + event.getY(1)) / 2f, - ) - } - lastDistance = distance - } else { - matrix.postTranslate(event.x - lastX, event.y - lastY) - } - lastX = event.x - lastY = event.y - constrainMatrix() - invalidate() - return true - } - - MotionEvent.ACTION_POINTER_UP -> { - lastDistance = 0f - return true - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - lastDistance = 0f - performClick() - return true - } - } - return super.onTouchEvent(event) - } - - override fun performClick(): Boolean { - super.performClick() - return true - } - - fun cropToSquare(): Bitmap? { - val source = bitmap ?: return null - if (viewport.width() <= 0f || viewport.height() <= 0f) return null - val inverse = Matrix() - if (!matrix.invert(inverse)) return null - val sourceRect = RectF() - inverse.mapRect(sourceRect, viewport) - val left = floor(sourceRect.left).toInt().coerceIn(0, source.width - 1) - val top = floor(sourceRect.top).toInt().coerceIn(0, source.height - 1) - val right = ceil(sourceRect.right).toInt().coerceIn(left + 1, source.width) - val bottom = ceil(sourceRect.bottom).toInt().coerceIn(top + 1, source.height) - val square = min(right - left, bottom - top) - val centerX = (left + right) / 2 - val centerY = (top + bottom) / 2 - val cropLeft = (centerX - square / 2).coerceIn(0, source.width - square) - val cropTop = (centerY - square / 2).coerceIn(0, source.height - square) - val cropped = Bitmap.createBitmap(source, cropLeft, cropTop, square, square) - val output = - Bitmap.createBitmap(OUTPUT_ICON_PIXELS, OUTPUT_ICON_PIXELS, Bitmap.Config.ARGB_8888) - Canvas(output) - .drawBitmap( - cropped, - null, - RectF(0f, 0f, OUTPUT_ICON_PIXELS.toFloat(), OUTPUT_ICON_PIXELS.toFloat()), - imagePaint, - ) - if (cropped !== source) cropped.recycle() - return output - } - - private fun configure() { - val source = bitmap ?: return - if (width <= 0 || height <= 0) return - val side = - min(width, height).toFloat() - - 2f * CROP_PADDING_DP * resources.displayMetrics.density - if (side <= 0f) return - viewport.set( - (width - side) / 2f, - (height - side) / 2f, - (width + side) / 2f, - (height + side) / 2f, - ) - minimumScale = max(side / source.width, side / source.height) - maximumScale = minimumScale * MAX_ZOOM - matrix.reset() - matrix.postScale(minimumScale, minimumScale) - matrix.postTranslate( - viewport.centerX() - source.width * minimumScale / 2f, - viewport.centerY() - source.height * minimumScale / 2f, - ) - constrainMatrix() - invalidate() - } - - private fun constrainMatrix() { - val source = bitmap ?: return - val values = FloatArray(9) - matrix.getValues(values) - val scale = values[Matrix.MSCALE_X] - if (scale < minimumScale) { - matrix.postScale( - minimumScale / scale, - minimumScale / scale, - viewport.centerX(), - viewport.centerY(), - ) - } else if (scale > maximumScale) { - matrix.postScale( - maximumScale / scale, - maximumScale / scale, - viewport.centerX(), - viewport.centerY(), - ) - } - mappedBitmap.set(0f, 0f, source.width.toFloat(), source.height.toFloat()) - matrix.mapRect(mappedBitmap) - var dx = 0f - var dy = 0f - if (mappedBitmap.left > viewport.left) dx = viewport.left - mappedBitmap.left - if (mappedBitmap.right < viewport.right) dx = viewport.right - mappedBitmap.right - if (mappedBitmap.top > viewport.top) dy = viewport.top - mappedBitmap.top - if (mappedBitmap.bottom < viewport.bottom) dy = viewport.bottom - mappedBitmap.bottom - if (dx != 0f || dy != 0f) matrix.postTranslate(dx, dy) - } - - private fun pointerDistance(event: MotionEvent): Float { - if (event.pointerCount < 2) return 0f - val dx = event.getX(0) - event.getX(1) - val dy = event.getY(0) - event.getY(1) - return kotlin.math.sqrt(dx * dx + dy * dy) - } - - private companion object { - const val CROP_PADDING_DP = 24f - const val MAX_ZOOM = 8f - } - } - - companion object { - const val EXTRA_CROP_COMPLETE = "crop_complete" - private const val MAX_DECODE_EDGE = 2048 - private const val OUTPUT_ICON_PIXELS = 1024 - } -} diff --git a/common/src/main/java/com/shilapi/xcertplay/MainActivity.kt b/common/src/main/java/com/shilapi/xcertplay/MainActivity.kt deleted file mode 100644 index 0b72ab7..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/MainActivity.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.shilapi.xcertplay - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -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.material3.Button -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -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.unit.dp -import com.shilapi.xcertplay.mfi.MfiProtocolMajorResult -import com.shilapi.xcertplay.mfi.MfiSelfCheck -import com.shilapi.xcertplay.mfi.MfiSelfCheckResult -import com.shilapi.xcertplay.shared.R as UiR -import com.shilapi.xcertplay.transport.LinuxI2cTransport -import com.shilapi.xcertplay.ui.theme.XcertplayTheme -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -class MainActivity : ComponentActivity() { - private val executor: ExecutorService = Executors.newSingleThreadExecutor() - private var status by mutableStateOf(DiagnosticStatus.Idle) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - XcertplayTheme { - var devicePath by remember { mutableStateOf("/dev/i2c-1") } - Scaffold(modifier = Modifier.fillMaxSize()) { padding -> - Column( - modifier = Modifier.padding(padding).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(getString(UiR.string.ui_board_i2c_diagnostic)) - OutlinedTextField( - value = devicePath, - onValueChange = { devicePath = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text(getString(UiR.string.ui_linux_i2c_device)) }, - singleLine = true, - enabled = status !is DiagnosticStatus.Running, - ) - Button( - onClick = { runSelfCheck(devicePath) }, - enabled = status !is DiagnosticStatus.Running, - ) { - Text(getString(UiR.string.ui_run_mfi_self_check)) - } - Spacer(Modifier.height(4.dp)) - Text(status.message(this@MainActivity)) - Text( - getString( - UiR.string - .ui_ch341_requires_deployment_specific_vid_pid_configuration - ) - ) - } - } - } - } - } - - override fun onDestroy() { - executor.shutdownNow() - super.onDestroy() - } - - private fun runSelfCheck(devicePath: String) { - status = DiagnosticStatus.Running - executor.execute { - val next = - try { - LinuxI2cTransport.open(devicePath) - .use { MfiSelfCheck(it).run() } - .let { DiagnosticStatus.Result(it) } - } catch (error: LinkageError) { - DiagnosticStatus.Failure( - error.message ?: getString(UiR.string.ui_i2c_native_library_is_unavailable) - ) - } catch (error: Exception) { - DiagnosticStatus.Failure(error.message ?: error.javaClass.simpleName) - } - runOnUiThread { if (!isFinishing && !isDestroyed) status = next } - } - } -} - -private sealed class DiagnosticStatus { - data object Idle : DiagnosticStatus() - - data object Running : DiagnosticStatus() - - data class Result(val selfCheck: MfiSelfCheckResult) : DiagnosticStatus() - - data class Failure(val message: String) : DiagnosticStatus() - - fun message(context: android.content.Context): String = - when (this) { - Idle -> context.getString(UiR.string.ui_idle) - Running -> context.getString(UiR.string.ui_running) - is Failure -> context.getString(UiR.string.diagnostic_failure, message) - is Result -> { - val chip = - selfCheck.chip - ?: return if (selfCheck.discovery.interrupted) { - context.getString(UiR.string.ui_mfi_scan_interrupted) - } else { - context.getString(UiR.string.ui_found_none) - } - val major = - when (val result = chip.protocolMajor) { - is MfiProtocolMajorResult.Value -> "%d".format(result.major) - is MfiProtocolMajorResult.MfiFailure -> - result.error.message ?: result.error.javaClass.simpleName - is MfiProtocolMajorResult.TransportFailure -> - result.error.message ?: result.error.javaClass.simpleName - } - context.getString( - UiR.string.diagnostic_mfi_found, - chip.address7Bit, - chip.deviceVersion, - major, - ) - } - } -} diff --git a/common/src/main/java/com/shilapi/xcertplay/SafeAreaEditorView.kt b/common/src/main/java/com/shilapi/xcertplay/SafeAreaEditorView.kt deleted file mode 100644 index bcfe341..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/SafeAreaEditorView.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.shilapi.xcertplay - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.view.MotionEvent -import android.view.View -import com.shilapi.xcertplay.airplay.AirPlaySafeArea -import com.shilapi.xcertplay.airplay.SafeAreaRect - -/** Full-screen editor for the two horizontal and two vertical safe-area boundaries. */ -class SafeAreaEditorView(context: Context) : View(context) { - private enum class Edge { - LEFT, - TOP, - RIGHT, - BOTTOM, - } - - private val density = resources.displayMetrics.density - private val touchRadius = 40f * density - private val dimPaint = Paint().apply { color = Color.argb(118, 0, 0, 0) } - private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(127, 205, 154) - strokeWidth = 3f * density - style = Paint.Style.STROKE - } - private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.argb(210, 255, 255, 255) - strokeWidth = 1f * density - style = Paint.Style.STROKE - } - private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - textSize = 15f * density * resources.configuration.fontScale - typeface = android.graphics.Typeface.MONOSPACE - } - - private var rect: SafeAreaRect? = null - private var sourceRect: SafeAreaRect? = null - private var sourceWidth = 0 - private var sourceHeight = 0 - private var activeEdge: Edge? = null - - init { - isClickable = true - } - - fun setRect(value: SafeAreaRect, sourceWidthPixels: Int, sourceHeightPixels: Int) { - require(sourceWidthPixels > 0 && sourceHeightPixels > 0) { - "Source dimensions must be positive" - } - sourceWidth = sourceWidthPixels - sourceHeight = sourceHeightPixels - sourceRect = value - if (width > 0 && height > 0) { - rect = AirPlaySafeArea.scaleRect( - value, - sourceWidth, - sourceHeight, - width, - height, - ) - invalidate() - } - } - - fun currentRectForSource(): SafeAreaRect? { - sourceRect?.let { return it } - val current = rect ?: return null - if (sourceWidth <= 0 || sourceHeight <= 0 || width <= 0 || height <= 0) return null - return AirPlaySafeArea.scaleRect(current, width, height, sourceWidth, sourceHeight) - } - - override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { - super.onSizeChanged(width, height, oldWidth, oldHeight) - if (width <= 0 || height <= 0) return - val source = sourceRect ?: AirPlaySafeArea.default(width, height) - rect = if (sourceWidth > 0 && sourceHeight > 0) { - AirPlaySafeArea.scaleRect(source, sourceWidth, sourceHeight, width, height) - } else { - source.clampTo(width, height) - } - invalidate() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val safe = rect ?: return - val width = width.toFloat() - val height = height.toFloat() - val left = safe.left.toFloat() - val top = safe.top.toFloat() - val right = safe.right.toFloat() - val bottom = safe.bottom.toFloat() - - canvas.drawRect(0f, 0f, width, top, dimPaint) - canvas.drawRect(0f, bottom, width, height, dimPaint) - canvas.drawRect(0f, top, left, bottom, dimPaint) - canvas.drawRect(right, top, width, bottom, dimPaint) - - canvas.drawRect(left, top, right, bottom, borderPaint) - canvas.drawLine(left, 0f, left, height, linePaint) - canvas.drawLine(right, 0f, right, height, linePaint) - canvas.drawLine(0f, top, width, top, linePaint) - canvas.drawLine(0f, bottom, width, bottom, linePaint) - - drawLabel(canvas, "x=${safe.left}", left + 8f * density, top + 20f * density) - drawLabel(canvas, "x=${safe.right}", right + 8f * density, bottom - 8f * density) - drawLabel(canvas, "y=${safe.top}", left + 8f * density, top - 8f * density) - drawLabel(canvas, "y=${safe.bottom}", right - 88f * density, bottom + 20f * density) - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - val safe = rect ?: return true - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - activeEdge = nearestEdge(event.x, event.y, safe) - return true - } - - MotionEvent.ACTION_MOVE -> { - val edge = activeEdge ?: return true - val moved = moveEdge(safe, edge, event.x, event.y) - rect = moved - if (sourceWidth > 0 && sourceHeight > 0 && width > 0 && height > 0) { - sourceRect = AirPlaySafeArea.scaleRect( - moved, - width, - height, - sourceWidth, - sourceHeight, - ) - } - invalidate() - return true - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - activeEdge = null - performClick() - return true - } - } - return super.onTouchEvent(event) - } - - override fun performClick(): Boolean { - super.performClick() - return true - } - - private fun nearestEdge(x: Float, y: Float, safe: SafeAreaRect): Edge? { - val candidates = listOf( - Edge.LEFT to kotlin.math.abs(x - safe.left), - Edge.RIGHT to kotlin.math.abs(x - safe.right), - Edge.TOP to kotlin.math.abs(y - safe.top), - Edge.BOTTOM to kotlin.math.abs(y - safe.bottom), - ).filter { it.second <= touchRadius } - return candidates.minByOrNull { it.second }?.first - } - - private fun moveEdge(safe: SafeAreaRect, edge: Edge, x: Float, y: Float): SafeAreaRect { - val pointX = x.toInt().coerceIn(0, width) - val pointY = y.toInt().coerceIn(0, height) - return when (edge) { - Edge.LEFT -> safe.copy(left = pointX.coerceIn(0, safe.right - 1)) - Edge.TOP -> safe.copy(top = pointY.coerceIn(0, safe.bottom - 1)) - Edge.RIGHT -> safe.copy(right = pointX.coerceIn(safe.left + 1, width)) - Edge.BOTTOM -> safe.copy(bottom = pointY.coerceIn(safe.top + 1, height)) - } - } - - private fun drawLabel(canvas: Canvas, value: String, x: Float, y: Float) { - val horizontalMin = 4f * density - val horizontalMax = (width - textPaint.measureText(value) - 4f * density) - .coerceAtLeast(horizontalMin) - val verticalMin = textPaint.textSize + 4f * density - val verticalMax = (height - 4f * density).coerceAtLeast(verticalMin) - val safeX = x.coerceIn(horizontalMin, horizontalMax) - val safeY = y.coerceIn(verticalMin, verticalMax) - canvas.drawText(value, safeX, safeY, textPaint) - } -} diff --git a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt deleted file mode 100644 index df342b3..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt +++ /dev/null @@ -1,342 +0,0 @@ -package com.shilapi.xcertplay - -import com.shilapi.xcertplay.shared.R as UiR - -import android.content.Context -import android.graphics.Color -import android.view.View -import android.widget.LinearLayout -import android.widget.FrameLayout -import android.widget.ScrollView -import android.widget.TextView -import com.google.android.material.button.MaterialButton -import com.google.android.material.divider.MaterialDivider -import com.google.android.material.bottomnavigation.BottomNavigationView -import com.google.android.material.card.MaterialCardView -import com.shilapi.xcertplay.host.R -import com.google.android.material.textview.MaterialTextView - -/** Material 3 components. The shell and bottom navigation stay mounted across page changes. */ -internal class ServerUi(private val context: Context) { - fun dp(value: Int) = (value * context.resources.displayMetrics.density).toInt() - private val compactWidth get() = context.resources.configuration.screenWidthDp < 360 - private val pageGutter get() = maxOf(if (compactWidth) 16 else 24, - (context.resources.configuration.screenWidthDp - 760) / 2) - private val cardPadding get() = if (compactWidth) 16 else 20 - fun column() = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - fun text(value: String, size: Int = 16, color: Int = MUTED, bold: Boolean = false) = MaterialTextView(context).apply { - val appearance = when { - size >= 28 -> com.google.android.material.R.style.TextAppearance_Material3_HeadlineSmall - size >= 21 -> com.google.android.material.R.style.TextAppearance_Material3_TitleLarge - bold -> com.google.android.material.R.style.TextAppearance_Material3_TitleMedium - size <= 14 -> com.google.android.material.R.style.TextAppearance_Material3_BodySmall - else -> com.google.android.material.R.style.TextAppearance_Material3_BodyLarge - } - setTextAppearance(appearance); text = value; setTextColor(color) - } - fun button(title: String, primary: Boolean = false, @androidx.annotation.DrawableRes iconRes: Int = 0, action: () -> Unit): MaterialButton { - val layout = if (primary) com.shilapi.xcertplay.host.R.layout.server_button_filled - else com.shilapi.xcertplay.host.R.layout.server_button_outlined - return (android.view.LayoutInflater.from(context).inflate(layout, null) as MaterialButton).apply { - text = title - if (iconRes != 0) { - setIconResource(iconRes) - iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START - iconSize = dp(24) - iconPadding = dp(8) - } - setOnClickListener { action() } - } - } - fun secondaryButtonLayout(top: Int = 0) = LinearLayout.LayoutParams(-2, -2).apply { - gravity = android.view.Gravity.END - topMargin = dp(top) - } - - fun preference(title: String, value: String, action: () -> Unit) = PreferenceRow(title, value, action) - - /** A whole-row preference target; its value is separate from its stable title. */ - inner class PreferenceRow(private val title: String, value: String, action: () -> Unit) : LinearLayout(context) { - private val valueView = text(value, 16, MUTED).apply { - gravity = android.view.Gravity.END or android.view.Gravity.CENTER_VERTICAL - maxLines = 2 - ellipsize = android.text.TextUtils.TruncateAt.END - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - } - init { - orientation = HORIZONTAL - gravity = android.view.Gravity.CENTER_VERTICAL - minimumHeight = dp(72) - setPadding(0, dp(16), 0, dp(16)) - val attributes = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) - background = attributes.getDrawable(0) - attributes.recycle() - addView(text(title, 16, TEXT).apply { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LayoutParams(0, -2, 1f).apply { marginEnd = dp(16) }) - addView(valueView, LayoutParams(0, -2, 1f)) - addView(android.widget.ImageView(context).apply { - setImageResource(com.shilapi.xcertplay.host.R.drawable.ic_settings_chevron) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LayoutParams(dp(24), dp(24)).apply { marginStart = dp(8) }) - isFocusable = true - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES - setOnClickListener { action() } - setValue(value) - } - fun setValue(value: String) { - valueView.text = value - contentDescription = "$title,$value" - } - } - /** Compact trailing button; its state layer is clipped to a pill-shaped target. */ - inner class DisclosureRow(private val title: String, action: () -> Unit) : LinearLayout(context) { - private var expanded = false - private val arrow = android.widget.ImageView(context).apply { - setImageResource(R.drawable.ic_settings_chevron) - rotation = 90f - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - } - init { - orientation = HORIZONTAL - isBaselineAligned = false - gravity = android.view.Gravity.END or android.view.Gravity.CENTER_VERTICAL - minimumHeight = dp(48) - setPadding(dp(12), dp(8), dp(12), dp(8)) - val stateColor = com.google.android.material.color.MaterialColors.getColor(this, - com.google.android.material.R.attr.colorOnSurface) - fun pill(alpha: Int) = android.graphics.drawable.GradientDrawable().apply { - cornerRadius = dp(24).toFloat() - setColor(androidx.core.graphics.ColorUtils.setAlphaComponent(stateColor, alpha)) - } - val states = android.graphics.drawable.StateListDrawable().apply { - addState(intArrayOf(android.R.attr.state_pressed), pill(31)) - addState(intArrayOf(android.R.attr.state_focused), pill(31)) - addState(intArrayOf(android.R.attr.state_hovered), pill(20)) - addState(intArrayOf(), pill(0)) - } - background = android.graphics.drawable.RippleDrawable( - android.content.res.ColorStateList.valueOf(androidx.core.graphics.ColorUtils.setAlphaComponent(stateColor, 31)), - states, pill(255)) - addView(text(title, 14, MUTED).apply { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LayoutParams(-2, -2)) - addView(arrow, LayoutParams(dp(20), dp(20)).apply { marginStart = dp(4) }) - contentDescription = title - isFocusable = true - setOnClickListener { action() } - setExpanded(false) - } - override fun getAccessibilityClassName(): CharSequence = android.widget.Button::class.java.name - fun setExpanded(value: Boolean, animate: Boolean = false) { - val changed = expanded != value - expanded = value - androidx.core.view.ViewCompat.setStateDescription(this, if (value) context.getString(UiR.string.ui_expanded) else context.getString(UiR.string.ui_collapsed)) - if (!changed && animate) return - arrow.animate().cancel() - val angle = if (value) 270f else 90f - if (animate && changed && android.animation.ValueAnimator.areAnimatorsEnabled()) { - arrow.animate().rotation(angle).setDuration(200) - .setInterpolator(androidx.interpolator.view.animation.FastOutSlowInInterpolator()).start() - } else arrow.rotation = angle - } - } - fun gap(height: Int) = View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(height)) } - fun section(parent: LinearLayout, title: String, description: String? = null, content: (LinearLayout) -> Unit) { - parent.addView(text(title, 21, TEXT, true)) - if (description != null) parent.addView(text(description).apply { setPadding(0, dp(8), 0, 0) }) - val body = column().apply { setPadding(0, dp(16), 0, dp(24)) } - content(body) - // Rows already include touch-target padding; keep the visual group gaps consistent. - val firstInset = body.getChildAt(0)?.paddingTop ?: 0 - val lastInset = body.getChildAt(body.childCount - 1)?.paddingBottom ?: 0 - body.setPadding(0, (dp(16) - firstInset).coerceAtLeast(0), 0, - (dp(24) - lastInset).coerceAtLeast(0)) - parent.addView(body) - parent.addView(MaterialDivider(context), LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(24) }) - } - fun card(parent: LinearLayout, title: String, icon: Int, content: (LinearLayout) -> Unit) { - val body = column().apply { setPadding(dp(cardPadding), dp(20), dp(cardPadding), dp(20)) } - val heading = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - gravity = android.view.Gravity.CENTER_VERTICAL - } - heading.addView(android.widget.ImageView(context).apply { - setImageResource(icon); importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(dp(24), dp(24)).apply { marginEnd = dp(12) }) - heading.addView(text(title, 21, TEXT, true).apply { - includeFontPadding = false - setFallbackLineSpacing(false) - gravity = android.view.Gravity.CENTER_VERTICAL - }, LinearLayout.LayoutParams(0, -2, 1f)) - body.addView(heading); body.addView(gap(16)); content(body) - parent.addView(MaterialCardView(context).apply { - radius = dp(24).toFloat(); cardElevation = 0f; strokeWidth = 0 - setCardBackgroundColor(SURFACE) - addView(body, android.view.ViewGroup.LayoutParams(-1, -2)) - }, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(16) }) - } - - /** One accessible navigation target, with no nested settings or connection actions. */ - fun sourceCard(parent: LinearLayout, title: String, summary: String, icon: Int, action: () -> Unit) { - val row = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = android.view.Gravity.CENTER_VERTICAL - minimumHeight = dp(132) - setPadding(dp(cardPadding), dp(24), dp(cardPadding), dp(24)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - } - row.addView(android.widget.ImageView(context).apply { setImageResource(icon) }, - LinearLayout.LayoutParams(dp(40), dp(40)).apply { marginEnd = dp(16) }) - row.addView(column().apply { - addView(text(title, 22, TEXT, true)) - addView(text(summary, 14, MUTED).apply { setPadding(0, dp(8), 0, 0) }) - }, LinearLayout.LayoutParams(0, -2, 1f)) - row.addView(android.widget.ImageView(context).apply { setImageResource(R.drawable.ic_settings_chevron) }, - LinearLayout.LayoutParams(dp(24), dp(24)).apply { marginStart = dp(12) }) - parent.addView(object : MaterialCardView(context) { - override fun getAccessibilityClassName(): CharSequence = android.widget.Button::class.java.name - }.apply { - radius = dp(24).toFloat(); cardElevation = 0f; strokeWidth = 0 - setCardBackgroundColor(SURFACE) - contentDescription = "$title, $summary" - isFocusable = true - setOnClickListener { action() } - addView(row, android.view.ViewGroup.LayoutParams(-1, -2)) - }, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(16) }) - } - fun content(build: (LinearLayout) -> Unit): ScrollView { - val gutter = pageGutter - val body = column().apply { setPadding(dp(gutter), dp(24), dp(gutter), dp(32)) } - build(body) - // A trailing section divider and its group gap would duplicate the page bottom space. - if (body.getChildAt(body.childCount - 1) is MaterialDivider) { - body.removeViewAt(body.childCount - 1) - body.getChildAt(body.childCount - 1)?.let { last -> - last.setPadding(last.paddingLeft, last.paddingTop, last.paddingRight, 0) - } - } - return ScrollView(context).apply { - isFillViewport = true; clipToPadding = false - addView(body, android.view.ViewGroup.LayoutParams(-1, -2)) - } - } - inner class Shell(initialPage: String, private val onPageChanged: (String) -> Unit, - private val parentPages: Map = emptyMap()) { - val root = FrameLayout(context).apply { setBackgroundColor(BG) } - private val layout = column() - private var topInset = 0 - private val statusScrim = View(context).apply { - setBackgroundColor(Color.argb(224, 17, 22, 26)) - alpha = 0f - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - isClickable = false - } - val tabs = BottomNavigationView(context).apply { - labelVisibilityMode = com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED - minimumHeight = dp(80) - itemIconSize = dp(24) - setItemHorizontalTranslationEnabled(false) - setItemTextAppearanceActive(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium) - setItemTextAppearanceInactive(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium) - setItemTextAppearanceActiveBoldEnabled(false) - // Insets belong to the surrounding surface, never to the icon / label layout. - androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> insets } - } - private val navigationSurface = FrameLayout(context).apply { - val surface = com.google.android.material.color.MaterialColors.getColor( - tabs, com.google.android.material.R.attr.colorSurfaceContainer) - setBackgroundColor(surface) - tabs.setBackgroundColor(surface) - tabs.elevation = 0f - addView(tabs, FrameLayout.LayoutParams(-1, -2)) - } - private val container = FrameLayout(context) - private val pages = linkedMapOf() - private val ids = listOf("service", "phone", "settings") - private var syncingTabs = false - private fun tabId(page: String) = ids.indexOf(parentPages[page] ?: page).coerceAtLeast(0) + 1 - var selected = initialPage - private set - init { - root.addView(layout, FrameLayout.LayoutParams(-1, -1)) - root.addView(statusScrim, FrameLayout.LayoutParams(-1, 0, android.view.Gravity.TOP)) - ServerWindow.fitBottomNavigation(root, navigationSurface) { inset -> - topInset = inset - statusScrim.layoutParams = (statusScrim.layoutParams as FrameLayout.LayoutParams).apply { height = inset } - pages.values.forEach(::applyTopInset) - updateStatusScrim() - } - val icons = listOf(R.drawable.ic_server_launch, R.drawable.ic_server_phone, R.drawable.ic_server_settings) - listOf(context.getString(UiR.string.ui_start), context.getString(UiR.string.source_tab), context.getString(UiR.string.ui_settings)).forEachIndexed { index, title -> - tabs.menu.add(0, index + 1, index, title).setIcon(icons[index]) - } - // CJK fallback metrics otherwise enlarge only Chinese labels, shifting their baseline. - for (id in 1..3) { - val item = tabs.findViewById(id) - listOf(com.google.android.material.R.id.navigation_bar_item_small_label_view, - com.google.android.material.R.id.navigation_bar_item_large_label_view).forEach { labelId -> - item.findViewById(labelId)?.apply { - includeFontPadding = true - setFallbackLineSpacing(false) - minHeight = dp(20) - gravity = android.view.Gravity.CENTER - } - } - } - tabs.selectedItemId = tabId(initialPage) - layout.addView(container, LinearLayout.LayoutParams(-1, 0, 1f)) - layout.addView(navigationSurface, LinearLayout.LayoutParams(-1, -2)) - tabs.setOnItemSelectedListener { item -> if (!syncingTabs) select(ids[item.itemId - 1]); true } - tabs.setOnItemReselectedListener { item -> if (!syncingTabs) select(ids[item.itemId - 1]) } - - } - fun put(id: String, view: View, scrollPosition: Int? = null) { - val old = pages.put(id, view) - val scrollY = scrollPosition ?: old?.scrollY ?: 0 - if (old != null) container.removeView(old) - container.addView(view, FrameLayout.LayoutParams(-1, -1)) - view.visibility = if (id == selected) View.VISIBLE else View.GONE - applyTopInset(view) - view.setOnScrollChangeListener { _, _, _, _, _ -> - if (selected == id) updateStatusScrim() - } - updateStatusScrim() - view.post { view.scrollTo(0, scrollY); updateStatusScrim() } - } - private fun applyTopInset(view: View) { - // ScrollView draws through this padding, so content can pass behind the status bar. - view.setPadding(view.paddingLeft, topInset, view.paddingRight, view.paddingBottom) - } - private fun updateStatusScrim() { - statusScrim.alpha = ((pageScroll[selected] ?: pages[selected]?.scrollY ?: 0).toFloat() / dp(24)).coerceIn(0f, 1f) - } - private val pageScroll = mutableMapOf() - fun setPageScroll(id: String, offset: Int) { - pageScroll[id] = offset - if (selected == id) updateStatusScrim() - } - fun select(id: String) { - if ((id !in ids && id !in parentPages) || selected == id) return - selected = id - pages.forEach { (page, view) -> view.visibility = if (page == id) View.VISIBLE else View.GONE } - // Only the stock Material indicator animates; no Activity or whole-window transition. - if (tabs.selectedItemId != tabId(id)) { - syncingTabs = true - try { tabs.selectedItemId = tabId(id) } finally { syncingTabs = false } - } - updateStatusScrim() - onPageChanged(id) - } - } - companion object { - val BG = Color.rgb(17, 22, 26) - val SURFACE = Color.rgb(28, 36, 40) - val BORDER = Color.rgb(65, 73, 65) - val ACCENT = Color.rgb(180, 217, 177) - val TEXT = Color.rgb(226, 232, 227) - val MUTED = Color.rgb(193, 201, 190) - val WARNING = Color.rgb(239, 190, 144) - } -} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt b/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt index 4c3a25a..afd1fdb 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt @@ -3,7 +3,7 @@ package com.shilapi.xcertplay.projection import android.content.Context import android.graphics.Bitmap import android.view.Surface -import com.shilapi.xcertplay.AirPlayPersistence +import moe.feng.wheelplay.AirPlayPersistence import com.shilapi.xcertplay.media.AndroidMediaSink import com.shilapi.xcertplay.media.CompressedVideoFrame import com.shilapi.xcertplay.media.VideoCodec diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt b/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt index 8ef4ab6..a3e2678 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt @@ -1,6 +1,6 @@ package com.shilapi.xcertplay.projection -import com.shilapi.xcertplay.CarPlayBackgroundSession +import moe.feng.wheelplay.CarPlayBackgroundSession import java.util.concurrent.CompletableFuture /** The only projection adapter that knows about the legacy CarPlay controller. */ diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt index 936045a..b579e08 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt @@ -5,8 +5,8 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Handler import android.os.Looper -import com.shilapi.xcertplay.AirPlayPersistence -import com.shilapi.xcertplay.CarPlayMicrophoneSource +import moe.feng.wheelplay.AirPlayPersistence +import moe.feng.wheelplay.CarPlayMicrophoneSource import com.shilapi.xcertplay.androidauto.* import com.shilapi.xcertplay.projection.* import java.io.DataInputStream diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt b/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt index a571c48..a645fbc 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt @@ -3,7 +3,7 @@ package com.shilapi.xcertplay.projection.plugin import android.content.* import android.os.* import android.content.res.Configuration -import com.shilapi.xcertplay.AirPlayPersistence +import moe.feng.wheelplay.AirPlayPersistence import com.shilapi.xcertplay.projection.* import moe.feng.wheelplay.plugin.* import java.util.UUID diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/DisplaySettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/DisplaySettingsScreen.kt deleted file mode 100644 index 13c73cb..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/settings/DisplaySettingsScreen.kt +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.shilapi.xcertplay.* -import com.shilapi.xcertplay.airplay.CarPlaySize -import com.shilapi.xcertplay.host.R -import com.shilapi.xcertplay.web.RtcRecoveryPolicy -import com.shilapi.xcertplay.web.StreamTechnology - -@Composable -internal fun DisplaySettingsScreen(state: SettingsState, actions: SettingsActions) { - var editMargins by rememberSaveable { mutableStateOf(false) } - SettingsChoice( - state, - actions, - ChoiceSetting.APPEARANCE, - R.string.settings_appearance, - CarPlayAppearance.entries.map { settingsString(it.labelRes) }, - ) - SettingsDescription(R.string.settings_appearance_description) - SettingsChoice( - state, - actions, - ChoiceSetting.DISPLAY_SIZE, - R.string.settings_display_size, - CarPlaySize.entries.map { settingsString(it.labelRes) }, - ) - SettingsDescription(R.string.settings_display_size_description) - val margins = state.margins - val summary = - if (margins == null && state.legacySafeArea) - settingsString(R.string.settings_legacy_safe_area) - else if (margins == null || margins.isFullScreen) - settingsString(R.string.settings_fullscreen) - else - settingsString( - R.string.settings_safe_area_value, - margins.top, - margins.bottom, - margins.left, - margins.right, - ) - SettingsRow(settingsString(R.string.settings_safe_area), summary, "settings:safe-area") { - editMargins = true - } - SettingsDescription(R.string.settings_safe_area_description) - SettingsToggle( - state, - actions, - ToggleSetting.ADAPTIVE_SIZE, - R.string.settings_adaptive_size, - R.string.settings_adaptive_size_description, - ) - SettingsChoice( - state, - actions, - ChoiceSetting.RESOLUTION, - R.string.settings_resolution, - listOf( - settingsString(R.string.settings_original_resolution), - settingsString(R.string.settings_resolution_lower), - settingsString(R.string.settings_resolution_lowest), - ), - ) - SettingsChoice( - state, - actions, - ChoiceSetting.FPS, - R.string.settings_fps, - listOf(settingsString(R.string.settings_fps_30), settingsString(R.string.settings_fps_60)), - ) - SettingsChoice( - state, - actions, - ChoiceSetting.STREAM, - R.string.settings_stream, - StreamTechnology.entries.map { settingsString(it.labelRes) }, - ) - SettingsDescription(R.string.settings_stream_description) - SettingsChoice( - state, actions, ChoiceSetting.IMAGE_FORMAT, R.string.settings_image_format, - listOf("JPEG", "WebP"), - ) - SettingsDescription(R.string.settings_image_format_description) - SettingsTextPreference( - actions, TextSetting.IMAGE_QUALITY, R.string.settings_image_quality, - state.imageQuality.toString(), numeric = true, - ) - SettingsDescription(R.string.settings_image_quality_description) - SettingsChoice( - state, - actions, - ChoiceSetting.RECOVERY, - R.string.settings_recovery, - RtcRecoveryPolicy.entries.map { settingsString(it.labelRes) }, - ) - SettingsDescription(R.string.settings_recovery_description) - SettingsToggle( - state, - actions, - ToggleSetting.HEVC, - R.string.settings_hevc, - R.string.settings_hevc_description, - ) - SettingsToggle( - state, - actions, - ToggleSetting.RIGHT_HAND, - R.string.settings_right_hand, - R.string.settings_right_hand_description, - ) - if (editMargins) - SafeAreaDialog(margins ?: CarPlaySafeMargins(), { editMargins = false }) { - actions.margins(it) - } -} - -@Composable -private fun SafeAreaDialog( - initial: CarPlaySafeMargins, - onDismiss: () -> Unit, - onSave: (CarPlaySafeMargins) -> Unit, -) { - var top by rememberSaveable { mutableStateOf(initial.top.toString()) } - var bottom by rememberSaveable { mutableStateOf(initial.bottom.toString()) } - var left by rememberSaveable { mutableStateOf(initial.left.toString()) } - var right by rememberSaveable { mutableStateOf(initial.right.toString()) } - var error by remember { mutableStateOf(null) } - val fields = listOf(top, bottom, left, right) - val parsed = - runCatching { - fields.map { it.toInt() }.let { CarPlaySafeMargins(it[0], it[1], it[2], it[3]) } - } - .getOrNull() - val preview = parsed ?: initial - val background = MaterialTheme.colorScheme.surfaceContainer - val highlight = MaterialTheme.colorScheme.primary.copy(alpha = .4f) - val previewDescription = settingsString(R.string.settings_safe_area_preview) - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(settingsString(R.string.settings_safe_area)) }, - text = { - Column( - Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Canvas( - Modifier.fillMaxWidth().height(112.dp).semantics { - contentDescription = previewDescription - } - ) { - drawRect(background) - drawRect( - highlight, - Offset(size.width * preview.left / 100, size.height * preview.top / 100), - Size( - size.width * (100 - preview.left - preview.right) / 100, - size.height * (100 - preview.top - preview.bottom) / 100, - ), - ) - } - SettingsDescription(R.string.settings_safe_area_hint) - val labels = - listOf( - R.string.settings_margin_top, - R.string.settings_margin_bottom, - R.string.settings_margin_left, - R.string.settings_margin_right, - ) - val changes: List<(String) -> Unit> = - listOf({ top = it }, { bottom = it }, { left = it }, { right = it }) - fields.forEachIndexed { index, value -> - OutlinedTextField( - value, - { - changes[index](it) - error = null - }, - modifier = Modifier.fillMaxWidth(), - label = { Text(settingsString(labels[index])) }, - suffix = { Text("%") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - isError = error != null, - ) - } - error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - } - }, - confirmButton = { - val invalidMessage = settingsString(R.string.settings_safe_area_invalid) - TextButton( - onClick = { - if (parsed == null) error = invalidMessage - else { - onSave(parsed) - onDismiss() - } - } - ) { - Text(settingsString(R.string.settings_save)) - } - }, - dismissButton = { - Row { - TextButton( - onClick = { - onSave(CarPlaySafeMargins()) - onDismiss() - } - ) { - Text(settingsString(R.string.settings_restore_fullscreen)) - } - TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_cancel)) } - } - }, - ) -} diff --git a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Color.kt b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Color.kt deleted file mode 100644 index c40139c..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.shilapi.xcertplay.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Theme.kt b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Theme.kt deleted file mode 100644 index 1e43c3d..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Theme.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.shilapi.xcertplay.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - -@Composable -fun XcertplayTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Type.kt b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Type.kt deleted file mode 100644 index 879bc46..0000000 --- a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.shilapi.xcertplay.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/common/src/main/java/com/shilapi/xcertplay/web/BrowserMediaBridge.kt b/common/src/main/java/com/shilapi/xcertplay/web/BrowserMediaBridge.kt index ae0047a..80ed035 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/BrowserMediaBridge.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/BrowserMediaBridge.kt @@ -4,7 +4,7 @@ import android.os.Handler import android.os.Looper import android.os.SystemClock import com.shilapi.xcertplay.projection.projectionSessions -import com.shilapi.xcertplay.DiPlaySessionService +import moe.feng.wheelplay.WheelPlaySessionService import com.shilapi.xcertplay.media.CarPlayMediaSource import com.shilapi.xcertplay.media.MediaArtwork import com.shilapi.xcertplay.media.MediaCommand @@ -20,8 +20,8 @@ internal class BrowserMediaBridge( private val send: (String) -> Unit, private val currentSource: () -> CarPlayMediaSource? = { projectionSessions.snapshot()?.media }, private val observeSource: (() -> Unit) -> AutoCloseable = { listener -> projectionSessions.subscribe { listener() } }, - private val dispatch: (CarPlayMediaSource, Long, MediaCommand) -> CompletableFuture = DiPlaySessionService::browserMediaCommand, - private val dispatchSeek: (CarPlayMediaSource, Long, Long, Long) -> CompletableFuture = DiPlaySessionService::browserMediaSeek, + private val dispatch: (CarPlayMediaSource, Long, MediaCommand) -> CompletableFuture = WheelPlaySessionService::browserMediaCommand, + private val dispatchSeek: (CarPlayMediaSource, Long, Long, Long) -> CompletableFuture = WheelPlaySessionService::browserMediaSeek, private val main: Handler = Handler(Looper.getMainLooper()), private val clock: () -> Long = SystemClock::elapsedRealtime, ) : AutoCloseable { diff --git a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt index c9a686f..6256805 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt @@ -418,7 +418,7 @@ internal class LanWebServer( ) WebSession.frame?.takeIf { it.format == ImageFormat.JPEG || imageProtocol == 3 && imageFormat == ImageFormat.WEBP && - com.shilapi.xcertplay.AirPlayPersistence.loadImageFormat(context) == ImageFormat.WEBP } + moe.feng.wheelplay.AirPlayPersistence.loadImageFormat(context) == ImageFormat.WEBP } else null }, write = { super.sendFrame(it) }, @@ -477,7 +477,7 @@ internal class LanWebServer( rtcAnswered = false val id = ++rtcId val recovery = - com.shilapi.xcertplay.AirPlayPersistence.loadRtcRecoveryPolicy(context) + moe.feng.wheelplay.AirPlayPersistence.loadRtcRecoveryPolicy(context) val stream = RtcVideoStream( source, diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt index 7ce0d4a..d2488f8 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt @@ -4,18 +4,18 @@ import android.content.Context import android.os.Handler import android.os.Looper import android.os.SystemClock -import com.shilapi.xcertplay.AirPlayPersistence -import com.shilapi.xcertplay.BrowserOfflinePolicy -import com.shilapi.xcertplay.CarPlayAppearance +import moe.feng.wheelplay.AirPlayPersistence +import moe.feng.wheelplay.BrowserOfflinePolicy +import moe.feng.wheelplay.CarPlayAppearance import com.shilapi.xcertplay.projection.projectionSessions import com.shilapi.xcertplay.projection.ProjectionContact import com.shilapi.xcertplay.projection.ProjectionSession import com.shilapi.xcertplay.projection.ProjectionInput import com.shilapi.xcertplay.projection.message -import com.shilapi.xcertplay.CarPlayMicrophoneSource +import moe.feng.wheelplay.CarPlayMicrophoneSource import com.shilapi.xcertplay.host.R import com.shilapi.xcertplay.i18n.* -import com.shilapi.xcertplay.isDarkMode +import moe.feng.wheelplay.isDarkMode import com.shilapi.xcertplay.shared.R as UiR import java.security.SecureRandom diff --git a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt b/common/src/main/java/moe/feng/wheelplay/AirPlayPersistence.kt similarity index 95% rename from common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt rename to common/src/main/java/moe/feng/wheelplay/AirPlayPersistence.kt index 38b97e4..83fff25 100644 --- a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt +++ b/common/src/main/java/moe/feng/wheelplay/AirPlayPersistence.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.content.Context import android.os.Build @@ -125,10 +125,33 @@ object AirPlayPersistence { fun saveBrowserOfflinePolicy(context: Context, value: BrowserOfflinePolicy) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_BROWSER_OFFLINE, value.name).apply() } - fun loadSafeMargins(context: Context): CarPlaySafeMargins? = CarPlaySafeMargins.decode( - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SAFE_MARGINS, null)) - fun saveSafeMargins(context: Context, value: CarPlaySafeMargins) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_SAFE_MARGINS, value.encode()).apply() + fun loadSafeMargins(context: Context, widthPixels: Int = 1280, heightPixels: Int = 720): CarPlaySafeMargins? { + migrateGlobalSafeMargins(context) + return CarPlaySafeMargins.decode(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(safeMarginsKey(widthPixels, heightPixels), null)) + } + + fun saveSafeMargins(context: Context, value: CarPlaySafeMargins, widthPixels: Int = 1280, heightPixels: Int = 720) { + migrateGlobalSafeMargins(context) + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(safeMarginsKey(widthPixels, heightPixels), value.encode()).apply() + } + + private fun safeMarginsKey(widthPixels: Int, heightPixels: Int): String { + require(widthPixels > 0 && heightPixels > 0) + return "${KEY_SAFE_MARGINS}_${widthPixels}x${heightPixels}" + } + + @Synchronized + private fun migrateGlobalSafeMargins(context: Context) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val previous = prefs.getString(KEY_SAFE_MARGINS, null) ?: return + val key = safeMarginsKey(1280, 720) + val editor = prefs.edit().remove(KEY_SAFE_MARGINS) + if (!prefs.contains(key) && CarPlaySafeMargins.decode(previous) != null) { + editor.putString(key, previous) + } + editor.apply() } fun loadDisplayScaleTenths(context: Context): Int { @@ -566,7 +589,7 @@ object AirPlayPersistence { fun loadSafeAreaRect(context: Context, widthPixels: Int, heightPixels: Int): SafeAreaRect? { require(widthPixels > 0 && heightPixels > 0) { "Activity dimensions must be positive" } - loadSafeMargins(context)?.let { return it.rectangle(widthPixels, heightPixels) } + loadSafeMargins(context, widthPixels, heightPixels)?.let { return it.rectangle(widthPixels, heightPixels) } return SafeAreaCodec.decode( context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getString(safeAreaKey(widthPixels, heightPixels), null), @@ -583,8 +606,9 @@ object AirPlayPersistence { require(activityWidthPixels > 0 && activityHeightPixels > 0) { "Activity dimensions must be positive" } + migrateGlobalSafeMargins(context) val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() - .remove(KEY_SAFE_MARGINS) + .remove(safeMarginsKey(activityWidthPixels, activityHeightPixels)) .putString( safeAreaKey(activityWidthPixels, activityHeightPixels), SafeAreaCodec.encode(rect.clampTo(activityWidthPixels, activityHeightPixels)), @@ -601,8 +625,9 @@ object AirPlayPersistence { require(activityWidthPixels > 0 && activityHeightPixels > 0) { "Activity dimensions must be positive" } + migrateGlobalSafeMargins(context) val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() - .remove(KEY_SAFE_MARGINS) + .remove(safeMarginsKey(activityWidthPixels, activityHeightPixels)) .remove(safeAreaKey(activityWidthPixels, activityHeightPixels)) if (commit) editor.commit() else editor.apply() } diff --git a/common/src/main/java/com/shilapi/xcertplay/AuthenticationImportModel.kt b/common/src/main/java/moe/feng/wheelplay/AuthenticationImportModel.kt similarity index 95% rename from common/src/main/java/com/shilapi/xcertplay/AuthenticationImportModel.kt rename to common/src/main/java/moe/feng/wheelplay/AuthenticationImportModel.kt index ddfa500..c917649 100644 --- a/common/src/main/java/com/shilapi/xcertplay/AuthenticationImportModel.kt +++ b/common/src/main/java/moe/feng/wheelplay/AuthenticationImportModel.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.app.Application import android.content.Intent @@ -29,7 +29,7 @@ internal class AuthenticationImportModel(application: Application) : AndroidView ) return } - if (!DiPlayBootstrap.beginImport()) return + if (!WheelPlayBootstrap.beginImport()) return state.value = State(busy = true) val context = getApplication() thread(name = "authentication-import") { @@ -63,7 +63,7 @@ internal class AuthenticationImportModel(application: Application) : AndroidView else if (apk != null) getApplication() .getString( - UiR.string.ui_import_failed_use_a_diplay_apk_containing_identity_pk8 + UiR.string.ui_import_failed_use_an_upstream_apk_containing_identity_pk8 ) else getApplication() @@ -71,7 +71,7 @@ internal class AuthenticationImportModel(application: Application) : AndroidView UiR.string.ui_import_failed_choose_a_valid_matching_pkcs_8_private ) } finally { - DiPlayBootstrap.finishImport() + WheelPlayBootstrap.finishImport() } state.postValue(State(result = result)) } diff --git a/common/src/main/java/com/shilapi/xcertplay/BootReceiver.kt b/common/src/main/java/moe/feng/wheelplay/BootReceiver.kt similarity index 90% rename from common/src/main/java/com/shilapi/xcertplay/BootReceiver.kt rename to common/src/main/java/moe/feng/wheelplay/BootReceiver.kt index 7e96d1e..48a2350 100644 --- a/common/src/main/java/com/shilapi/xcertplay/BootReceiver.kt +++ b/common/src/main/java/moe/feng/wheelplay/BootReceiver.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.content.BroadcastReceiver import android.content.Context @@ -11,7 +11,7 @@ class BootReceiver : BroadcastReceiver() { if (intent.action != Intent.ACTION_BOOT_COMPLETED) return if (!AirPlayPersistence.loadAutoStartOnBoot(context)) return - val launch = Intent(context, DiPlayActivity::class.java).apply { + val launch = Intent(context, WheelPlayActivity::class.java).apply { addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayExperienceSettings.kt b/common/src/main/java/moe/feng/wheelplay/CarPlayExperienceSettings.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/CarPlayExperienceSettings.kt rename to common/src/main/java/moe/feng/wheelplay/CarPlayExperienceSettings.kt index 11d7696..9d0084d 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayExperienceSettings.kt +++ b/common/src/main/java/moe/feng/wheelplay/CarPlayExperienceSettings.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import com.shilapi.xcertplay.airplay.SafeAreaRect import com.shilapi.xcertplay.i18n.* diff --git a/common/src/main/java/moe/feng/wheelplay/CarPlayHostActivity.kt b/common/src/main/java/moe/feng/wheelplay/CarPlayHostActivity.kt new file mode 100644 index 0000000..d00574c --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/CarPlayHostActivity.kt @@ -0,0 +1,1451 @@ +package moe.feng.wheelplay + +import com.shilapi.xcertplay.projection.ProjectionSourceIds +import com.shilapi.xcertplay.projection.CarPlaySourceSession +import com.shilapi.xcertplay.projection.ProjectionResource +import com.shilapi.xcertplay.projection.projectionSessions + +import com.shilapi.xcertplay.shared.R as UiR +import com.shilapi.xcertplay.i18n.* + +import com.shilapi.xcertplay.web.WebSession +import com.shilapi.xcertplay.web.OffscreenVideoOutput +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.graphics.BitmapFactory +import android.media.MediaCodecList +import android.media.MediaFormat +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import moe.feng.wheelplay.ui.CarPlayConnectionDialog +import moe.feng.wheelplay.ui.CarPlayConnectionUiState +import moe.feng.wheelplay.ui.theme.WheelPlayTheme +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayDisplaySettings +import com.shilapi.xcertplay.airplay.AirPlayPhysicalSizeMm +import com.shilapi.xcertplay.airplay.CarPlayDisplayScale +import com.shilapi.xcertplay.airplay.CarPlayUiScale +import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.airplay.AirPlayIcon +import com.shilapi.xcertplay.airplay.AirPlaySafeArea +import com.shilapi.xcertplay.airplay.AirPlaySession +import com.shilapi.xcertplay.airplay.AirPlaySessionListener +import com.shilapi.xcertplay.airplay.CarPlayMediaEngine +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.location.AndroidCarPlayLocationProvider +import com.shilapi.xcertplay.media.AndroidMediaSink +import com.shilapi.xcertplay.network.CarPlayVpnService +import com.shilapi.xcertplay.orchestration.CarPlayController +import com.shilapi.xcertplay.orchestration.CarPlayRuntimeConfig +import com.shilapi.xcertplay.orchestration.CarPlayStatus +import com.shilapi.xcertplay.orchestration.CarPlayTransport +import com.shilapi.xcertplay.orchestration.ManualHotspotBand +import com.shilapi.xcertplay.orchestration.ManualHotspotSecurity +import com.shilapi.xcertplay.orchestration.MfiTarget +import com.shilapi.xcertplay.orchestration.WirelessHotspotMode +import com.shilapi.xcertplay.transport.Iap2IdentificationConfig +import com.shilapi.xcertplay.transport.Iap2LocationProvider +import com.shilapi.xcertplay.transport.UsbDeviceId +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * CarPlay protocol and permission host. A transparent window presents Compose connection dialogs + * over the console; video stays in the offscreen server output. The controller outlives the dialog. + * + * Apple devices are discovered by vendor ID; CH341 uses the configured VID/PID below. + */ +class CarPlayHostActivity : ComponentActivity() { + private var connectionUi by mutableStateOf(CarPlayConnectionUiState()) + private var connectionDialogVisible by mutableStateOf(true) + private var reconnectAttempts = 0 + private lateinit var airPlayIdentity: AirPlayIdentity + + // CH341 USB\VID_1A86&PID_5512&REV_0304 is the deployment-supplied bridge identity. + private fun createRuntimeConfig(): CarPlayRuntimeConfig = CarPlayRuntimeConfig( + mfiTarget = MfiTarget.LOCAL, + ch341Devices = if (mfiTarget == MfiTarget.USB_CH341) { + listOf(UsbDeviceId(0x1a86, 0x5512)) + } else { + emptyList() + }, + // The CP latches its I2C address from the RST level at its own power-up, so the host must + // not pulse RST before discovery. Driving D0 re-latches the part onto the alternate + // address (0x10), where the accessory certificate is not readable. Leave RST at its + // hardware pull (VCC -> 0x11) and let the scanner find the part with its certificate. + // Set this back to 0 to restore the D0 pulse. + ch341MfiResetGpio = null, + linuxI2cPath = if (mfiTarget == MfiTarget.I2C) mfiI2cPath.trim() else null, + remoteMfiServer = remoteMfiServer.trim().takeIf { it.isNotEmpty() }, + remoteMfiToken = remoteMfiToken.takeIf { it.isNotEmpty() }, + identification = Iap2IdentificationConfig( + name = "WheelPlay", + modelIdentifier = normalizedModel(), + manufacturer = normalizedManufacturer(), + serialNumber = "DIPLAY-" + WheelPlayBootstrap.deviceId(airPlayIdentity).replace(":", ""), + firmwareVersion = "0.1.0", + hardwareVersion = "1.0", + carPlayUsbInterfaceNumber = 3, + // Advertise the channel so a browser can opt in during an existing session. + // No fixes are supplied until browser consent or the Android GPS setting enables a source. + locationInformationEnabled = true, + ), + label = "WheelPlay", + hostName = "wheelplay-" + WheelPlayBootstrap.deviceId(airPlayIdentity).replace(":", "").lowercase(), + hostMac = WheelPlayBootstrap.deviceId(airPlayIdentity).split(":").map { it.toInt(16).toByte() }.toByteArray(), + wirelessBluetoothDeviceAddress = WheelPlayPreferences.phoneAddress(this), + transport = if (wirelessEnabled) CarPlayTransport.WIRELESS else CarPlayTransport.WIRED, + wirelessHotspotMode = wirelessHotspotMode, + manualHotspotSsid = manualHotspotSsid, + manualHotspotPassphrase = manualHotspotPassphrase, + manualHotspotBand = manualHotspotBand, + manualHotspotChannel = manualHotspotChannel, + manualHotspotSecurity = manualHotspotSecurity, + locationReportingEnabled = true, + ) + + private val vpnConsent = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + awaitingVpnConsent = false + if (result.resultCode == RESULT_OK) { + vpnReady = true + maybeStartCarPlay() + } else { + setLocalizedConnectionStage(UiText(UiR.string.host_vpn_consent_was_denied)) + connectionUi = connectionUi.copy(failed = true) + } + } + private val wirelessPermissions = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + awaitingWirelessPermissions = false + wirelessPermissionsReady = hasRequiredWirelessPermissions() + appendLog( + if (wirelessPermissionsReady) { + "Wireless startup permissions granted" + } else { + "Wireless startup permissions denied" + }, + ) + + if (!wirelessPermissionsReady) { + setLocalizedConnectionStage(UiText(UiR.string.host_allow_the_connection_permissions_to_continue)) + connectionUi = connectionUi.copy(failed = true) + } + maybeStartCarPlay() + } + private val manualHotspotChannelPermission = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + val granted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || + hasFineLocationPermission() + appendLog( + if (granted) { + "Hotspot channel scan permission granted" + } else { + "Hotspot channel scan permission denied; continuing with Auto" + }, + ) + if (!granted) { + AirPlayPersistence.saveManualHotspotChannelPermissionPromptDismissed(this, true) + } + + maybeStartCarPlay() + } + private val microphonePermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + microphoneAvailable = granted + microphonePermissionResolved = true + appendLog(if (granted) "Microphone permission granted" else "Microphone permission denied") + requestStartupPrerequisites() + } + private val locationPermission = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + awaitingLocationPermission = false + locationPermissionAvailable = hasFineLocationPermission() + if (locationPermissionAvailable) { + appendLog("Location permission granted") + } else if (locationReportingEnabled) { + locationReportingEnabled = false + AirPlayPersistence.saveLocationReportingEnabled(this, false) + val approximateOnly = + grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true + appendLog( + if (approximateOnly) { + "Precise location permission denied; location reporting disabled" + } else { + "Location permission denied; location reporting disabled" + }, + ) + } + if (!locationPermissionAvailable && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + wirelessHotspotMode == WirelessHotspotMode.MANUAL + ) { + // Don't immediately ask for the same denied permission again for optional channel detection. + manualHotspotChannelPermissionAsked = true + AirPlayPersistence.saveManualHotspotChannelPermissionPromptDismissed(this, true) + } + + requestStartupPrerequisites() + } + + private var sink: AndroidMediaSink? = null + private var controller: CarPlayController? = null + private var activeDisplaySize: DisplaySize? = null + private var pendingDisplaySize: DisplaySize? = null + private var displayScaleTenths = CarPlayDisplayScale.DEFAULT_TENTHS + private var uiScalePercent = CarPlayUiScale.DEFAULT + private var displayDiagnosticAttempt: String? = null + private var hevcEnabled = true + private var hevcSoftwareDecoderEnabled = false + private var advancedAudioChannelMappingSupported = false + private var advancedAudioChannelMapping = false + private var model = AirPlayPersistence.DEFAULT_MODEL + private var simulatedManufacturerName = AirPlayPersistence.DEFAULT_SIMULATED_MANUFACTURER_NAME + private var fps = AirPlayDisplaySettings.DEFAULT_FPS + private var streamTechnology = com.shilapi.xcertplay.web.StreamTechnology.WEBRTC + private var widthPhysicalMm = AirPlayDisplaySettings.DEFAULT_WIDTH_PHYSICAL_MM + private var physicalSizeBasis = AirPlayDisplaySettings.DEFAULT_PHYSICAL_SIZE_BASIS + private var maximumDetectedWidthPixels = 0 + private var maximumDetectedHeightPixels = 0 + private var rightHandDrive = false + private var safeAreaDrawOutside = true + private var locationReportingEnabled = false + private var locationPermissionAvailable = false + private var microphoneAvailable = false + private var microphonePermissionResolved = false + private var microphoneSource = CarPlayMicrophoneSource.ANDROID + private var wirelessEnabled = false + private var mfiTarget = MfiTarget.USB_CH341 + private var mfiI2cPath = AirPlayPersistence.DEFAULT_MFI_I2C_PATH + private var remoteMfiServer = "" + private var remoteMfiToken = "" + private var wirelessPermissionsReady = false + private var wirelessHotspotMode = WirelessHotspotMode.WIFI_P2P + private var manualHotspotChannelPermissionAsked = false + private var manualHotspotSsid = "" + private var manualHotspotPassphrase = "" + private var manualHotspotBand = ManualHotspotBand.AUTO + private var manualHotspotChannel = 0 + private var manualHotspotSecurity = ManualHotspotSecurity.OPEN + private var awaitingVpnConsent = false + private var awaitingWirelessPermissions = false + private var awaitingLocationPermission = false + private var vpnReady = false + private var darkMode = false + private var activeAirPlaySession: AirPlaySession? = null + private val activeScreenStreamTypes = mutableSetOf() + private var handshakeResetInProgress = false + private var restartGeneration = 0 + private var reconnectScheduled = false + private var automaticReconnectAllowed = true + private var sessionLog: SessionLogFile? = null + private val shuttingDown = AtomicBoolean(false) + private var shutdownComplete = false + private val shutdownCompletions = mutableListOf<() -> Unit>() + private val mainHandler = Handler(Looper.getMainLooper()) + private val teardownExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val applyDisplaySize = Runnable { + val size = pendingDisplaySize ?: return@Runnable + pendingDisplaySize = null + applyDisplaySize(size) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + showSystemBars() + if (projectionSessions.hasSession() && + projectionSessions.snapshot()?.descriptor?.id != ProjectionSourceIds.CARPLAY) { + showWheelPlayHome("home"); finish(); return + } + if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED") { + AirPlayPersistence.saveWirelessEnabled(this, false) + } + val authenticationReady = runCatching { WheelPlayBootstrap.ensure(this) }.isSuccess + if (!authenticationReady) { + // Do not retain a partially initialized host after USB/other external launches. + showWheelPlayHome("settings") + finish() + return + } + startForegroundService(Intent(this, WheelPlaySessionService::class.java)) + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + initializeSessionLog() + darkMode = isDarkMode(resources.configuration.uiMode) + advancedAudioChannelMappingSupported = + resources.getBoolean(R.bool.config_advanced_audio_channel_mapping) + airPlayIdentity = AirPlayPersistence.loadIdentity(this) + loadPersistedSettings() + locationPermissionAvailable = hasFineLocationPermission() + setContent { + WheelPlayTheme { + if (connectionDialogVisible) { + CarPlayConnectionDialog( + state = connectionUi, + onCancel = ::cancelConnection, + onBackground = ::returnToDashboard, + onSettings = { showWheelPlayHome("settings") }, + onResetWifi = { showWheelPlayHome("wireless-recovery") }, + onChannelPermission = ::resolveChannelPermissionPrompt, + ) + } + } + } + activeDisplaySize = DisplaySize(1280, 720) + WebSession.setBrowserViewportListener { width, height -> + mainHandler.post { scheduleBrowserDisplaySize(width, height) } + } + showSystemBars() + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + cancelConnection() + } + }, + ) + + appendLog( + "Host started; MFI target=${mfiTargetLabel(mfiTarget)}; transport=${if (wirelessEnabled) "wireless" else "wired"}", + ) + val reusedBackgroundSession = adoptBackgroundSession() + microphoneAvailable = + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + microphonePermissionResolved = microphoneAvailable + if (reusedBackgroundSession) { + dismissConnectedDialog() + } else if (AirPlayPersistence.loadMicrophoneSource(this) == CarPlayMicrophoneSource.DISABLED || + microphonePermissionResolved || WebSession.audio.microphoneRequested) { + microphonePermissionResolved = true + requestStartupPrerequisites() + } else { + microphonePermission.launch(Manifest.permission.RECORD_AUDIO) + } + } + + private fun loadPersistedSettings() { + microphoneSource = AirPlayPersistence.loadMicrophoneSource(this) + displayScaleTenths = AirPlayPersistence.loadDisplayScaleTenths(this) + // Size is now chosen only through CarPlaySize; ignore the canvas scale older builds stored. + uiScalePercent = CarPlayUiScale.DEFAULT + hevcEnabled = AirPlayPersistence.loadHevcEnabled(this) + hevcSoftwareDecoderEnabled = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + AirPlayPersistence.loadHevcSoftwareDecoderEnabled(this) + advancedAudioChannelMapping = + advancedAudioChannelMappingSupported && + AirPlayPersistence.loadAdvancedAudioChannelMapping(this) + model = AirPlayPersistence.loadModel(this) + simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) + fps = AirPlayPersistence.loadFps(this) + streamTechnology = AirPlayPersistence.loadStreamTechnology(this) + widthPhysicalMm = AirPlayPersistence.loadWidthPhysicalMm(this) + physicalSizeBasis = AirPlayPersistence.loadPhysicalSizeBasis(this) + AirPlayPersistence.loadMaximumDetectedDisplay(this).let { (width, height) -> + maximumDetectedWidthPixels = width + maximumDetectedHeightPixels = height + } + rightHandDrive = AirPlayPersistence.loadRightHandDrive(this) + safeAreaDrawOutside = AirPlayPersistence.loadSafeAreaDrawOutside(this) + locationReportingEnabled = AirPlayPersistence.loadLocationReportingEnabled(this) + locationPermissionAvailable = hasFineLocationPermission() + mfiTarget = AirPlayPersistence.loadMfiTarget(this) + mfiI2cPath = AirPlayPersistence.loadMfiI2cPath(this) + remoteMfiServer = AirPlayPersistence.loadRemoteMfiServer(this) + remoteMfiToken = AirPlayPersistence.loadRemoteMfiToken(this) + loadPersistedWirelessSettings() + manualHotspotChannelPermissionAsked = + AirPlayPersistence.loadManualHotspotChannelPermissionPromptDismissed(this) + wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions() + } + + private fun loadPersistedWirelessSettings() { + wirelessEnabled = AirPlayPersistence.loadWirelessEnabled(this) + wirelessHotspotMode = AirPlayPersistence.loadWirelessHotspotMode(this) + manualHotspotSsid = AirPlayPersistence.loadManualHotspotSsid(this) + manualHotspotPassphrase = AirPlayPersistence.loadManualHotspotPassphrase(this) + manualHotspotBand = AirPlayPersistence.loadManualHotspotBand(this) + manualHotspotChannel = AirPlayPersistence.loadManualHotspotChannel(this) + manualHotspotSecurity = AirPlayPersistence.loadManualHotspotSecurity(this) + } + + private fun requestStartupPrerequisites() { + if (isFinishing || shuttingDown.get()) return + if (locationReportingEnabled && !locationPermissionAvailable) { + requestLocationPermission() + return + } + if (wirelessEnabled) { + requestWirelessPermissions() + } else { + requestVpnConsent() + } + } + + private fun requestLocationPermission() { + if (locationPermissionAvailable || awaitingLocationPermission) return + awaitingLocationPermission = true + locationPermission.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + + private fun hasFineLocationPermission(): Boolean = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + /** Precise location is optional on Android 13+: it only enables Wi-Fi scan-result channel detection. */ + private fun requestManualHotspotChannelPermissionIfNeeded(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + wirelessHotspotMode != WirelessHotspotMode.MANUAL || + hasFineLocationPermission() || manualHotspotChannelPermissionAsked + ) return false + + manualHotspotChannelPermissionAsked = true + connectionUi = connectionUi.copy(requestChannelPermission = true) + return true + } + + private fun resolveChannelPermissionPrompt(allow: Boolean) { + if (!connectionUi.requestChannelPermission || shuttingDown.get()) return + connectionUi = connectionUi.copy(requestChannelPermission = false) + if (allow) { + manualHotspotChannelPermission.launch(arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + )) + } else { + AirPlayPersistence.saveManualHotspotChannelPermissionPromptDismissed(this, true) + appendLog("Hotspot channel scan skipped; continuing with Auto") + + maybeStartCarPlay() + } + } + + private fun requestVpnConsent() { + val consent = CarPlayVpnService.prepare(this) + if (consent == null) { + vpnReady = true + maybeStartCarPlay() + } else { + awaitingVpnConsent = true + vpnConsent.launch(consent) + } + } + + private fun requestWirelessPermissions() { + val permissions = requiredWirelessPermissions() + if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) { + wirelessPermissionsReady = true + + maybeStartCarPlay() + return + } + wirelessPermissionsReady = false + + awaitingWirelessPermissions = true + wirelessPermissions.launch(permissions.toTypedArray()) + } + + private fun hasRequiredWirelessPermissions(): Boolean = + requiredWirelessPermissions().all { + checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + private fun requiredWirelessPermissions(): List = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> listOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> listOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + else -> listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (shuttingDown.get()) { + // A failed startup may leave its error dialog in the history after cleanup. Its + // executor and generation are closed; a new connection needs a fresh host. + finish() + shutdown(false, "Replacing stopped connection host") { + startActivity(Intent(intent).setClass(this, CarPlayHostActivity::class.java)) + } + return + } + setIntent(intent) + connectionDialogVisible = true + if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED" && wirelessEnabled) { + shutdown(false, "switching to USB") { + AirPlayPersistence.saveWirelessEnabled(this, false) + startActivity(Intent(this, CarPlayHostActivity::class.java)) + } + finish() + } + } + + override fun onResume() { + super.onResume() + if (isFinishing || shuttingDown.get()) return + var transportChanged = false + if (!isFinishing && !shuttingDown.get() && controller == null && + !handshakeResetInProgress + ) { + // The retained connection host can return from settings before a controller exists. + // Refresh the backend and its credentials before any startup/permission checks. + val previousWireless = wirelessEnabled + loadPersistedWirelessSettings() + transportChanged = previousWireless != wirelessEnabled + locationReportingEnabled = AirPlayPersistence.loadLocationReportingEnabled(this) + val selectedMicrophone = AirPlayPersistence.loadMicrophoneSource(this) + if (microphoneSource != selectedMicrophone) { + microphoneSource = selectedMicrophone + microphonePermissionResolved = selectedMicrophone == CarPlayMicrophoneSource.DISABLED || + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED || + WebSession.audio.microphoneRequested + if (!microphonePermissionResolved) microphonePermission.launch(Manifest.permission.RECORD_AUDIO) + } + } + locationPermissionAvailable = hasFineLocationPermission() + if (locationReportingEnabled && !locationPermissionAvailable) { + requestLocationPermission() + } + wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions() + + if (transportChanged && microphonePermissionResolved && !awaitingVpnConsent && + !awaitingWirelessPermissions && !awaitingLocationPermission + ) { + requestStartupPrerequisites() + } else { + maybeStartCarPlay() + } + showSystemBars() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) showSystemBars() + } + + override fun onStop() { + // The controller, USB/iAP2 link, and VPN attachment intentionally outlive the UI. + super.onStop() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val nextDarkMode = isDarkMode(newConfig.uiMode) + if (nextDarkMode != darkMode) { + darkMode = nextDarkMode + syncAirPlayDarkMode() + } + showSystemBars() + } + + override fun onDestroy() { + WebSession.setBrowserViewportListener(null) + mainHandler.removeCallbacks(applyDisplaySize) + sessionLog?.append("Activity destroyed") + sessionLog?.close() + sessionLog = null + super.onDestroy() + } + + override fun onPostResume() { + super.onPostResume() + dismissConnectedDialog() + } + + private fun dismissConnectedDialog() { + if (connectionDialogVisible && !connectionUi.stopping && CarPlayBackgroundSession.active && + lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + returnToDashboard() + } + } + + private fun returnToDashboard() = showWheelPlayHome(intent.getStringExtra("return_page") ?: "service") + + private fun cancelConnection() { + if (connectionUi.stopping || isFinishing) return + connectionUi = connectionUi.copy(stopping = true, requestChannelPermission = false) + automaticReconnectAllowed = false + val done = { + returnToDashboard() + finish() + } + if (!shuttingDown.get() && controller == null && !CarPlayBackgroundSession.isOwner(this)) { + // Permission/setup cancellation must not clear another input source's reservation. + shuttingDown.set(true) + restartGeneration += 1 + teardownExecutor.shutdown() + shutdownComplete = true + done() + } else { + shutdown(false, "Connection cancelled", done) + } + } + + private fun mfiTargetLabel(target: MfiTarget): String = when (target) { + MfiTarget.LOCAL -> getString(UiR.string.host_local_offline) + MfiTarget.USB_CH341 -> "USB/CH341" + MfiTarget.I2C -> "I2C" + MfiTarget.REMOTE -> getString(UiR.string.host_remote) + } + + private data class CanvasSupport(val supported: Boolean, val reason: String, val details: String) + + private fun largerCanvasSupport(display: AirPlayDisplayConfig): CanvasSupport = try { + val mime = if (hevcEnabled) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC + // Match MediaCodec.createDecoderByType's first suitable decoder; do not silently force + // an enlarged stream through a software decoder on a slower head unit. + val decoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.firstOrNull { + !it.isEncoder && it.supportedTypes.any { type -> type.equals(mime, ignoreCase = true) } + } + if (decoder == null) { + CanvasSupport(false, "no_decoder", "Decoder capability mime=$mime result=no_decoder") + } else { + val hardware = if (Build.VERSION.SDK_INT >= 29) decoder.isHardwareAccelerated + else !decoder.name.startsWith("OMX.google.") && !decoder.name.startsWith("c2.android.") + val video = decoder.getCapabilitiesForType(mime).videoCapabilities + val sizeSupported = video?.isSizeSupported(display.widthPixels, display.heightPixels) == true + val rateSupported = sizeSupported && video?.areSizeAndRateSupported( + display.widthPixels, display.heightPixels, display.fps.toDouble()) == true + val reason = when { + !hardware -> "software_decoder" + hevcEnabled && hevcSoftwareDecoderEnabled -> "software_hevc_selected" + video == null -> "no_video_capabilities" + !sizeSupported -> "canvas_dimensions_unsupported" + !rateSupported -> "frame_rate_unsupported" + else -> "supported" + } + CanvasSupport(reason == "supported", reason, + "Decoder capability codec=${decoder.name} mime=$mime hardware=$hardware sizeSupported=$sizeSupported rateSupported=$rateSupported " + + "widths=${video?.supportedWidths} heights=${video?.supportedHeights} alignment=${video?.widthAlignment}x${video?.heightAlignment} " + + "fpsRange=${video?.supportedFrameRates} result=$reason") + } + } catch (error: Exception) { + CanvasSupport(false, "capability_query_${error.javaClass.simpleName}", + "Decoder capability query failed error=${error.javaClass.simpleName}") + } + + private fun createAirPlayConfig(size: DisplaySize): AirPlayConfig { + val physical = resolvePhysicalSize(size) + val baseDisplay = AirPlayDisplayConfig( + widthPixels = size.width, + heightPixels = size.height, + widthPhysicalMm = physical.widthMm, + heightPhysicalMm = physical.heightMm, + fps = fps, + ) + val resolutionDisplay = CarPlayDisplayScale.apply(baseDisplay, displayScaleTenths) + val requestedPercent = uiScalePercent + var scaledDisplay = CarPlayUiScale.apply(resolutionDisplay, uiScalePercent) + val candidate = scaledDisplay + val support = when { + uiScalePercent >= CarPlayUiScale.DEFAULT -> CanvasSupport(true, "not_enlarging", "Decoder capability enlargement check not required") + scaledDisplay === resolutionDisplay -> CanvasSupport(false, "canvas_4k_limit", "Decoder capability check skipped: canvas exceeds enlargement limit") + else -> largerCanvasSupport(scaledDisplay) + } + if (!support.supported) { + scaledDisplay = resolutionDisplay + uiScalePercent = CarPlayUiScale.DEFAULT + AirPlayPersistence.saveUiScalePercent(this, uiScalePercent) + appendLog("Larger CarPlay canvas unavailable reason=${support.reason}; using Default icon and text size") + runOnUiThread { + android.widget.Toast.makeText(this, + getString(UiR.string.host_this_head_unit_cannot_use_the_smaller_size_at), + android.widget.Toast.LENGTH_LONG).show() + } + } + appendLog("CarPlay size=${CarPlayUiScale.label(uiScalePercent)} canvas=${scaledDisplay.widthPixels}x${scaledDisplay.heightPixels}") + val display = scaledDisplay.copy( + safeArea = AirPlaySafeArea.toInsets( + mapping = AirPlayPersistence.loadSafeAreaRect(this, size.width, size.height), + activityWidthPixels = size.width, + activityHeightPixels = size.height, + displayWidthPixels = scaledDisplay.widthPixels, + displayHeightPixels = scaledDisplay.heightPixels, + ), + safeAreaDrawOutside = if (AirPlayPersistence.loadSafeMargins(this, size.width, size.height)?.isFullScreen == false) false else safeAreaDrawOutside, + ) + val requestSummary = "Display request selected=${CarPlayUiScale.label(requestedPercent)} percent=$requestedPercent surface=${size.width}x${size.height} resolution=${displayScaleTenths * 10}% " + + "base=${resolutionDisplay.widthPixels}x${resolutionDisplay.heightPixels} candidate=${candidate.widthPixels}x${candidate.heightPixels} fps=$fps " + + "codec=${if (hevcEnabled) "HEVC" else "H.264"} softwareHevc=$hevcSoftwareDecoderEnabled" + val effectiveSummary = "Display effective percent=$uiScalePercent canvas=${display.widthPixels}x${display.heightPixels} decision=${support.reason} " + + "physical=${physical.widthMm}x${physical.heightMm}mm safeArea=${display.safeArea} drawOutside=${display.safeAreaDrawOutside}" + displayDiagnosticAttempt = DisplayDiagnosticSnapshot.begin(this, requestSummary, support.details, effectiveSummary) + appendLog(requestSummary) + appendLog(support.details) + appendLog(effectiveSummary) + return AirPlayConfig( + deviceName = "WheelPlay", + deviceId = WheelPlayBootstrap.deviceId(airPlayIdentity), + btMac = WheelPlayBluetooth.localAddress(this) ?: WheelPlayBootstrap.deviceId(airPlayIdentity), + sourceVersion = "950.7.1", + main = display, + rightHandDrive = rightHandDrive, + hevc = hevcEnabled, + microphone = AirPlayPersistence.loadMicrophoneSource(this) != CarPlayMicrophoneSource.DISABLED, + microphoneOpus = com.shilapi.xcertplay.media.OpusEncoderSupport.isAvailable(), + manufacturer = normalizedManufacturer(), + model = normalizedModel(), + oemLabel = normalizedManufacturer(), + icons = listOf(loadAirPlayIcon()), + ) + } + + private fun loadAirPlayIcon(): AirPlayIcon { + val customBytes = try { + AirPlayPersistence.loadCustomAirPlayIconFile(this)?.readBytes() + } catch (_: Exception) { + null + } + if (customBytes != null) { + decodeAirPlayIcon(customBytes)?.let { return it } + AirPlayPersistence.clearCustomAirPlayIcon(this) + } + return decodeAirPlayIcon(defaultAirPlayIconBytes()) + ?: throw IllegalStateException("Packaged AirPlay icon is invalid") + } + + private fun decodeAirPlayIcon(encoded: ByteArray): AirPlayIcon? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(encoded, 0, encoded.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0 || + bounds.outWidth != bounds.outHeight + ) { + return null + } + return AirPlayIcon(bounds.outWidth, bounds.outHeight, encoded) + } + + private fun defaultAirPlayIconBytes(): ByteArray = + // Shown in CarPlay's app list as the "back to the car" button. + resources.openRawResource(R.raw.ic_car_home).use { it.readBytes() } + + private fun resolvePhysicalSize(size: DisplaySize): AirPlayPhysicalSizeMm = + AirPlayDisplaySettings.resolvePhysicalSizeMm( + currentWidthPixels = size.width, + currentHeightPixels = size.height, + // The browser canvas fills its display at every pixel resolution. Treating it as + // a fraction of a previous viewport shrinks the reported screen and enlarges iOS UI. + maximumWidthPixels = size.width, + maximumHeightPixels = size.height, + referenceMillimeters = widthPhysicalMm, + basis = physicalSizeBasis, + ) + + private fun normalizedManufacturer(): String = + simulatedManufacturerName.trim().ifBlank { AirPlayPersistence.DEFAULT_SIMULATED_MANUFACTURER_NAME } + + private fun normalizedModel(): String = + model.trim().ifBlank { AirPlayPersistence.DEFAULT_MODEL } + + private fun createMediaSink( + videoWidth: Int, + videoHeight: Int, + controllerGeneration: Int, + ): AndroidMediaSink { + // Capture this session's log: late decoder shutdown must not write into a new session. + val diagnosticLog = sessionLog + val videoGeneration = WebSession.beginVideo(videoWidth, videoHeight) + val source = com.shilapi.xcertplay.web.WebVideoSource(streamTechnology) + WebSession.attachVideoSource(videoGeneration, source) + val output = OffscreenVideoOutput(videoWidth, videoHeight, + wanted = { WebSession.hasViewer && source.jpegNeeded }, + encoding = { AirPlayPersistence.imageEncoding(this, WebSession.imageFormat) }, + publish = { bytes, format, quality -> WebSession.publish(videoGeneration, bytes, format, quality) }) + return AndroidMediaSink( + surface = output.surface, + encodedVideo = source, + frameDecoderFactory = { key -> + try { + com.shilapi.xcertplay.web.NativeVideoDecoder(key, platform = true) + .also { source.setProcessorName(it.name) } + } catch (_: LinkageError) { null } + }, + remoteAudio = WebSession.audio.createRoute(), + onClosed = { output.closeAndAwait(); WebSession.endVideo(videoGeneration) }, + videoWidth = videoWidth, + videoHeight = videoHeight, + preferSoftwareHevcDecoder = hevcSoftwareDecoderEnabled, + advancedAudioChannelMapping = advancedAudioChannelMapping, + onScreenStreamActiveChanged = { type, active -> + onScreenStreamStateChanged(controllerGeneration, type, active) + }, + mediaBufferMillis = AirPlayPersistence.loadMediaBufferMillis(this), + onAudioDiagnostic = { message -> + diagnosticLog?.append(formattedLogLine(message, System.currentTimeMillis())) + }, + ) + } + + private fun createMediaEngine(sink: AndroidMediaSink): CarPlayMediaEngine = + CarPlayMediaEngine( + sink = sink, + microphoneEnabled = AirPlayPersistence.loadMicrophoneSource(this) != CarPlayMicrophoneSource.DISABLED, + audioCaptureDirectory = audioCaptureDirectory(), + ) + + private fun createSessionListener(controllerGeneration: Int): AirPlaySessionListener = + object : AirPlaySessionListener { + override fun onSessionActive(session: AirPlaySession) { + runOnUiThread { + if (controllerGeneration != restartGeneration) { + return@runOnUiThread + } + activeAirPlaySession = session + CarPlayBackgroundSession.active = true + reconnectAttempts = 0 + syncAirPlayDarkMode() + appendLog("AirPlay session active") + dismissConnectedDialog() + } + } + + override fun onSessionEnded(session: AirPlaySession) { + runOnUiThread { + if (controllerGeneration != restartGeneration) return@runOnUiThread + if (activeAirPlaySession === session) activeAirPlaySession = null + CarPlayBackgroundSession.active = false + WebSession.videoActive = false + if (controllerGeneration != restartGeneration) { + return@runOnUiThread + } + activeScreenStreamTypes.clear() + WebSession.videoActive = false + setLocalizedConnectionStage(UiText(UiR.string.host_carplay_session_ended_reconnecting)) + appendLog("AirPlay session ended; reconnecting from scratch") + reconnectAfterLoss("AirPlay session ended") + } + } + + override fun onTransportError(message: String) { + runOnUiThread { + if (controllerGeneration != restartGeneration) { + return@runOnUiThread + } + activeScreenStreamTypes.clear() + WebSession.videoActive = false + setLocalizedConnectionStage(UiText(UiR.string.host_transport_error_reconnecting)) + appendLog("CarPlay transport error: $message; reconnecting from scratch") + reconnectAfterLoss("CarPlay transport error: $message") + } + } + + override fun onDebugLog(message: String) { + if (DiagnosticRedactor.redact(message) == null) return + runOnUiThread { + if (controllerGeneration != restartGeneration) { + return@runOnUiThread + } + DisplayDiagnosticSnapshot.record(this@CarPlayHostActivity, displayDiagnosticAttempt, message) + if (message.startsWith(PROTOCOL_TRACE_PREFIX)) { + appendFileLog(message) + } else { + appendLog(message) + } + } + } + } + + private fun createStatusReporter( + controllerGeneration: Int, + ): (CarPlayStatus) -> Unit = { status -> + runOnUiThread { + if (!shuttingDown.get() && controllerGeneration == restartGeneration) { + val failed = status is CarPlayStatus.Failed && (!status.automaticRetry || status.wifiResetRequired) + if (failed) automaticReconnectAllowed = false + val description = status.describe() + setConnectionStage(description) + connectionUi = connectionUi.copy( + failed = failed, + wifiResetRequired = status is CarPlayStatus.Failed && status.wifiResetRequired, + ) + if (failed && connectionUi.stage == UiText(UiR.string.host_connection_interrupted_retrying)) { + setLocalizedConnectionStage(UiText(UiR.string.carplay_connection_failed_hint)) + } + if (status is CarPlayStatus.Failed && status.automaticRetry && !status.wifiResetRequired) { + reconnectAfterLoss(description) + } + } + } + } + + private fun adoptBackgroundSession(): Boolean { + val snapshot = CarPlayBackgroundSession.snapshot() ?: return false + displayDiagnosticAttempt = DisplayDiagnosticSnapshot.currentAttempt(this) + controller = snapshot.controller + sink = snapshot.sink + CarPlayBackgroundSession.store(snapshot.controller, snapshot.sink, snapshot.width, snapshot.height, this) { completion -> + runOnUiThread { + shutdown(false, "WheelPlay disconnect", completion) + finish() + } + } + if (snapshot.width > 0 && snapshot.height > 0) { + activeDisplaySize = DisplaySize(snapshot.width, snapshot.height) + } + if (snapshot.controller.isClosed()) { + restartCarPlay("Replacing closed background CarPlay controller") + return true + } + val generation = restartGeneration + snapshot.controller.attachUi( + createSessionListener(generation), + createStatusReporter(generation), + ) + snapshot.sink.setScreenStreamActiveChangedListener { type, active -> + onScreenStreamStateChanged(restartGeneration, type, active) + } + val serviceReused = snapshot.controller.hasActiveAirPlayAttachment() + appendLog( + if (serviceReused) { + "Reusing existing background CarPlay service" + } else { + "Reusing existing background CarPlay session" + }, + ) + setConnectionStage( + if (serviceReused) { + getString(UiR.string.host_carplay_service_already_running) + } else { + getString(UiR.string.host_carplay_session_already_running) + }, + ) + dismissConnectedDialog() + return true + } + + private fun startCarPlay(size: DisplaySize) { + if (CarPlayBackgroundSession.hasSession() && !CarPlayBackgroundSession.isOwner(this)) return + if (shuttingDown.get() || handshakeResetInProgress || controller != null) return + automaticReconnectAllowed = true + if (WheelPlayBootstrap.importing) { + setLocalizedConnectionStage(UiText(UiR.string.ui_authentication_resources_are_being_imported_reconnect_when_finished)) + connectionUi = connectionUi.copy(failed = true) + return + } + if (runCatching { WheelPlayBootstrap.ensure(this) }.isFailure) { + setLocalizedConnectionStage(UiText(UiR.string.ui_authentication_resources_are_not_ready_import_files_or_a)) + connectionUi = connectionUi.copy(failed = true) + return + } + if (!CarPlayBackgroundSession.reserve(this, { WebSession.stageDetail(this) }) { completion -> + runOnUiThread { shutdown(false, "WheelPlay disconnect", completion); finish() } + }) { + showWheelPlayHome("home") + finish() + return + } + val controllerGeneration = restartGeneration + // The settings page can change branding while this activity remains in the back stack. + simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) + val config = createRuntimeConfig() + val airPlayConfig = createAirPlayConfig(size) + val locationProvider: Iap2LocationProvider = WebSession.location.createProvider( + if (locationReportingEnabled) { + AndroidCarPlayLocationProvider(this) + } else { + null + }, + ) + appendLog( + "Starting CarPlay controller at ${size.width}x${size.height} -> ${airPlayConfig.main.widthPixels}x${airPlayConfig.main.heightPixels} " + + "(${CarPlayDisplayScale.label(displayScaleTenths)}) physical=${airPlayConfig.main.widthPhysicalMm}x" + + "${airPlayConfig.main.heightPhysicalMm}mm video=${if (airPlayConfig.hevc) "HEVC" else "H.264"} decoder=${if (airPlayConfig.hevc && hevcSoftwareDecoderEnabled) "software" else "hardware"} microphone=${airPlayConfig.microphone} " + + "androidLocation=${if (locationReportingEnabled) "enabled" else "disabled"} browserLocation=opt-in mfi=${mfiTargetLabel(config.mfiTarget)}", + ) + Log.i( + TAG, + "starting controller display=${size.width}x${size.height} negotiated=${airPlayConfig.main.widthPixels}x${airPlayConfig.main.heightPixels} " + + "scale=${CarPlayDisplayScale.label(displayScaleTenths)} hevc=${airPlayConfig.hevc} " + + "softwareHevc=${airPlayConfig.hevc && hevcSoftwareDecoderEnabled} microphone=${airPlayConfig.microphone} " + + "androidLocation=$locationReportingEnabled browserLocation=opt-in mfi=${config.mfiTarget}", + ) + val renderer = try { + createMediaSink( + videoWidth = airPlayConfig.main.widthPixels, + videoHeight = airPlayConfig.main.heightPixels, + controllerGeneration = controllerGeneration, + ) + } catch (error: Exception) { + Log.e(TAG, "Offscreen video initialization failed", error) + if (error is com.shilapi.xcertplay.projection.ProjectionCleanupException) + CarPlayBackgroundSession.teardownFinished(false) + setLocalizedConnectionStage(UiText(UiR.string.host_this_device_cannot_create_an_offscreen_decoder_surface_check)) + connectionUi = connectionUi.copy(failed = true) + return + } + sink = renderer + val media = createMediaEngine(renderer) + val pairings = AirPlayPersistence.loadPairings(this) { id, key -> + AirPlayPersistence.savePairing(this, id, key) + } + val next = CarPlayController( + context = this, + config = config, + airPlayConfig = airPlayConfig, + identity = airPlayIdentity, + pairings = pairings, + listener = createSessionListener(controllerGeneration), + media = media, + reportStatus = createStatusReporter(controllerGeneration), + loadPairRecord = { AirPlayPersistence.loadLockdownRecord(this) }, + savePairRecord = { record -> AirPlayPersistence.saveLockdownRecord(this, record) }, + clearPairRecord = { AirPlayPersistence.clearLockdownRecord(this) }, + locationProvider = locationProvider, + ) + controller = next + CarPlayBackgroundSession.store(next, renderer, size.width, size.height, this) { completion -> + runOnUiThread { + shutdown(terminateProcess = false, reason = "WheelPlay disconnect", completion = completion) + finish() + } + } + try { + startForegroundService(Intent(this, WheelPlaySessionService::class.java)) + next.start() + } catch (error: RuntimeException) { + appendLog("Connection could not start: ${error.javaClass.simpleName}") + shutdown(false, "foreground service could not start") + setLocalizedConnectionStage(UiText(UiR.string.host_could_not_start_carplay_return_to_wheelplay_and_check)) + connectionUi = connectionUi.copy(failed = true) + } + } + + private fun syncAirPlayDarkMode() { + if (activeAirPlaySession != null) WebSession.refreshAppearance(this) + } + + private fun audioCaptureDirectory(): File? { + if (!File(filesDir, AUDIO_CAPTURE_MARKER).isFile) return null + return File(filesDir, AUDIO_CAPTURE_DIRECTORY) + } + + private fun scheduleBrowserDisplaySize(width: Int?, height: Int?) { + if (shuttingDown.get()) return + val slot = CarPlayResolutionSlot.forBrowser(if (width != null && height != null) width to height else null) + val size = DisplaySize(slot.width, slot.height) + if (size == activeDisplaySize) { + pendingDisplaySize = null + mainHandler.removeCallbacks(applyDisplaySize) + return + } + if (size == pendingDisplaySize) return + appendLog("Adaptive browser display requested: ${size.width}x${size.height}") + pendingDisplaySize = size + mainHandler.removeCallbacks(applyDisplaySize) + mainHandler.postDelayed(applyDisplaySize, BROWSER_DISPLAY_CHANGE_DEBOUNCE_MILLIS) + } + + private fun applyDisplaySize(size: DisplaySize) { + if (shuttingDown.get() || size == activeDisplaySize) return + val previous = activeDisplaySize + activeDisplaySize = size + recordDetectedMaximum(size) + + if (previous == null) { + appendLog("Display detected: ${size.width}x${size.height}") + maybeStartCarPlay() + } else if (controller == null) { + maybeStartCarPlay() + } else if (handshakeResetInProgress) { + appendLog( + "Display updated while handshake is reset: ${previous.width}x${previous.height} -> ${size.width}x${size.height}", + ) + } else { + restartCarPlay( + "Display changed ${previous.width}x${previous.height} -> ${size.width}x${size.height}", + ) + } + } + + private fun recordDetectedMaximum(size: DisplaySize) { + val width = maxOf(maximumDetectedWidthPixels, size.width) + val height = maxOf(maximumDetectedHeightPixels, size.height) + if (width == maximumDetectedWidthPixels && height == maximumDetectedHeightPixels) return + maximumDetectedWidthPixels = width + maximumDetectedHeightPixels = height + AirPlayPersistence.saveMaximumDetectedDisplay(this, width, height) + } + + private fun maybeStartCarPlay() { + if (shuttingDown.get()) return + if (CarPlayBackgroundSession.hasSession() && !CarPlayBackgroundSession.isOwner(this)) { + if (!adoptBackgroundSession()) mainHandler.postDelayed({ maybeStartCarPlay() }, 500) + return + } + if (controller == null && adoptBackgroundSession()) return + val size = activeDisplaySize ?: return + val transportReady = if (wirelessEnabled) wirelessPermissionsReady else vpnReady + val locationReady = !locationReportingEnabled || locationPermissionAvailable + if ( + !transportReady || + !locationReady || + !microphonePermissionResolved || + shuttingDown.get() || + handshakeResetInProgress || + controller != null || + connectionUi.requestChannelPermission + ) { + return + } + if (requestManualHotspotChannelPermissionIfNeeded()) return + startCarPlay(size) + } + + private fun reconnectAfterLoss(reason: String) { + if (!automaticReconnectAllowed) return + if (!CarPlayBackgroundSession.isOwner(this)) return + if (shuttingDown.get() || handshakeResetInProgress) return + if (reconnectScheduled) return + reconnectScheduled = true + val generation = restartGeneration + val delayMillis = if (reason.contains("AirPlay iAP tunnel", ignoreCase = true)) { + IAP_TUNNEL_RECONNECT_DELAY_MILLIS + } else { + (RECONNECT_DELAY_MILLIS * (1L shl reconnectAttempts.coerceAtMost(4))).coerceAtMost(30_000L) + } + reconnectAttempts += 1 + appendLog("$reason; retrying in ${delayMillis}ms") + mainHandler.postDelayed( + { + reconnectScheduled = false + if ( + !automaticReconnectAllowed || shuttingDown.get() || + handshakeResetInProgress || + generation != restartGeneration + ) { + return@postDelayed + } + restartCarPlay("Reconnecting after $reason") + }, + delayMillis, + ) + } + + /** Full-stack fallback when an AirPlay-only reconnect is unavailable. */ + private fun restartCarPlay(reason: String) { + if (!CarPlayBackgroundSession.isOwner(this)) return + if (shuttingDown.get() || handshakeResetInProgress) return + val size = activeDisplaySize ?: return + appendLog(reason) + activeScreenStreamTypes.clear() + setConnectionStage(reason) + Log.i(TAG, "$reason; rebuilding stack at ${size.width}x${size.height}") + val generation = ++restartGeneration + handshakeResetInProgress = true + val oldController = controller + val oldSink = sink + CarPlayBackgroundSession.clear(oldController, keepOwner = true) + controller = null + sink = null + teardownExecutor.execute { + val clean = runCatching { + oldController?.close() + val transportStopped = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true + if (transportStopped) oldSink?.close(awaitWorkers = true) + transportStopped && CarPlayBackgroundSession.teardownClean + }.getOrDefault(false) + if (!clean) { + CarPlayBackgroundSession.teardownFinished(false) + return@execute + } + runOnUiThread { + if (!shuttingDown.get() && generation == restartGeneration) { + handshakeResetInProgress = false + startCarPlay(size) + } + } + } + } + + private fun showWheelPlayHome(page: String = "home") { + connectionDialogVisible = false + controller?.sendTouch(emptyList()) + startActivity(Intent(this, WheelPlayActivity::class.java) + .putExtra("page", page).addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_NO_ANIMATION)) + overridePendingTransition(0, 0) + } + + private fun shutdown(terminateProcess: Boolean, reason: String, completion: () -> Unit = {}) { + if (shutdownComplete) { completion(); return } + shutdownCompletions.add(completion) + if (!shuttingDown.compareAndSet(false, true)) return + restartGeneration += 1 + mainHandler.removeCallbacks(applyDisplaySize) + val oldController = controller + val oldSink = sink + CarPlayBackgroundSession.clear(oldController) + controller = null + sink = null + Log.i(TAG, "shutdown reason=$reason terminateProcess=$terminateProcess") + teardownExecutor.execute { + val clean = runCatching { + oldController?.close() + val transportStopped = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true + if (transportStopped) oldSink?.close(awaitWorkers = true) + transportStopped && CarPlayBackgroundSession.teardownClean + }.getOrDefault(false) + if (terminateProcess) { + applicationContext.stopService(Intent(applicationContext, CarPlayVpnService::class.java)) + } + Log.i(TAG, "shutdown complete clean=$clean") + if (terminateProcess) applicationContext.stopService(Intent(applicationContext, WheelPlaySessionService::class.java)) + teardownExecutor.shutdown() + mainHandler.post { + CarPlayBackgroundSession.teardownFinished(clean) + shutdownComplete = true + val callbacks = shutdownCompletions.toList() + shutdownCompletions.clear() + callbacks.forEach { it() } + } + if (terminateProcess) Process.killProcess(Process.myPid()) + } + } + + private fun onScreenStreamStateChanged(generation: Int, type: Int, active: Boolean) { + runOnUiThread { + if (shuttingDown.get() || generation != restartGeneration) return@runOnUiThread + if (active) { + activeScreenStreamTypes.add(type) + } else { + activeScreenStreamTypes.remove(type) + } + WebSession.videoActive = activeScreenStreamTypes.isNotEmpty() + dismissConnectedDialog() + } + } + + private fun setConnectionStage(message: String) { + WebSession.setStage(friendlyStage(message)) + connectionUi = connectionUi.copy(stage = friendlyStage(message), failed = false, wifiResetRequired = false) + dismissConnectedDialog() + } + + private fun setLocalizedConnectionStage(message: UiText) { + WebSession.setStage(message) + connectionUi = connectionUi.copy(stage = message) + dismissConnectedDialog() + } + + private fun friendlyStage(message: String): UiText = when { + message.contains("Turn on Wi-Fi", true) -> UiText(UiR.string.host_turn_on_wi_fi_in_this_device_s_system) + message.contains("Allow precise Location", true) -> UiText(UiR.string.host_allow_precise_location_in_the_app_permissions) + message.contains("Allow Nearby devices", true) -> UiText(UiR.string.host_allow_access_to_nearby_devices_in_the_app_permissions) + message.contains("createGroup failed", true) -> UiText(UiR.string.host_cannot_start_carplay_wi_fi_check_wi_fi_and) + message.contains("needs a reset", true) -> UiText(UiR.string.host_the_previous_wi_fi_direct_connection_is_still_running) + message.contains("socket", true) || message.contains("RFCOMM", true) -> UiText(UiR.string.host_cannot_connect_to_the_iphone_yet_unlock_it_and) + message.contains("unsupported", true) || message.contains("not supported", true) -> UiText(UiR.string.host_this_device_may_not_support_wireless_carplay_try_a) + message.contains("denied", true) || message.contains("permission", true) -> UiText(UiR.string.host_allow_the_connection_permissions_to_continue) + message.contains("Failed", true) || message.contains("error", true) -> UiText(UiR.string.host_connection_interrupted_retrying) + message.contains("Waiting for iPhone", true) || message.contains("Discovering iPhone", true) -> UiText(UiR.string.host_connect_the_iphone_using_a_usb_data_cable) + message.contains("paired", true) -> UiText(UiR.string.host_looking_for_a_paired_iphone) + message.contains("Bluetooth", true) -> UiText(UiR.string.ui_connecting_to_iphone) + message.contains("reconnect", true) || message.contains("ended", true) -> UiText(UiR.string.host_reconnecting_to_iphone) + message.contains("active", true) || message.contains("running", true) -> UiText(UiR.string.host_carplay_session_started_waiting_for_video) + else -> UiText(UiR.string.host_preparing_carplay) + } + + private fun appendLog(message: String) { + val safe = DiagnosticRedactor.redact(message) ?: return + sessionLog?.append(formattedLogLine(safe, System.currentTimeMillis())) + } + + private fun appendFileLog(message: String) { + sessionLog?.append(formattedLogLine(message, System.currentTimeMillis())) + } + + private fun formattedLogLine(message: String, nowMillis: Long): String = + "${SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date(nowMillis))} $message" + + private fun initializeSessionLog() { + val logFile = File(File(filesDir, "logs"), "diplay.log") + val activeLog = SessionLogFile(logFile) + runCatching { + activeLog.reset( + "WheelPlay log started ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())} pid=${Process.myPid()} path=${logFile.absolutePath}", + ) + } + sessionLog = activeLog + } + + private fun showSystemBars() = ServerWindow.showSystemBars(window) + + private fun CarPlayStatus.describe(): String = when (this) { + CarPlayStatus.DiscoveringMfi -> "Preparing MFi authentication" + CarPlayStatus.WaitingForMfi -> "Waiting for MFi coprocessor" + CarPlayStatus.RequestingMfiPermission -> "Requesting MFi USB permission" + CarPlayStatus.MfiReady -> "MFi authentication ready" + CarPlayStatus.StartingHotspot -> "Starting wireless hotspot" + is CarPlayStatus.HotspotReady -> + "Hotspot ready: $backend, $ssid, $band, channel ${if (channel == 0) "auto" else channel}" + CarPlayStatus.WaitingForPairedIphone -> "Waiting for paired iPhone" + CarPlayStatus.ConnectingBluetooth -> "Connecting Bluetooth" + CarPlayStatus.RunningWireless -> "Wireless CarPlay control running" + CarPlayStatus.WirelessActiveFallback -> "Wireless CarPlay active with Bluetooth control" + CarPlayStatus.WirelessActive -> "Wireless CarPlay active" + CarPlayStatus.DiscoveringIphone -> "Discovering iPhone" + CarPlayStatus.WaitingForIphone -> "Waiting for iPhone over USB" + CarPlayStatus.RequestingIphonePermission -> "Requesting iPhone USB permission" + CarPlayStatus.WaitingForReenumeration -> "Waiting for iPhone re-enumeration" + CarPlayStatus.SelectingConfiguration -> "Selecting CarPlay configuration" + CarPlayStatus.OpeningDataPaths -> "Opening USB data paths" + CarPlayStatus.Pairing -> "Pairing with iPhone" + CarPlayStatus.ConnectingControl -> "Connecting iAP2 control" + CarPlayStatus.AttachingNetwork -> + if (wirelessEnabled) "Starting AirPlay service" else "Attaching NCM/AirPlay network" + CarPlayStatus.RunningControl -> "CarPlay control running" + CarPlayStatus.ControlEnded -> "CarPlay control window ended" + is CarPlayStatus.Failed -> "Failed: ${message}" + } + + private companion object { + const val TAG = "xcertplay-usb" + const val BROWSER_DISPLAY_CHANGE_DEBOUNCE_MILLIS = 900L + const val RECONNECT_DELAY_MILLIS = 2_000L + const val IAP_TUNNEL_RECONNECT_DELAY_MILLIS = 15_000L + const val CONTROLLER_CLOSE_TIMEOUT_MILLIS = 4_000L + const val AUDIO_CAPTURE_MARKER = "audio-capture.enabled" + const val AUDIO_CAPTURE_DIRECTORY = "audio-captures" + const val PROTOCOL_TRACE_PREFIX = "TRACE " + } + + private data class DisplaySize(val width: Int, val height: Int) +} + +/** Process-local hand-off for keeping the CarPlay session alive while no Activity is visible. */ +internal object CarPlayBackgroundSession { + @Volatile var active = false + private var stopAction: (((() -> Unit)) -> Unit)? = null + private var stopping = false + private var owner: Any? = null + @Synchronized fun isOwner(candidate: Any): Boolean = owner === candidate + @Synchronized fun hasSession(): Boolean = stopAction != null || stopping + private val stopWaiters = mutableListOf<() -> Unit>() + + fun stop(completion: () -> Unit = {}) { + val action: (((() -> Unit)) -> Unit)? + synchronized(this) { + if (stopping) { stopWaiters.add(completion); return } + action = stopAction + if (action != null) { stopping = true; stopWaiters.add(completion) } + } + if (action == null) { completion(); return } + action.invoke { + val callbacks = synchronized(this) { + stopping = false + stopWaiters.toList().also { stopWaiters.clear() } + } + callbacks.forEach { it() } + } + } + + data class Snapshot( + val controller: CarPlayController, + val sink: AndroidMediaSink, + val width: Int, + val height: Int, + ) + + @Volatile var teardownClean = true + private set + + @Synchronized fun reserve(owner: Any, status: () -> String?, stop: (() -> Unit) -> Unit): Boolean { + if (projection != null) return this.owner === owner + val adapter = CarPlaySourceSession(status) + if (!projectionSessions.adopt(adapter, + ProjectionResource.entries.toSet())) return false + projection = adapter + this.owner = owner + stopAction = stop + teardownClean = true + return true + } + + fun teardownFinished(clean: Boolean) { + val adapter = synchronized(this) { + teardownClean = clean + projection.also { if (clean) projection = null } + } + adapter?.completed(clean) + if (clean) adapter?.let { projectionSessions.ended(it) } + } + + private var projection: CarPlaySourceSession? = null + private var controller: CarPlayController? = null + private var sink: AndroidMediaSink? = null + private var width = 0 + private var height = 0 + + @Synchronized + fun snapshot(): Snapshot? { + val currentController = controller ?: return null + val currentSink = sink ?: return null + return Snapshot(currentController, currentSink, width, height) + } + + private val listeners = java.util.concurrent.CopyOnWriteArrayList<(Snapshot?) -> Unit>() + fun subscribe(listener: (Snapshot?) -> Unit): AutoCloseable { + listeners.add(listener) + listener(snapshot()) + return AutoCloseable { listeners.remove(listener) } + } + + fun store(controller: CarPlayController, sink: AndroidMediaSink, width: Int, height: Int, owner: Any, stop: (() -> Unit) -> Unit) { + synchronized(this) { + this.stopAction = stop + this.owner = owner + this.controller = controller + this.sink = sink + projection?.bind(controller, sink) + this.width = width + this.height = height + } + projection?.let { projectionSessions.changed(it) } + listeners.forEach { it(snapshot()) } + } + + fun clear(expected: CarPlayController? = null, keepOwner: Boolean = false) { + synchronized(this) { + if (expected != null && controller !== expected) return + controller = null + sink = null + projection?.bind(null, null) + if (!keepOwner) { stopAction = null; owner = null } + active = false + width = 0 + height = 0 + } + listeners.forEach { it(snapshot()) } + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/CarPlayResolutionSlot.kt b/common/src/main/java/moe/feng/wheelplay/CarPlayResolutionSlot.kt new file mode 100644 index 0000000..caf7f8c --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/CarPlayResolutionSlot.kt @@ -0,0 +1,26 @@ +package moe.feng.wheelplay + +/** Base CarPlay canvas, before encoding resolution and UI scale are applied. */ +internal data class CarPlayResolutionSlot(val width: Int, val height: Int) { + init { + require(width > 0 && height > 0) + } + + val aspectRatio: Float + get() = width.toFloat() / height + + companion object { + val DEFAULT = CarPlayResolutionSlot(1280, 720) + + fun forBrowser(viewport: Pair?, adaptive: Boolean = true): CarPlayResolutionSlot { + if (!adaptive || viewport == null || viewport.first <= 0 || viewport.second <= 0) + return DEFAULT + val scale = minOf(1.0, 3840.0 / viewport.first, 2160.0 / viewport.second) + fun even(value: Double) = value.toInt().coerceAtLeast(2) / 2 * 2 + return CarPlayResolutionSlot( + even(viewport.first * scale), + even(viewport.second * scale), + ) + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DarkMode.kt b/common/src/main/java/moe/feng/wheelplay/DarkMode.kt similarity index 85% rename from common/src/main/java/com/shilapi/xcertplay/DarkMode.kt rename to common/src/main/java/moe/feng/wheelplay/DarkMode.kt index 14aad54..89c711b 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DarkMode.kt +++ b/common/src/main/java/moe/feng/wheelplay/DarkMode.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.content.res.Configuration diff --git a/common/src/main/java/com/shilapi/xcertplay/DiagnosticExportStore.kt b/common/src/main/java/moe/feng/wheelplay/DiagnosticExportStore.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/DiagnosticExportStore.kt rename to common/src/main/java/moe/feng/wheelplay/DiagnosticExportStore.kt index 9ef46d7..5a08d6e 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiagnosticExportStore.kt +++ b/common/src/main/java/moe/feng/wheelplay/DiagnosticExportStore.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.content.ContentResolver import android.content.ContentValues diff --git a/common/src/main/java/com/shilapi/xcertplay/DiagnosticRedactor.kt b/common/src/main/java/moe/feng/wheelplay/DiagnosticRedactor.kt similarity index 97% rename from common/src/main/java/com/shilapi/xcertplay/DiagnosticRedactor.kt rename to common/src/main/java/moe/feng/wheelplay/DiagnosticRedactor.kt index 5442c29..8d8c70d 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiagnosticRedactor.kt +++ b/common/src/main/java/moe/feng/wheelplay/DiagnosticRedactor.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay /** Diagnostics describe state transitions; protocol payloads and credentials are never exported. */ internal object DiagnosticRedactor { diff --git a/common/src/main/java/com/shilapi/xcertplay/DisplayDiagnosticSnapshot.kt b/common/src/main/java/moe/feng/wheelplay/DisplayDiagnosticSnapshot.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/DisplayDiagnosticSnapshot.kt rename to common/src/main/java/moe/feng/wheelplay/DisplayDiagnosticSnapshot.kt index 8c46695..a665474 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DisplayDiagnosticSnapshot.kt +++ b/common/src/main/java/moe/feng/wheelplay/DisplayDiagnosticSnapshot.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.content.Context import java.text.SimpleDateFormat diff --git a/common/src/main/java/moe/feng/wheelplay/ImageCropActivity.kt b/common/src/main/java/moe/feng/wheelplay/ImageCropActivity.kt new file mode 100644 index 0000000..67040c7 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ImageCropActivity.kt @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.ViewModelProvider +import moe.feng.wheelplay.ui.crop.IconCropScreen +import moe.feng.wheelplay.ui.crop.IconCropViewModel +import moe.feng.wheelplay.ui.theme.WheelPlayTheme + +/** Loads one local image and writes a user-positioned 1:1 PNG into the AirPlay icon slot. */ +class ImageCropActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val uri = + intent.data + ?: run { + finish() + return + } + val model = ViewModelProvider(this)[IconCropViewModel::class.java] + model.load(uri) + ServerWindow.showSystemBars(window) + setContent { + WheelPlayTheme { + // Once Save is accepted, wait for the atomic write; Cancel never races that write. + BackHandler(enabled = model.saving || model.complete) {} + LaunchedEffect(model.complete) { + if (model.complete) { + setResult(RESULT_OK, Intent().putExtra(EXTRA_CROP_COMPLETE, true)) + finish() + } + } + IconCropScreen( + bitmap = model.bitmap, + selection = model.selection, + error = model.error, + saving = model.saving || model.complete, + onSelectionChange = model::select, + onSave = model::save, + onCancel = { + setResult(RESULT_CANCELED) + finish() + }, + ) + } + } + } + + companion object { + const val EXTRA_CROP_COMPLETE = "crop_complete" + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/MfiAssetStore.kt b/common/src/main/java/moe/feng/wheelplay/MfiAssetStore.kt similarity index 99% rename from common/src/main/java/com/shilapi/xcertplay/MfiAssetStore.kt rename to common/src/main/java/moe/feng/wheelplay/MfiAssetStore.kt index e73ed5f..fada7d5 100644 --- a/common/src/main/java/com/shilapi/xcertplay/MfiAssetStore.kt +++ b/common/src/main/java/moe/feng/wheelplay/MfiAssetStore.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay +package moe.feng.wheelplay import com.shilapi.xcertplay.mfi.LocalMfiAuthenticationClient import java.io.File diff --git a/common/src/main/java/com/shilapi/xcertplay/ServerWindow.kt b/common/src/main/java/moe/feng/wheelplay/ServerWindow.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/ServerWindow.kt rename to common/src/main/java/moe/feng/wheelplay/ServerWindow.kt index ee6c57c..ff0b149 100644 --- a/common/src/main/java/com/shilapi/xcertplay/ServerWindow.kt +++ b/common/src/main/java/moe/feng/wheelplay/ServerWindow.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.view.View import android.view.Window diff --git a/common/src/main/java/com/shilapi/xcertplay/SessionLogFile.kt b/common/src/main/java/moe/feng/wheelplay/SessionLogFile.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/SessionLogFile.kt rename to common/src/main/java/moe/feng/wheelplay/SessionLogFile.kt index fd8ca78..c18f115 100644 --- a/common/src/main/java/com/shilapi/xcertplay/SessionLogFile.kt +++ b/common/src/main/java/moe/feng/wheelplay/SessionLogFile.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import java.io.Closeable import java.io.File diff --git a/common/src/main/java/moe/feng/wheelplay/WheelPlayActivity.kt b/common/src/main/java/moe/feng/wheelplay/WheelPlayActivity.kt new file mode 100644 index 0000000..72470b6 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/WheelPlayActivity.kt @@ -0,0 +1,1173 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// UI copy and visual language adapted from DiAuto. See docs/THIRD_PARTY_NOTICES.md. +package moe.feng.wheelplay + +import com.shilapi.xcertplay.projection.message +import com.shilapi.xcertplay.projection.projectionFailure +import com.shilapi.xcertplay.projection.ProjectionSourceIds +import com.shilapi.xcertplay.projection.projectionSessions +import com.shilapi.xcertplay.projection.plugin.projectionPlugins +import com.shilapi.xcertplay.projection.plugin.RemotePluginSession +import com.shilapi.xcertplay.projection.ProjectionSession +import moe.feng.wheelplay.plugin.ProjectionPlugin +import com.shilapi.xcertplay.projection.registerProjectionSources +import com.shilapi.xcertplay.projection.androidauto.LocalAndroidAuto +import moe.feng.wheelplay.projection.desktop.* +import com.shilapi.xcertplay.projection.SourceAvailability +import rikka.shizuku.Shizuku +import com.shilapi.xcertplay.projection.vnc.VncSource +import com.shilapi.xcertplay.projection.vnc.VncEndpoint + +import com.shilapi.xcertplay.shared.R as UiR +import com.shilapi.xcertplay.i18n.* + +import android.Manifest +import com.shilapi.xcertplay.web.WebSession +import com.shilapi.xcertplay.web.RootAccess +import com.shilapi.xcertplay.web.TeslaHttpCompatibility +import com.shilapi.xcertplay.web.LanTls +import com.shilapi.xcertplay.web.BrowserExperienceLink +import com.shilapi.xcertplay.network.CarPlayVpnService +import com.shilapi.xcertplay.network.TeslaHttpMode +import io.github.g00fy2.quickie.QRResult +import io.github.g00fy2.quickie.ScanQRCode +import android.bluetooth.BluetoothManager +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import androidx.activity.compose.setContent +import androidx.compose.runtime.* +import androidx.compose.material3.SnackbarHostState +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import moe.feng.wheelplay.ui.* +import moe.feng.wheelplay.settings.* +import androidx.lifecycle.ViewModelProvider +import androidx.appcompat.app.AppCompatActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.orchestration.WirelessHotspotMode +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Connection and preference pages in the shared server navigation. */ +class WheelPlayActivity : AppCompatActivity() { + private class CertificateDocument : ActivityResultContracts.OpenDocument() { + override fun createIntent(context: android.content.Context, input: Array): Intent = + super.createIntent(context, input).addCategory(Intent.CATEGORY_OPENABLE) + } + private val handler = Handler(Looper.getMainLooper()) + private var page by mutableStateOf("service") + private val sourceDetailPages = SourcePage.entries.map { it.id }.toSet() + private var aaConnection by mutableStateOf(AaConnection.LOCAL) + private val settingsModel by lazy { ViewModelProvider(this)[SettingsViewModel::class.java] } + private val settingsDialogs = SettingsDialogHost() + private var webBusy = false + private var awaitingTeslaVpnConsent = false + private val localAndroidAutoSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (!isFinishing && !isDestroyed) showLocalAndroidAutoSetup() + } + private val teslaVpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val requested = awaitingTeslaVpnConsent + awaitingTeslaVpnConsent = false + if (requested && TeslaHttpCompatibility.config(this).mode == TeslaHttpMode.VPN) { + applyTeslaHttpEnabled(result.resultCode == RESULT_OK) + if (result.resultCode != RESULT_OK) toast(getString(UiR.string.ui_vpn_permission_canceled_rootless_mode_remains_disabled)) + } + } + private var pendingPemCertificate: ByteArray? = null + private val pemCertificatePicker = registerForActivityResult(CertificateDocument()) { uri -> + if (uri != null) { + webBusy = true + Thread({ + val result = runCatching { readCertificateFile(uri) } + runOnUiThread { + webBusy = false + if (isFinishing || isDestroyed) { result.getOrNull()?.fill(0); return@runOnUiThread } + result.onSuccess { pendingPemCertificate = it; pemKeyPicker.launch(arrayOf("*/*")) } + .onFailure { toast(getString(UiR.string.error_certificate_read, it.userMessage(this))) } + } + }, "https-certificate-read").start() + } + } + private val pemKeyPicker = registerForActivityResult(CertificateDocument()) { uri -> + val certificate = pendingPemCertificate + pendingPemCertificate = null + if (uri == null || certificate == null) certificate?.fill(0) + else textInput(getString(UiR.string.ui_pem_private_key_password_leave_blank_if_unencrypted), "", true, onCancel = { certificate.fill(0) }) { value -> + val password = value.toCharArray() + configureWeb(getString(UiR.string.ui_https_certificate_imported_reconnect_your_browser)) { + try { + val key = readCertificateFile(uri) + try { LanTls.preparePem(certificate, key, password).use { WebSession.installCertificate(this, it) } } + finally { key.fill(0) } + } finally { certificate.fill(0); password.fill('\u0000') } + } + } + } + private val pkcs12Picker = registerForActivityResult(CertificateDocument()) { uri -> + if (uri != null) textInput(getString(UiR.string.ui_pkcs_12_file_password_optional), "", true) { value -> + choosePkcs12Identity(uri, value.toCharArray()) + } + } + private data class HomeSource(val id: String, val name: String, val page: SourcePage) + private var setupError: String? = null + private var pendingWireless = false + private var initialLaunch = true + private var notificationTransport = true + private var exportInProgress = false + private var pendingAndroidAutoSourceId: String? = null + private val androidAutoMicrophonePermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val sourceId = pendingAndroidAutoSourceId ?: return@registerForActivityResult + pendingAndroidAutoSourceId = null + if (!granted) toast(getString(R.string.aa_microphone_permission_denied)) + startProjectionSourceNow(sourceId) + } + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + connect(notificationTransport) + } + private val tick = object : Runnable { + override fun run() { refreshUiState(); refreshSettingsRuntime(); handler.postDelayed(this, 1000) } + } + private val bluetoothPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) choosePhone() else permissionHelp(getString(UiR.string.ui_nearby_devices), getString(UiR.string.ui_allow_access_to_nearby_devices_to_connect_to_a)) + } + private val export = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) exportDiagnostics(uri) + } + + private val oemImagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) runCatching { + oemImageCrop.launch(Intent(this, ImageCropActivity::class.java) + .setData(uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + }.onFailure { toast(getString(UiR.string.ui_could_not_open_the_icon_editor_try_again)) } + } + private val oemImageCrop = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + render() + toast(getString(UiR.string.ui_vehicle_icon_saved_applies_on_the_next_iphone_connection)) + } + } + + private val authenticationImport by lazy { + ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory(application))[AuthenticationImportModel::class.java] + } + private var selectedPrivateKey: Uri? = null + private val selectAuthenticationKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + selectedPrivateKey = uri + launchAuthenticationPicker { selectAuthenticationCertificate.launch(arrayOf("*/*")) } + } + } + private val selectAuthenticationCertificate = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + val key = selectedPrivateKey + selectedPrivateKey = null + if (uri != null && key != null) authenticationImport.import(key = key, certificate = uri) + } + private val selectAuthenticationApk = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) authenticationImport.import(apk = uri) + } + + private val scan = registerForActivityResult(ScanQRCode()) { result -> + val payload = when (result) { + is QRResult.QRSuccess -> result.content.rawValue?.take(257) + QRResult.QRUserCanceled -> null + QRResult.QRMissingPermission -> { + permissionHelp(getString(UiR.string.ui_camera), getString(UiR.string.ui_qr_pairing_requires_camera_permission_you_can_also_enter)) + null + } + is QRResult.QRError -> { + toast(getString(UiR.string.ui_scan_failed_check_camera_permission_or_close_other_apps)) + null + } + } ?: return@registerForActivityResult + val request = WebSession.inspectPairing(payload) + if (request == null) { + toast(getString(UiR.string.ui_invalid_or_expired_qr_code_refresh_this_server_s)) + } else { + dialogs.message(getString(UiR.string.ui_allow_this_head_unit_to_connect), getString(UiR.string.pairing_permission_message, request.peer), listOf( + WheelPlayDialogs.Action(getString(UiR.string.ui_allow_connection)) { + toast(if (WebSession.approvePairing(payload)) getString(UiR.string.ui_authorized_the_head_unit_web_page_will_connect_automatically) else getString(UiR.string.ui_request_expired_refresh_the_web_page_and_scan_again)) + }, + )) + } + } + private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) scanPairing() else permissionHelp(getString(UiR.string.ui_camera), getString(UiR.string.ui_qr_pairing_requires_camera_permission_you_can_also_enter)) + } + private fun scanPairing() { + if (!WebSession.running) { toast(getString(UiR.string.ui_wait_for_the_web_service_to_start_before_scanning)); return } + if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { + toast(getString(UiR.string.ui_this_device_has_no_camera_enter_the_pairing_code)); return + } + if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + cameraPermission.launch(Manifest.permission.CAMERA); return + } + scan.launch(null) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Shizuku.addRequestPermissionResultListener(desktopPermissionResult) + if (TeslaHttpCompatibility.config(this).let { it.enabled && it.mode == TeslaHttpMode.ROOT }) RootAccess.checkOnStartup() + com.shilapi.xcertplay.hud.BydNavigationOutputs.onAppOpened(applicationContext) + ServerWindow.showSystemBars(window) + refreshAuthentication() + selectedPrivateKey = savedInstanceState?.getString("selectedPrivateKey")?.let(Uri::parse) + pendingAndroidAutoSourceId = savedInstanceState?.getString("pendingAndroidAutoSourceId") + aaConnection = AaConnection.entries.firstOrNull { it.name == savedInstanceState?.getString("aaConnection") } ?: AaConnection.LOCAL + awaitingTeslaVpnConsent = savedInstanceState?.getBoolean("awaitingTeslaVpnConsent") ?: false + page = savedInstanceState?.getString("page") ?: intent.getStringExtra("page") + ?: if (setupError == null) "service" else "settings" + if (savedInstanceState == null && page == "settings" && setupError != null) { + settingsModel.open(SettingsCategory.AUTHENTICATION) + } + createComposeContent() + startWebServiceIfReady() + handleWirelessRecovery() + authenticationImport.state.observe(this) { state -> + refreshAuthentication() + render() + state.result?.let { + startWebServiceIfReady() + toast(it); authenticationImport.consumeResult() + } + } + if (savedInstanceState == null && !handleUsbConnectionIntent(intent)) handleAuthenticationIntent(intent) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when { + page == "settings" && settingsModel.state.value.category != null -> settingsModel.open(null) + page in sourceDetailPages -> navigate("phone") + page != "service" -> navigate("service") + else -> moveTaskToBack(true) + } + } + }) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent); setIntent(intent) + if (!handleUsbConnectionIntent(intent) && !handleAuthenticationIntent(intent)) navigate(intent.getStringExtra("page") ?: "service") + } + override fun onSaveInstanceState(outState: Bundle) { + outState.putString("page", page) + outState.putString("selectedPrivateKey", selectedPrivateKey?.toString()) + outState.putString("pendingAndroidAutoSourceId", pendingAndroidAutoSourceId) + outState.putString("aaConnection", aaConnection.name) + outState.putBoolean("awaitingTeslaVpnConsent", awaitingTeslaVpnConsent) + super.onSaveInstanceState(outState) + } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + render() + } + override fun onResume() { + super.onResume(); projectionPlugins(this).refresh(); refreshAuthentication(); render(); ServerWindow.showSystemBars(window); handler.removeCallbacks(tick); handler.post(tick) + WebSession.setPhoneConnectHandler { + if (setupError != null) setupError!! + else { + connect(AirPlayPersistence.loadWirelessEnabled(this)) + getString(UiR.string.ui_iphone_connection_requested_complete_any_device_selection_or_permission) + } + } + // Back from the car settings: refresh the car hotspot reminder on the home page. + if (!initialLaunch) { refreshUiState() } + if (initialLaunch) { + initialLaunch = false + if (setupError == null && !WheelPlayBootstrap.importing && selectedPrivateKey == null && + intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_VIEW && + !projectionSessions.hasSession() && + selectedHomeSource().id == ProjectionSourceIds.CARPLAY && WheelPlayPreferences.autoConnect(this) && intent.getStringExtra("page") == null) { + handler.post { connect(AirPlayPersistence.loadWirelessEnabled(this)) } + } + } + } + override fun onPause() { WebSession.setPhoneConnectHandler(null); handler.removeCallbacks(tick); super.onPause() } + override fun onDestroy() { Shizuku.removeRequestPermissionResultListener(desktopPermissionResult); dialogs.cancel(); settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } + + private fun normalizedPage(value: String) = when (value) { + "home" -> "phone" + "wireless-recovery" -> SourcePage.CARPLAY.id + "about" -> "settings" + "phone", "settings" -> value + in sourceDetailPages -> value + else -> "service" + } + + private fun navigate(destination: String) { + val target = normalizedPage(destination) + if (target == "settings") { + settingsModel.open(if (setupError != null) SettingsCategory.AUTHENTICATION else null) + } + page = target + if (destination == "wireless-recovery") confirmWirelessReset() + } + + private var uiState by mutableStateOf(WheelPlayUiState()) + private val snackbar = SnackbarHostState() + private val dialogs = WheelPlayDialogs() + + private fun createComposeContent() { + page = normalizedPage(page) + refreshUiState() + refreshSettings() + setContent { + WheelPlayScreen(page, aaConnection, uiState, settingsModel, settingsActions, settingsDialogs, dialogs, snackbar, ::dispatch) + } + } + + + private fun dispatch(action: UiAction, argument: String?) { + when (action) { + UiAction.NAVIGATE -> { + // Reselecting a tab preserves its scroll and settings subpage. + if (page != argument) page = normalizedPage(argument ?: "service") + } + UiAction.AA_MODE -> aaConnection = AaConnection.valueOf(requireNotNull(argument)) + UiAction.BROWSER -> openLocalBrowserExperience() + UiAction.SCAN -> scanPairing() + UiAction.CHOOSE_HOME -> chooseHomeSource() + UiAction.HOME_SETTINGS -> navigate(selectedHomeSource().page.id) + UiAction.HOME_CONNECT -> { + val source = selectedHomeSource() + when (source.id) { + ProjectionSourceIds.CARPLAY -> connect(AirPlayPersistence.loadWirelessEnabled(this)) + ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.ANDROID_DESKTOP -> navigate(source.page.id) + ProjectionSourceIds.VNC -> configureVnc() + else -> startProjectionSource(source.id) + } + } + UiAction.HOME_STOP -> { + val active = projectionSessions.snapshot() + if (projectionSessions.hasSession()) projectionSessions.stop(active).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this))) + refreshUiState() + } } else CarPlayBackgroundSession.stop { runOnUiThread { refreshUiState() } } + } + UiAction.STOP_SERVICE -> { + startService(Intent(this, WheelPlaySessionService::class.java).setAction(WheelPlaySessionService.ACTION_STOP)) + finishAndRemoveTask() + } + UiAction.CHOOSE_PHONE -> choosePhone() + UiAction.CARPLAY_CONNECT -> connect(true) + UiAction.CARPLAY_USB -> connect(false) + UiAction.CARPLAY_STOP -> CarPlayBackgroundSession.stop { runOnUiThread { + startWebServiceIfReady(); WebSession.setStage(UiText(UiR.string.ui_iphone_disconnected)); refreshUiState() + } } + UiAction.HOTSPOT -> openCarWifiSettings() + UiAction.AA_LOCAL -> connectLocalAndroidAuto() + UiAction.AA_USB -> startProjectionSource(ProjectionSourceIds.ANDROID_AUTO_USB) + UiAction.AA_NETWORK -> configureAndroidAutoTcp() + UiAction.AA_HELP -> showLocalAndroidAutoSetup() + UiAction.VNC -> configureVnc() + UiAction.STOP_SOURCE -> stopSource(requireNotNull(argument)) + UiAction.PLUGIN_REFRESH -> { projectionPlugins(this).refresh(); refreshUiState() } + UiAction.PLUGIN_ENABLE, UiAction.PLUGIN_DISABLE, UiAction.PLUGIN_SETTINGS, UiAction.PLUGIN_CONNECT -> { + val registry = projectionPlugins(this) + val plugin = registry.entries().find { it.id == argument } + if (plugin == null) { toast(getString(UiR.string.plugin_unavailable)); return } + when (action) { + UiAction.PLUGIN_ENABLE -> settingsDialogs.confirm(getString(UiR.string.plugin_enable), + getString(UiR.string.plugin_enable_prompt, plugin.name, plugin.component.packageName)) { + runCatching { registry.setEnabled(plugin, true) }.onFailure { toast(getString(UiR.string.plugin_unavailable)) } + refreshUiState() + } + UiAction.PLUGIN_DISABLE -> { registry.setEnabled(plugin, false); refreshUiState() } + UiAction.PLUGIN_SETTINGS -> plugin.settings?.let { component -> + runCatching { startActivity(Intent().setComponent(component)) }.onFailure { toast(getString(UiR.string.plugin_unavailable)) } + } + UiAction.PLUGIN_CONNECT -> startProjectionSource(plugin.id) + else -> Unit + } + } + UiAction.DESKTOP_DISPLAY -> configureDesktopDisplay() + UiAction.DESKTOP_SHIZUKU -> openSystem(packageManager.getLaunchIntentForPackage(ShizukuDesktopBackend.PACKAGE) + ?: Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/download/"))) + UiAction.DESKTOP_AUTHORIZE -> runCatching { + if (Shizuku.pingBinder() && Shizuku.getVersion() >= 13) Shizuku.requestPermission(40) + else toast((desktopProvider().dependencyStatus() as? SourceAvailability.Unavailable)?.reason.orEmpty()) + }.onFailure { toast(getString(UiR.string.desktop_not_authorized)) }.let { Unit } + UiAction.DESKTOP_APP -> chooseDesktopApp() + UiAction.DESKTOP_START -> startProjectionSource(ProjectionSourceIds.ANDROID_DESKTOP) + UiAction.DEMO -> chooseDiagnosticSource() + } + refreshUiState() + } + + private fun stopSource(group: String) { + val session = projectionSessions.snapshot()?.takeIf { sessionBelongsToSource(it, group) } + val state = projectionSessions.stateSnapshot() + val pendingHere = state.source?.id?.let { sourcePageFor(it)?.group == group || it == group || belongsToSource(it, group) } == true && projectionSessions.hasSession() + if (session != null || pendingHere) projectionSessions.stop(session).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this))) + refreshUiState() + } } + } + + /** Read backend state on the main thread; composition only consumes this immutable snapshot. */ + private fun refreshUiState() { + val desktop = desktopProvider() + val desktopState = desktop.probe() + val state = projectionSessions.stateSnapshot() + val active = projectionSessions.snapshot() + val running = projectionSessions.hasSession() + val carPlayRunning = CarPlayBackgroundSession.hasSession() + val registry = projectionPlugins(this) + val selected = selectedHomeSource() + val currentId = state.source?.id?.takeIf { running } ?: if (carPlayRunning) ProjectionSourceIds.CARPLAY else null + val currentPage = currentId?.let(::sourcePageFor) + val phase = when { + running -> state.message(this) + CarPlayBackgroundSession.active -> getString(UiR.string.projection_state_running) + else -> getString(UiR.string.projection_state_connecting) + } + val overview = buildString { + append(getString(UiR.string.source_home_selection, selected.name)) + if (currentId != null) append("\n" + getString(UiR.string.source_current_session, + state.source?.displayName?.takeIf { running } ?: getString(UiR.string.source_carplay), phase)) + } + val flags = SourcePage.entries.associateWith { sourcePage -> + val labels = mutableListOf() + if (selected.page == sourcePage) labels += getString(UiR.string.source_home_default) + if (sourcePage == currentPage) labels += if (state.failure != null) getString(UiR.string.source_needs_attention) else phase + else if (state.failure != null && state.source?.id?.let(::sourcePageFor) == sourcePage) labels += getString(UiR.string.source_needs_attention) + else when (sourcePage) { + SourcePage.ICCOA -> { + val plugins = registry.entriesForSource(ProjectionPlugin.SOURCE_ICCOA_CARLINK) + labels += getString(when { + plugins.isEmpty() -> UiR.string.source_install_required + plugins.none { it.problem == null } -> UiR.string.source_update_required + plugins.none { registry.available(it) } -> UiR.string.source_enable_required + plugins.any { registry.lastFailure(it.id) != null } -> UiR.string.source_needs_attention + else -> UiR.string.source_ready + }) + } + SourcePage.DESKTOP -> if (desktopState !is SourceAvailability.Ready) labels += getString(UiR.string.source_setup_required) + else -> Unit + } + labels.joinToString(" · ") + } + val sessions = SourcePage.entries.filter { it != SourcePage.CARPLAY }.mapNotNull { sourcePage -> + val group = sourcePage.group + val session = active?.takeIf { sessionBelongsToSource(it, group) } + val belongs = state.source?.id?.let { sourcePageFor(it)?.group == group || it == group || belongsToSource(it, group) } == true + val hasPlugins = if (sourcePage == SourcePage.ICCOA) registry.entriesForSource(ProjectionPlugin.SOURCE_ICCOA_CARLINK).isNotEmpty() else registry.entries().isNotEmpty() + if (sourcePage in setOf(SourcePage.ICCOA, SourcePage.PLUGINS) && !hasPlugins && session == null && !(belongs && (running || state.failure != null))) null + else group to SessionUi(when { + belongs && (state.failure != null || (session == null && running)) -> state.message(this) + session != null -> session.statusText ?: state.message(this) + else -> getString(UiR.string.source_idle) + }, session != null || (belongs && running)) + }.toMap() + val plugins = registry.entries().map { plugin -> PluginUi( + plugin.id, plugin.name, plugin.component.packageName, plugin.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK, + registry.enabled(plugin), plugin.problem, registry.lastFailure(plugin.id)?.let { getString(UiR.string.projection_failed, it) }, + plugin.settings != null, !(running && (state.source?.id == plugin.id || active?.descriptor?.id == plugin.id)), + ) } + val addresses = if (WebSession.running && WebSession.error == null) WebSession.addresses(this) else emptyList() + val primary = addresses.firstOrNull() + val activeName = active?.descriptor?.displayName ?: if (carPlayRunning) getString(UiR.string.source_carplay) else state.source?.displayName?.takeIf { running } + val selectedActive = (selected.id == ProjectionSourceIds.CARPLAY && carPlayRunning) || active?.let { + it.descriptor.id == selected.id || (selected.id in setOf(ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.VNC) && belongsToSource(it.descriptor.id, selected.id)) + } == true + val config = desktop.displayConfig() + uiState = WheelPlayUiState( + home = HomeUi( + address = WebSession.error ?: if (WebSession.running) primary?.url ?: getString(UiR.string.ui_no_local_network_address_found_connect_to_wi_fi) else getString(UiR.string.ui_starting_service), + addressDescription = primary?.let { it.description(this) + if (it.hostname.isNotEmpty()) getString(UiR.string.address_direct_ip, it.ipUrl) else "" }.orEmpty(), + otherAddresses = addresses.drop(1).map { AddressUi(it.description(this), it.url) }, + browserEnabled = WebSession.running && BrowserExperienceLink.local(WebSession.code, WebSession.httpPort) != null, + code = if (WebSession.running) WebSession.code else "— — — — — —", viewerConnected = WebSession.hasViewer, + sourceName = getString(UiR.string.home_switch_source, selected.name), + stage = when { + active != null -> getString(UiR.string.home_active_source, active.descriptor.displayName, state.failure?.message(this) ?: active.statusText ?: getString(UiR.string.projection_active, active.descriptor.displayName)) + carPlayRunning -> getString(UiR.string.home_active_source, getString(UiR.string.source_carplay), WebSession.stage(this)) + selected.id == ProjectionSourceIds.CARPLAY -> setupError ?: if (WheelPlayPreferences.phoneAddress(this) != null) getString(UiR.string.status_selected_phone, WheelPlayPreferences.phoneName(this)) else getString(UiR.string.ui_waiting_to_connect_to_iphone) + registry.lastFailure(selected.id) != null -> getString(UiR.string.projection_failed, registry.lastFailure(selected.id)) + state.source?.id == selected.id -> state.message(this) + else -> getString(UiR.string.projection_waiting) + }, + connectLabel = getString(if (selected.id in setOf(ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.VNC, ProjectionSourceIds.ANDROID_DESKTOP)) UiR.string.home_configure_connect else UiR.string.ui_connect), + showConnect = !selectedActive && !(selected.id == ProjectionSourceIds.CARPLAY && WheelPlayPreferences.phoneAddress(this) == null), + connectEnabled = selected.id != ProjectionSourceIds.CARPLAY || (setupError == null && !WheelPlayBootstrap.importing), + stopLabel = activeName?.let { getString(UiR.string.home_stop_source, it) }, showAudioHint = selected.id == ProjectionSourceIds.CARPLAY, + ), + sources = SourcesUi( + overview, flags, sessions, plugins, + carPlayStatus = when { + active?.descriptor?.id?.let { it != ProjectionSourceIds.CARPLAY } == true -> active.descriptor.displayName + setupError != null -> getString(UiR.string.ui_authentication_setup_needs_attention) + CarPlayBackgroundSession.active -> getString(UiR.string.ui_carplay_connected) + carPlayRunning -> getString(UiR.string.ui_connecting_to_iphone) + WheelPlayPreferences.phoneAddress(this) != null -> getString(UiR.string.status_selected_phone, WheelPlayPreferences.phoneName(this)) + else -> getString(UiR.string.ui_no_iphone_selected) + }, carPlayRunning = carPlayRunning, carPlayEnabled = setupError == null && !WheelPlayBootstrap.importing, + phone = selectedPhoneName(), setupError = setupError, + hotspotHint = if (carHotspotOff()) getString(UiR.string.hotspot_disabled_hint, AirPlayPersistence.loadManualHotspotSsid(this)) else null, + desktopStatus = if (desktop.preparing) getString(UiR.string.desktop_preparing) else (desktopState as? SourceAvailability.Unavailable)?.reason ?: desktop.lastFailure ?: getString(UiR.string.desktop_ready), + desktopEnabled = !desktop.preparing && desktopState is SourceAvailability.Ready && active?.descriptor?.id != ProjectionSourceIds.ANDROID_DESKTOP, + desktopDisplay = getString(UiR.string.desktop_display_summary, config.width, config.height, config.dpi), desktopApp = desktop.selectedApp(), + ), + ) + } + + private fun render() { + refreshUiState() + refreshSettings() + } + + private fun refreshSettings() { + settingsModel.reload() + refreshSettingsRuntime() + } + + private fun refreshSettingsRuntime() { + val session = CarPlayBackgroundSession.snapshot() + val audioStatus = when { + session == null -> getString(UiR.string.ui_current_no_iphone_connected) + AirPlayPersistence.loadBrowserAudioPlayback(this) && WebSession.audio.playbackReady && session.sink.isRemoteMediaAvailable() -> getString(UiR.string.ui_current_output_head_unit_browser) + AirPlayPersistence.loadBrowserAudioPlayback(this) -> getString(UiR.string.ui_current_output_android_browser_not_ready_or_offline) + else -> getString(UiR.string.ui_current_output_android) + } + val source = AirPlayPersistence.loadMicrophoneSource(this) + val microphoneStatus = when { + source == CarPlayMicrophoneSource.DISABLED -> getString(UiR.string.ui_current_microphone_disabled) + session == null -> getString(UiR.string.ui_current_no_iphone_connected) + source == CarPlayMicrophoneSource.BROWSER && WebSession.audio.microphoneReady -> getString(UiR.string.ui_input_browser_captured_on_demand) + checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED -> getString(UiR.string.ui_android_microphone_permission_is_missing_voice_input_is_unavailable) + source == CarPlayMicrophoneSource.BROWSER -> getString(UiR.string.ui_input_android_browser_microphone_not_ready) + else -> getString(UiR.string.ui_input_android_captured_on_demand) + } + val compatibility = TeslaHttpCompatibility.status + val teslaConfig = TeslaHttpCompatibility.config(this) + val teslaMessage = if (!teslaConfig.enabled) getString(UiR.string.ui_disabled) + else if (!WebSession.running && setupError != null) getString(UiR.string.ui_starts_after_authentication_resources_are_imported) + else compatibility.error ?: compatibility.address?.let { + if (compatibility.mode == TeslaHttpMode.VPN) getString(UiR.string.status_rootless_address_configured, it) + else getString(UiR.string.status_root_routing_enabled, it, compatibility.downstreams.joinToString()) + } ?: getString(UiR.string.status_preparing_mode, getString(teslaConfig.mode.labelRes)) + settingsModel.updateRuntime(SettingsRuntime( + authenticationError = setupError, importing = WheelPlayBootstrap.importing, webBusy = webBusy, + awaitingVpnConsent = awaitingTeslaVpnConsent, exporting = exportInProgress, + rootGranted = RootAccess.granted, rootChecking = RootAccess.checking, rootStatus = RootAccess.status.resolve(this), + teslaStatus = teslaMessage, audioStatus = audioStatus, microphoneStatus = microphoneStatus, + version = version(), fingerprint = WebSession.tls?.fingerprint + )) + } + + private val settingsActions by lazy { + SettingsActions( + choice = { setting, index -> changeSettings { applySettingsEffect(settingsModel.choose(setting, index)) }?.let(::toast) }, + toggle = { setting, checked -> changeSettings { applySettingsEffect(settingsModel.toggle(setting, checked)) }?.let(::toast) }, + text = { setting, value -> changeSettings { applySettingsEffect(settingsModel.edit(setting, value)) } }, + credentials = { ssid, password -> changeSettings { settingsModel.saveCredentials(ssid, password) } }, + margins = { slot, margins -> changeSettings { applySettingsEffect(settingsModel.saveMargins(slot, margins)) }?.let(::toast) }, + resetBranding = { includeName -> changeSettings { settingsModel.resetBranding(includeName) }?.let(::toast) }, + command = { command -> changeSettings { settingsCommand(command) }?.let(::toast) }, + renameBrowser = { id, name -> changeSettings { settingsModel.renameBrowser(id, name) }?.let(::toast) }, + removeBrowser = { id -> changeSettings { settingsModel.removeBrowser(id) }?.let(::toast) } + ) + } + + private fun changeSettings(action: () -> Unit): String? = runCatching { + action(); refreshSettings(); refreshUiState() + }.exceptionOrNull()?.let { it.userMessage(this) } + + private fun applySettingsEffect(effect: SettingsEffect) { + when (effect) { + SettingsEffect.None -> Unit + is SettingsEffect.Reconnect -> if (CarPlayBackgroundSession.hasSession()) { + val wireless = effect.wireless ?: AirPlayPersistence.loadWirelessEnabled(this) + if (effect.stopBeforeConnect) { + // Release the old backend before checking the new hotspot: its P2P group + // can prevent the user from enabling the system hotspot. + CarPlayBackgroundSession.stop { runOnUiThread { connect(wireless) } } + } else connect(wireless) + } + is SettingsEffect.WebUpdate -> configureWeb(effect.message, effect.operation) + is SettingsEffect.TeslaEnabled -> setTeslaHttpEnabled(effect.enabled) + } + } + + private fun settingsCommand(command: SettingsCommand) { + when (command) { + SettingsCommand.SELECT_PHONE -> choosePhone() + SettingsCommand.HOTSPOT_SETTINGS -> openCarWifiSettings() + SettingsCommand.REQUEST_ROOT -> configureWeb(getString(UiR.string.ui_root_access_granted)) { RootAccess.request()?.resolve(this).also { TeslaHttpCompatibility.retry() } } + SettingsCommand.RETRY_ROOT -> TeslaHttpCompatibility.retry() + SettingsCommand.ENABLE_VPN -> setTeslaHttpEnabled(true) + SettingsCommand.SELECT_ICON -> oemImagePicker.launch("image/*") + SettingsCommand.IMPORT_APK -> if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationApk.launch(arrayOf("*/*")) } + SettingsCommand.IMPORT_FILES -> if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationKey.launch(arrayOf("*/*")) } + SettingsCommand.SELECT_PEM -> pemCertificatePicker.launch(arrayOf("*/*")) + SettingsCommand.SELECT_PKCS12 -> pkcs12Picker.launch(arrayOf("*/*")) + SettingsCommand.APP_PERMISSIONS -> openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) + SettingsCommand.BLUETOOTH_SETTINGS -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + SettingsCommand.WIRELESS_HELP -> wirelessHelp() + SettingsCommand.EXPORT_REPORT -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) exportDiagnostics() else chooseReportDestination() + SettingsCommand.UPSTREAM_PROJECT -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/shihabal3amri/DiPlay"))) + SettingsCommand.SOURCE -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/fython/wheelplay"))) + SettingsCommand.DOWNLOAD -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://shihabal3amri.github.io/DiPlay/"))) + } + } + + private fun homeSources(): List = listOf( + HomeSource(ProjectionSourceIds.CARPLAY, getString(UiR.string.source_carplay), SourcePage.CARPLAY), + HomeSource(ProjectionSourceIds.ANDROID_AUTO, "Android Auto", SourcePage.ANDROID_AUTO), + HomeSource(ProjectionSourceIds.VNC, "VNC", SourcePage.VNC), + HomeSource(ProjectionSourceIds.ANDROID_DESKTOP, getString(UiR.string.desktop_title), SourcePage.DESKTOP), + ) + projectionPlugins(this).let { registry -> + registry.entries().filter { registry.available(it) }.map { plugin -> + HomeSource(plugin.id, plugin.name, + if (plugin.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK) SourcePage.ICCOA else SourcePage.PLUGINS) + } + } + + private fun selectedHomeSource(): HomeSource { + val sources = homeSources() + val saved = WheelPlayPreferences.homeSource(this) + return sources.find { it.id == saved } ?: sources.first().also { + WheelPlayPreferences.saveHomeSource(this, it.id) + } + } + + private fun chooseHomeSource() { + projectionPlugins(this).refresh() + val sources = homeSources() + dialogs.choices(getString(UiR.string.home_choose_source), sources.map { it.name }, sources.indexOf(selectedHomeSource())) { index -> + WheelPlayPreferences.saveHomeSource(this, sources[index].id) + refreshUiState() + } + } + + private fun openLocalBrowserExperience() { + val url = BrowserExperienceLink.local(WebSession.code, WebSession.httpPort) + if (!WebSession.running || url == null) { + toast(getString(UiR.string.ui_wait_for_the_web_service_to_start_before_opening)) + return + } + runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } + .onFailure { toast(getString(UiR.string.ui_no_browser_available_to_open_this_page)) } + } + + private fun sourcePageFor(id: String): SourcePage? = when { + id == ProjectionSourceIds.CARPLAY -> SourcePage.CARPLAY + id == ProjectionSourceIds.ANDROID_DESKTOP -> SourcePage.DESKTOP + id == ProjectionSourceIds.ANDROID_AUTO || belongsToSource(id, ProjectionSourceIds.ANDROID_AUTO) -> SourcePage.ANDROID_AUTO + id == ProjectionSourceIds.VNC || belongsToSource(id, ProjectionSourceIds.VNC) -> SourcePage.VNC + belongsToSource(id, ProjectionSourceIds.DEMO) -> SourcePage.DIAGNOSTICS + id.startsWith(ProjectionSourceIds.PLUGIN_PREFIX) -> { + val plugin = (projectionSessions.snapshot() as? RemotePluginSession)?.plugin?.takeIf { it.id == id } + ?: projectionPlugins(this).entries().find { it.id == id } + if (plugin?.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK) SourcePage.ICCOA else SourcePage.PLUGINS + } + else -> null + } + + private val desktopPermissionResult = Shizuku.OnRequestPermissionResultListener { code, _ -> + if (code == 40) runOnUiThread { if (!isDestroyed) refreshUiState() } + } + private fun desktopProvider(): AndroidDesktopSource { + registerProjectionSources(this) + return projectionSessions.sources().filterIsInstance().single() + } + private fun configureDesktopDisplay() { + val source = desktopProvider() + val config = source.displayConfig() + val defaults = DesktopDisplayConfig() + dialogs.form(getString(UiR.string.desktop_display_settings), getString(UiR.string.desktop_display_hint), listOf( + WheelPlayDialogs.Field("width", getString(UiR.string.desktop_width), config.width.toString(), numeric = true), + WheelPlayDialogs.Field("height", getString(UiR.string.desktop_height), config.height.toString(), numeric = true), + WheelPlayDialogs.Field("dpi", getString(UiR.string.desktop_dpi), config.dpi.toString(), numeric = true), + ), resetLabel = getString(UiR.string.desktop_display_reset), defaults = listOf(defaults.width, defaults.height, defaults.dpi).map { it.toString() }) { values, _ -> + val selected = runCatching { DesktopDisplayConfig(values[0].toInt(), values[1].toInt(), values[2].toInt()) }.getOrNull() + if (selected == null) getString(UiR.string.desktop_display_invalid, DesktopDisplayConfig.MIN_SIZE, DesktopDisplayConfig.MAX_SIZE, + DesktopDisplayConfig.MAX_PIXELS, DesktopDisplayConfig.MIN_DPI, DesktopDisplayConfig.MAX_DPI) + else { source.configureDisplay(selected); refreshUiState(); null } + } + } + + private fun chooseDesktopApp() { + val apps = packageManager.queryIntentActivities(AndroidDesktopSource.launcherIntent(), 0) + .filter { it.activityInfo.isDesktopLaunchable }.distinctBy { it.activityInfo.desktopComponent } + .sortedBy { it.loadLabel(packageManager).toString().lowercase() } + if (apps.isEmpty()) { toast(getString(UiR.string.desktop_no_apps)); return } + dialogs.choices(getString(UiR.string.desktop_choose_app), apps.map { "${it.loadLabel(packageManager)} (${it.activityInfo.packageName})" }) { index -> + runCatching { desktopProvider().selectApp(apps[index].activityInfo.desktopComponent.flattenToString()) } + .onFailure { toast(getString(UiR.string.desktop_choose_app)) } + refreshUiState() + } + } + + private fun configureVnc() { + registerProjectionSources(this) + val source = projectionSessions.sources().filterIsInstance().single() + val endpoint = source.endpoint() + dialogs.form(getString(UiR.string.vnc_connect), getString(UiR.string.vnc_security_hint), listOf( + WheelPlayDialogs.Field("host", getString(UiR.string.vnc_host), endpoint.host), + WheelPlayDialogs.Field("port", getString(UiR.string.vnc_port), endpoint.port.toString(), numeric = true), + WheelPlayDialogs.Field("password", getString(UiR.string.vnc_password), "", secret = true), + ), getString(UiR.string.vnc_read_only), endpoint.readOnly) { values, readOnly -> + val target = VncEndpoint(values[0].trim(), values[1].toIntOrNull() ?: 0, readOnly) + val secret = values[2].toCharArray() + try { + when { + !target.valid() -> getString(UiR.string.vnc_invalid_address) + secret.size > 8 || secret.any { it.code !in 1..255 } -> getString(UiR.string.vnc_password_hint) + else -> { source.configure(target, secret); startProjectionSource(ProjectionSourceIds.VNC_TCP); null } + } + } finally { secret.fill('\u0000') } + } + } + + private fun belongsToSource(id: String, group: String) = id.startsWith("$group-") + + private fun sessionBelongsToSource(session: ProjectionSession, group: String): Boolean = + if (group == ProjectionSourceIds.ICCOA) (session as? RemotePluginSession)?.plugin?.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK + else session.descriptor.id == group || belongsToSource(session.descriptor.id, group) + + private fun chooseDiagnosticSource() { + registerProjectionSources(this) + val sources = projectionSessions.sources().filter { belongsToSource(it.descriptor.id, ProjectionSourceIds.DEMO) } + dialogs.choices(getString(UiR.string.projection_choose_demo), sources.map { it.descriptor.displayName }) { index -> + startProjectionSource(sources[index].descriptor.id) + } + } + + private fun configureAndroidAutoTcp() { + dialogs.form(getString(UiR.string.aa_tcp_title), getString(UiR.string.aa_tcp_hint), listOf( + WheelPlayDialogs.Field("host", "IP", getSharedPreferences("projection", 0).getString("aa_host", "").orEmpty()), + )) { values, _ -> + val address = values.single().trim() + if (!com.shilapi.xcertplay.projection.androidauto.AapTcpConnection.validAddress(address)) getString(UiR.string.aa_invalid_address) + else { + getSharedPreferences("projection", 0).edit().putString("aa_host", address).putInt("aa_port", 5277).apply() + startProjectionSource(ProjectionSourceIds.ANDROID_AUTO_TCP); null + } + } + } + + private fun connectLocalAndroidAuto() { + projectionSessions.snapshot()?.takeIf { it.descriptor.id == LocalAndroidAuto.SOURCE_ID }?.let { + toast(it.statusText ?: getString(UiR.string.projection_active, it.descriptor.displayName)) + return + } + if (!LocalAndroidAuto.isInstalledAndEnabled(this) || + !getSharedPreferences("projection", 0).getBoolean(LocalAndroidAuto.SETUP_SEEN, false)) { + showLocalAndroidAutoSetup() + return + } + registerProjectionSources(this) + startProjectionSource(LocalAndroidAuto.SOURCE_ID) + } + + private fun showLocalAndroidAutoSetup() { + val available = LocalAndroidAuto.isInstalledAndEnabled(this) + val actions = mutableListOf(WheelPlayDialogs.Action(getString(UiR.string.aa_local_settings), ::openLocalAndroidAutoSettings)) + if (available) actions.add(0, WheelPlayDialogs.Action(getString(UiR.string.aa_local_connect)) { + getSharedPreferences("projection", 0).edit().putBoolean(LocalAndroidAuto.SETUP_SEEN, true).apply() + connectLocalAndroidAuto() + }) + dialogs.message(getString(UiR.string.aa_local_title), getString(if (available) UiR.string.aa_local_setup else UiR.string.aa_local_unavailable), actions) + } + + private fun openLocalAndroidAutoSettings() { + for (intent in LocalAndroidAuto.settingsIntents()) { + try { + localAndroidAutoSettings.launch(intent) + return + } catch (_: android.content.ActivityNotFoundException) { + // AA versions expose different settings entry points. + } catch (_: SecurityException) { + // Fall back when an AA activity is not exported. + } + } + toast(getString(UiR.string.aa_local_settings_unavailable)) + } + + private fun startProjectionSource(id: String) { + val homeId = when { + belongsToSource(id, ProjectionSourceIds.ANDROID_AUTO) -> ProjectionSourceIds.ANDROID_AUTO + belongsToSource(id, ProjectionSourceIds.VNC) -> ProjectionSourceIds.VNC + else -> id + } + if (homeSources().any { it.id == homeId }) WheelPlayPreferences.saveHomeSource(this, homeId) + if (isAndroidAutoSource(id) && + AirPlayPersistence.loadMicrophoneSource(this) == CarPlayMicrophoneSource.ANDROID && + checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + pendingAndroidAutoSourceId = id + androidAutoMicrophonePermission.launch(Manifest.permission.RECORD_AUDIO) + return + } + startProjectionSourceNow(id) + } + + private fun isAndroidAutoSource(id: String) = belongsToSource(id, ProjectionSourceIds.ANDROID_AUTO) + + private fun startProjectionSourceNow(id: String) { + registerProjectionSources(this) + startWebServiceIfReady() + projectionSessions.switchTo(id).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@WheelPlayActivity))) + refreshUiState() + } } + } + + private fun refreshAuthentication() { + setupError = if (WheelPlayBootstrap.importing) getString(UiR.string.ui_importing_authentication_resources_please_wait) + else runCatching { WheelPlayBootstrap.ensure(this) }.exceptionOrNull()?.let { + getString(UiR.string.ui_authentication_resources_are_not_ready_import_files_or_a_2) + } + } + + private fun startWebServiceIfReady() { + startForegroundService(Intent(this, WheelPlaySessionService::class.java)) + } + + private fun canImportAuthentication(): Boolean { + if (WheelPlayBootstrap.importing) { toast(getString(UiR.string.ui_importing_authentication_resources_please_wait)); return false } + if (CarPlayBackgroundSession.hasSession()) { toast(getString(UiR.string.ui_disconnect_the_iphone_before_importing_authentication_resources)); return false } + return true + } + + private fun launchAuthenticationPicker(launch: () -> Unit) { + runCatching(launch).onFailure { + selectedPrivateKey = null + toast(getString(UiR.string.ui_could_not_open_the_file_picker_enable_the_system)) + } + } + + private fun handleAuthenticationIntent(incoming: Intent): Boolean { + if (incoming.action != Intent.ACTION_SEND && incoming.action != Intent.ACTION_VIEW) return false + settingsModel.open(SettingsCategory.AUTHENTICATION) + page = "settings" + val uri = AuthenticationImportIntents.apk(incoming) + if (uri == null) { toast(getString(UiR.string.ui_could_not_read_apk_import_it_using_the_file)); return true } + if (!canImportAuthentication()) return true + settingsDialogs.confirm(getString(UiR.string.ui_import_authentication_resources_from_this_apk), getString(UiR.string.ui_wheelplay_reads_only_the_two_authentication_resources_from_the)) { + authenticationImport.import(apk = uri) + } + return true + } + + private fun setTeslaHttpEnabled(enabled: Boolean) { + if (enabled && TeslaHttpCompatibility.config(this).mode == TeslaHttpMode.VPN) { + if (awaitingTeslaVpnConsent) return + try { + val consent = CarPlayVpnService.prepare(this) + if (consent != null) { + awaitingTeslaVpnConsent = true + teslaVpnConsent.launch(consent) + render() + return + } + } catch (error: Exception) { + awaitingTeslaVpnConsent = false + toast(getString(UiR.string.error_vpn_permission_request, error.userMessage(this))) + render() + return + } + } + applyTeslaHttpEnabled(enabled) + } + + private fun applyTeslaHttpEnabled(enabled: Boolean) { + TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(enabled = enabled)) + if (!WebSession.running) startWebServiceIfReady() + handler.post { render() } + } + + // The car hotspot link needs the hotspot on; WheelPlay only checks it (turning it on needs ADB-only permission). + private fun carHotspotOff(): Boolean = + AirPlayPersistence.loadWirelessHotspotMode(this) == WirelessHotspotMode.MANUAL && + com.shilapi.xcertplay.network.CarHotspotStatus.isEnabled(this) == false + + private fun carHotspotOffDialog() { + dialogs.message(getString(UiR.string.ui_hotspot_is_off), getString(UiR.string.hotspot_enable_message, AirPlayPersistence.loadManualHotspotSsid(this)), listOf( + WheelPlayDialogs.Action(getString(UiR.string.ui_open_system_settings), ::openCarWifiSettings), + WheelPlayDialogs.Action(getString(UiR.string.ui_connect)) { connect(true) }, + )) + } + + private fun openCarWifiSettings() { + val hotspot = Intent("com.android.settings.WIFI_TETHER_SETTINGS") + val target = packageManager.resolveActivity(hotspot, 0)?.activityInfo?.packageName + if (target == null) { + openSystem(Intent(Settings.ACTION_WIRELESS_SETTINGS)) + return + } + if (target == "com.byd.carsettings") { + runCatching { startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)) } + } + if (runCatching { startActivity(hotspot) }.isSuccess) return + openSystem(Intent(Settings.ACTION_WIRELESS_SETTINGS)) + } + + private fun selectedPhoneName() = if (WheelPlayPreferences.phoneAddress(this) == null) getString(UiR.string.ui_not_selected) else WheelPlayPreferences.phoneName(this) + + private fun readCertificateFile(uri: Uri): ByteArray = contentResolver.openInputStream(uri)?.use(LanTls::readImport) + ?: error(getString(UiR.string.ui_could_not_open_the_selected_file)) + + private fun configureWeb(success: String, action: () -> String?) { + if (webBusy) { toast(getString(UiR.string.ui_updating_settings_please_wait)); return } + webBusy = true + render() + Thread({ + val failure = runCatching(action).getOrElse { getString(UiR.string.error_web_settings_update, it.userMessage(this)) } + runOnUiThread { + webBusy = false + if (!isFinishing && !isDestroyed) { toast(failure ?: success); render() } + } + }, "web-configure").start() + } + + private fun choosePkcs12Identity(uri: Uri, password: CharArray) { + if (webBusy) { password.fill('\u0000'); toast(getString(UiR.string.ui_updating_https_please_wait)); return } + webBusy = true + Thread({ + var bytes: ByteArray? = null + val result = runCatching { + readCertificateFile(uri).also { bytes = it }.let { LanTls.pkcs12Aliases(it, password) } + } + runOnUiThread { + webBusy = false + fun clear() { bytes?.fill(0); password.fill('\u0000') } + if (isFinishing || isDestroyed) { clear(); return@runOnUiThread } + result.onFailure { clear(); toast(getString(UiR.string.ui_could_not_read_pkcs_12_check_the_file_and)) } + .onSuccess { aliases -> + val choose = { alias: String -> + textInput(getString(UiR.string.ui_private_key_password_leave_blank_to_use_file_password), "", true, onCancel = ::clear) { value -> + val keyPassword = if (value.isEmpty()) password.copyOf() else value.toCharArray() + configureWeb(getString(UiR.string.ui_pkcs_12_certificate_imported_reconnect_your_browser)) { + try { LanTls.preparePkcs12(bytes!!, password, alias, keyPassword).use { WebSession.installCertificate(this, it) } } + finally { clear(); keyPassword.fill('\u0000') } + } + } + } + if (aliases.size == 1) choose(aliases.single()) + else settingsDialogs.select(getString(UiR.string.ui_choose_a_pkcs_12_private_key_entry), aliases, ::clear) { index -> choose(aliases[index]) } + } + } + }, "https-pkcs12-read").start() + } + + private fun textInput(title: String, current: String, secret: Boolean, preserveWhitespace: Boolean = false, + onCancel: () -> Unit = {}, save: (String) -> Unit) { + settingsDialogs.input(title, current, secret, preserveWhitespace, onCancel, save) + } + + private fun connect(wireless: Boolean) { + WheelPlayPreferences.saveHomeSource(this, ProjectionSourceIds.CARPLAY) + refreshUiState() + refreshAuthentication() + if (setupError != null) { toast(setupError!!); return } + if (wireless && carHotspotOff()) { carHotspotOffDialog(); return } + if (wireless && WheelPlayPreferences.phoneAddress(this) == null) { + pendingWireless = true; choosePhone(); return + } + val preferences = getSharedPreferences("diplay", MODE_PRIVATE) + if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED && !preferences.getBoolean("notification_asked", false)) { + preferences.edit().putBoolean("notification_asked", true).apply() + notificationTransport = wireless + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + val open = { + AirPlayPersistence.saveWirelessEnabled(this, wireless) + openProjection() + } + if (projectionSessions.hasSession()) projectionSessions.stop().whenComplete { _, failure -> runOnUiThread { + if (failure == null) open() + else toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@WheelPlayActivity))) + } } else open() + } + private fun handleUsbConnectionIntent(launch: Intent): Boolean { + if (launch.action != "android.hardware.usb.action.USB_DEVICE_ATTACHED") return false + // Route USB attachment through the console so the dialog always has a real backdrop. + // Consume it before onResume's optional auto-connect and before Activity recreation. + launch.action = null + launch.putExtra("page", page) + if (projectionSessions.hasSession() && + projectionSessions.snapshot()?.descriptor?.id != ProjectionSourceIds.CARPLAY) return true + refreshAuthentication() + if (setupError != null) { + navigate("settings") + settingsModel.open(SettingsCategory.AUTHENTICATION) + } else if (!CarPlayBackgroundSession.hasSession() || AirPlayPersistence.loadWirelessEnabled(this)) { + connect(false) + } + return true + } + + private fun openProjection() { + startActivity(Intent(this, CarPlayHostActivity::class.java) + .putExtra("return_page", page) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_NO_ANIMATION)) + overridePendingTransition(0, 0) + } + private fun choosePhone() { + if (Build.VERSION.SDK_INT >= 31 && checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + bluetoothPermission.launch(Manifest.permission.BLUETOOTH_CONNECT); return + } + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + val settings = WheelPlayDialogs.Action(getString(UiR.string.ui_open_bluetooth_settings)) { openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } + if (adapter == null || !adapter.isEnabled) { + dialogs.message(getString(UiR.string.ui_turn_on_bluetooth), getString(UiR.string.ui_turn_on_bluetooth_on_this_android_device_and_pair), listOf(settings)); return + } + val devices = runCatching { adapter.bondedDevices.sortedBy { it.name ?: "" } }.getOrDefault(emptyList()) + if (devices.isEmpty()) { + dialogs.message(getString(UiR.string.ui_pair_your_iphone_first), getString(UiR.string.ui_pair_this_android_device_in_settings_bluetooth_on_your), listOf(settings)); return + } + dialogs.choices(getString(UiR.string.ui_choose_iphone), devices.map { device -> + val name = device.name ?: getString(UiR.string.ui_paired_device) + if (devices.count { it.name == device.name } > 1) "$name · ${device.address.takeLast(5)}" else name + }, extra = WheelPlayDialogs.Action(getString(UiR.string.ui_pair_another_device), settings.run), onCancel = { pendingWireless = false }) { index -> + val device = devices[index] + WheelPlayPreferences.savePhone(this, device.address, device.name ?: "iPhone") + val start = pendingWireless; pendingWireless = false + render() + if (start) connect(true) + } + } + + private fun wirelessHelp() { + dialogs.message(getString(UiR.string.ui_wireless_connection_help), getString(UiR.string.ui_pair_the_iphone_with_this_device_over_bluetooth_keep), listOf( + WheelPlayDialogs.Action(getString(UiR.string.ui_ok)) {}, + WheelPlayDialogs.Action(getString(UiR.string.ui_reset_carplay_wi_fi), ::confirmWirelessReset), + )) + } + + private fun handleWirelessRecovery() { + if (intent.getStringExtra("page") != "wireless-recovery") return + intent.removeExtra("page") + navigate(SourcePage.CARPLAY.id) + confirmWirelessReset() + } + + private fun confirmWirelessReset() { + dialogs.message(getString(UiR.string.ui_reset_carplay_wi_fi_2), getString(UiR.string.ui_this_ends_the_current_wi_fi_direct_connection_including), listOf( + WheelPlayDialogs.Action(getString(UiR.string.ui_reset_and_connect)) { CarPlayBackgroundSession.stop { runOnUiThread { resetWirelessGroup() } } }, + )) + } + + private fun resetWirelessGroup() { + val manager = getSystemService(android.net.wifi.p2p.WifiP2pManager::class.java) + if (manager == null) { toast(getString(UiR.string.ui_this_device_does_not_support_wi_fi_direct)); return } + val channel = manager.initialize(this, mainLooper, null) + try { + manager.requestGroupInfo(channel) { group -> + if (group == null) { channel.close(); connect(true); return@requestGroupInfo } + manager.removeGroup(channel, object : android.net.wifi.p2p.WifiP2pManager.ActionListener { + override fun onSuccess() { + val deadline = android.os.SystemClock.elapsedRealtime() + 4000 + fun waitUntilRemoved() { + manager.requestGroupInfo(channel) { remaining -> + when { + remaining == null -> { channel.close(); if (!isFinishing && !isDestroyed) connect(true) } + android.os.SystemClock.elapsedRealtime() >= deadline -> { + channel.close(); toast(getString(UiR.string.ui_wi_fi_direct_is_still_in_use_close_other)) + } + else -> handler.postDelayed({ waitUntilRemoved() }, 200) + } + } + } + waitUntilRemoved() + } + override fun onFailure(reason: Int) { channel.close(); toast(getString(UiR.string.ui_could_not_reset_wi_fi_direct_close_other_casting)) } + }) + } + } catch (_: SecurityException) { + channel.close(); permissionHelp(getString(UiR.string.ui_wireless_connection_permissions), getString(UiR.string.ui_allow_nearby_devices_permission_first_older_android_versions_also)) + } + } + + private fun reportFileName() = "WheelPlay-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(Date())}.txt" + + private fun chooseReportDestination() { + // Some head units omit or disable DocumentsUI. Launch itself can throw, before + // the result callback and the background writer's exception handler ever run. + runCatching { export.launch(reportFileName()) }.onFailure { + toast(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + getString(UiR.string.ui_could_not_open_the_save_location_try_saving_to) + else getString(UiR.string.ui_no_file_picker_is_available_on_this_device)) + } + } + + private fun exportDiagnostics(uri: Uri? = null) { + if (exportInProgress) return + exportInProgress = true + refreshSettingsRuntime() + val appContext = applicationContext + val fileName = reportFileName() + Thread({ + val result = runCatching { + val report = buildString { + appendLine("WheelPlay ${version()} · private beta diagnostic report") + appendLine("Android ${Build.VERSION.RELEASE} / API ${Build.VERSION.SDK_INT}") + appendLine("Head unit: ${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("Connection: ${if (AirPlayPersistence.loadWirelessEnabled(appContext)) "wireless" else "USB"}") + appendLine("Authentication: local experimental beta identity; no remote fallback") + appendLine("Saved video preference (may differ from active session): ${if (AirPlayPersistence.loadHevcEnabled(appContext)) "HEVC" else "H.264"}; ${AirPlayPersistence.loadFps(appContext)} fps") + appendLine("CarPlay size: ${com.shilapi.xcertplay.airplay.CarPlaySize.fromWidthMillimeters(AirPlayPersistence.loadWidthPhysicalMm(appContext)).name}") + appendLine("Saved resolution preference (may differ from active session): ${AirPlayPersistence.loadDisplayScaleTenths(appContext) * 10}%") + appendLine("Tesla compatibility: enabled=${TeslaHttpCompatibility.config(appContext).enabled}; mode=${TeslaHttpCompatibility.config(appContext).mode}; addressConfigured=${TeslaHttpCompatibility.status.address != null}; downstreams=${TeslaHttpCompatibility.status.downstreams.joinToString()}") + TeslaHttpCompatibility.status.error?.let { appendLine("Tesla compatibility status: $it") } + appendLine("Session: ${if (CarPlayBackgroundSession.active) "active" else if (CarPlayBackgroundSession.hasSession()) "connecting" else "stopped"}") + appendLine("Head-unit board: ${Build.BOARD}; hardware: ${Build.HARDWARE}; build: ${Build.DISPLAY}") + appendLine() + appendLine("--- Last display negotiation (timestamps distinguish it from current settings) ---") + appendLine(DisplayDiagnosticSnapshot.report(appContext)) + appendLine() + for (name in SessionLogFile.REPORT_NAMES) { + val file = File(appContext.filesDir, "logs/$name") + if (file.isFile) { + appendLine("--- $name ---") + file.useLines { lines -> lines.forEach { line -> DiagnosticRedactor.redact(line)?.let { appendLine(it) } } } + } + } + } + if (uri != null) DiagnosticExportStore.write(appContext.contentResolver, uri, report) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + DiagnosticExportStore.saveToDownloads(appContext.contentResolver, fileName, report) + } else error("A save location is required") + } + runOnUiThread { + exportInProgress = false + if (isFinishing || isDestroyed) return@runOnUiThread + refreshSettingsRuntime() + if (result.isSuccess) { + dialogs.message(getString(UiR.string.ui_diagnostic_report_saved), + if (uri == null) "Downloads/WheelPlay/$fileName" else getString(UiR.string.ui_report_saved_to_the_selected_location), + listOf(WheelPlayDialogs.Action(getString(UiR.string.ui_done)) {})) + } else { + dialogs.message(getString(UiR.string.ui_could_not_save_report), getString(UiR.string.ui_check_storage_space_or_choose_another_save_location), + listOf(WheelPlayDialogs.Action(getString(UiR.string.ui_choose_location), ::chooseReportDestination))) + } + } + }, "wheelplay-export").start() + } + private fun permissionHelp(title: String, body: String) { + dialogs.message(title, body, listOf(WheelPlayDialogs.Action(getString(UiR.string.ui_app_settings)) { + openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) + })) + } + + private fun openSystem(intent: Intent) { runCatching { startActivity(intent) }.onFailure { toast(getString(UiR.string.ui_open_this_option_in_the_device_s_system_settings)) } } + private fun toast(message: String) { lifecycleScope.launch { snackbar.showSnackbar(message) } } + private fun version() = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.1.0-beta.1" + + + + +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayBluetooth.kt b/common/src/main/java/moe/feng/wheelplay/WheelPlayBluetooth.kt similarity index 90% rename from common/src/main/java/com/shilapi/xcertplay/DiPlayBluetooth.kt rename to common/src/main/java/moe/feng/wheelplay/WheelPlayBluetooth.kt index c4c0e59..5206a5a 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayBluetooth.kt +++ b/common/src/main/java/moe/feng/wheelplay/WheelPlayBluetooth.kt @@ -1,10 +1,10 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.bluetooth.BluetoothManager import android.content.Context import android.provider.Settings -internal object DiPlayBluetooth { +internal object WheelPlayBluetooth { fun localAddress(context: Context): String? { val adapter = runCatching { context.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull() val setting = runCatching { Settings.Secure.getString(context.contentResolver, "bluetooth_address") }.getOrNull() diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt b/common/src/main/java/moe/feng/wheelplay/WheelPlayBootstrap.kt similarity index 92% rename from common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt rename to common/src/main/java/moe/feng/wheelplay/WheelPlayBootstrap.kt index f6cf43d..ddb1336 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt +++ b/common/src/main/java/moe/feng/wheelplay/WheelPlayBootstrap.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import com.shilapi.xcertplay.projection.ProjectionSourceIds import android.content.Context @@ -7,7 +7,7 @@ import com.shilapi.xcertplay.orchestration.MfiTarget import java.security.MessageDigest /** Uses user-imported resources, with optional privately provisioned APK assets as a fallback. */ -internal object DiPlayBootstrap { +internal object WheelPlayBootstrap { private val importInProgress = java.util.concurrent.atomic.AtomicBoolean(false) val importing: Boolean get() = importInProgress.get() @Synchronized fun beginImport(): Boolean = importInProgress.compareAndSet(false, true) @@ -34,7 +34,8 @@ internal object DiPlayBootstrap { } } -internal object DiPlayPreferences { +internal object WheelPlayPreferences { + // Storage names are upgrade contracts: keep the existing phone and home-source settings. private fun prefs(context: Context) = context.getSharedPreferences("diplay", Context.MODE_PRIVATE) fun phoneAddress(context: Context): String? = prefs(context).getString("phone_address", null) fun phoneName(context: Context): String = prefs(context).getString("phone_name", null) ?: context.getString(com.shilapi.xcertplay.shared.R.string.ui_your_iphone) diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/moe/feng/wheelplay/WheelPlaySessionService.kt similarity index 97% rename from common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt rename to common/src/main/java/moe/feng/wheelplay/WheelPlaySessionService.kt index 316614b..ec99ee7 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt +++ b/common/src/main/java/moe/feng/wheelplay/WheelPlaySessionService.kt @@ -1,4 +1,4 @@ -package com.shilapi.xcertplay +package moe.feng.wheelplay import android.Manifest import android.annotation.SuppressLint @@ -33,7 +33,7 @@ import com.shilapi.xcertplay.projection.projectionSessions import java.util.concurrent.CompletableFuture /** Keeps the connection and its media session alive while no dashboard is visible. */ -class DiPlaySessionService : Service() { +class WheelPlaySessionService : Service() { private var wakeLock: PowerManager.WakeLock? = null private lateinit var media: CarPlayMediaSessionBridge private var sessionSubscription: AutoCloseable? = null @@ -132,7 +132,7 @@ class DiPlaySessionService : Service() { private fun mediaAction(command: MediaCommand, icon: Int, title: Int): Notification.Action { val intent = - Intent(this, DiPlaySessionService::class.java) + Intent(this, WheelPlaySessionService::class.java) .setAction(ACTION_MEDIA) .setData(Uri.parse("wheelplay://media/${media.commandGeneration}/${command.name}")) .putExtra(EXTRA_COMMAND, command.name) @@ -158,13 +158,13 @@ class DiPlaySessionService : Service() { PendingIntent.getActivity( this, 0, - Intent(this, DiPlayActivity::class.java) + Intent(this, WheelPlayActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val builder = Notification.Builder(this, CHANNEL) - .setSmallIcon(R.drawable.ic_diplay_notification) + .setSmallIcon(R.drawable.ic_wheelplay_notification) .setContentIntent(open) .setOngoing(true) val session = currentSession @@ -215,7 +215,7 @@ class DiPlaySessionService : Service() { PendingIntent.getService( this, 1, - Intent(this, DiPlaySessionService::class.java).setAction(ACTION_STOP), + Intent(this, WheelPlaySessionService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val port = diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/AudioSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/AudioSettingsScreen.kt similarity index 94% rename from common/src/main/java/com/shilapi/xcertplay/settings/AudioSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/AudioSettingsScreen.kt index 59e3eea..724e9da 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/AudioSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/AudioSettingsScreen.kt @@ -1,10 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import com.shilapi.xcertplay.* +import moe.feng.wheelplay.* import com.shilapi.xcertplay.host.R @Composable diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/AuthenticationSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/AuthenticationSettingsScreen.kt similarity index 89% rename from common/src/main/java/com/shilapi/xcertplay/settings/AuthenticationSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/AuthenticationSettingsScreen.kt index 33bdd13..eba542f 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/AuthenticationSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/AuthenticationSettingsScreen.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -26,8 +26,8 @@ internal fun AuthenticationSettingsScreen(state: SettingsState, actions: Setting } SettingsButton(R.string.settings_import_files, !state.runtime.importing) { manualImport = true } SettingsRow( - settingsString(R.string.settings_diplay_download), - settingsString(R.string.settings_diplay_download_description), + settingsString(R.string.settings_upstream_download), + settingsString(R.string.settings_upstream_download_description), "settings:download", ) { actions.command(SettingsCommand.DOWNLOAD) diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/BrowserSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/BrowserSettingsScreen.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/settings/BrowserSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/BrowserSettingsScreen.kt index b9b114d..eeae226 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/BrowserSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/BrowserSettingsScreen.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -8,7 +8,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.shilapi.xcertplay.BrowserOfflinePolicy +import moe.feng.wheelplay.BrowserOfflinePolicy import com.shilapi.xcertplay.host.R import java.text.DateFormat import java.util.Date diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/ConnectionSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/ConnectionSettingsScreen.kt similarity index 99% rename from common/src/main/java/com/shilapi/xcertplay/settings/ConnectionSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/ConnectionSettingsScreen.kt index 2a7d754..67ab9da 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/ConnectionSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/ConnectionSettingsScreen.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions diff --git a/common/src/main/java/moe/feng/wheelplay/settings/DisplaySettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/DisplaySettingsScreen.kt new file mode 100644 index 0000000..5d10d33 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/settings/DisplaySettingsScreen.kt @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import com.shilapi.xcertplay.airplay.CarPlaySize +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.web.RtcRecoveryPolicy +import com.shilapi.xcertplay.web.StreamTechnology +import moe.feng.wheelplay.* + +@Composable +internal fun DisplaySettingsScreen(state: SettingsState, actions: SettingsActions) { + var editMargins by rememberSaveable { mutableStateOf(false) } + var editWidth by rememberSaveable { mutableIntStateOf(1280) } + var editHeight by rememberSaveable { mutableIntStateOf(720) } + var editInitial by rememberSaveable { mutableStateOf(CarPlaySafeMargins().encode()) } + var editLegacy by rememberSaveable { mutableStateOf(false) } + SettingsChoice( + state, + actions, + ChoiceSetting.APPEARANCE, + R.string.settings_appearance, + CarPlayAppearance.entries.map { settingsString(it.labelRes) }, + ) + SettingsDescription(R.string.settings_appearance_description) + SettingsChoice( + state, + actions, + ChoiceSetting.DISPLAY_SIZE, + R.string.settings_display_size, + CarPlaySize.entries.map { settingsString(it.labelRes) }, + ) + SettingsDescription(R.string.settings_display_size_description) + val margins = state.margins + val summary = + if (margins == null && state.legacySafeArea) + settingsString(R.string.settings_legacy_safe_area) + else if (margins == null || margins.isFullScreen) + settingsString(R.string.settings_fullscreen) + else + settingsString( + R.string.settings_safe_area_value, + margins.top, + margins.bottom, + margins.left, + margins.right, + ) + SettingsRow(settingsString(R.string.settings_safe_area), summary, "settings:safe-area") { + editWidth = state.safeAreaSlot.width + editHeight = state.safeAreaSlot.height + editInitial = (margins ?: state.legacyMargins ?: CarPlaySafeMargins()).encode() + editLegacy = margins == null && state.legacySafeArea + editMargins = true + } + Text( + settingsString( + R.string.settings_safe_area_slot, + state.safeAreaSlot.width, + state.safeAreaSlot.height, + ), + style = MaterialTheme.typography.bodySmall, + ) + SettingsDescription(R.string.settings_safe_area_description) + SettingsToggle( + state, + actions, + ToggleSetting.ADAPTIVE_SIZE, + R.string.settings_adaptive_size, + R.string.settings_adaptive_size_description, + ) + SettingsChoice( + state, + actions, + ChoiceSetting.RESOLUTION, + R.string.settings_resolution, + listOf( + settingsString(R.string.settings_original_resolution), + settingsString(R.string.settings_resolution_lower), + settingsString(R.string.settings_resolution_lowest), + ), + ) + SettingsChoice( + state, + actions, + ChoiceSetting.FPS, + R.string.settings_fps, + listOf(settingsString(R.string.settings_fps_30), settingsString(R.string.settings_fps_60)), + ) + SettingsChoice( + state, + actions, + ChoiceSetting.STREAM, + R.string.settings_stream, + StreamTechnology.entries.map { settingsString(it.labelRes) }, + ) + SettingsDescription(R.string.settings_stream_description) + SettingsChoice( + state, + actions, + ChoiceSetting.IMAGE_FORMAT, + R.string.settings_image_format, + listOf("JPEG", "WebP"), + ) + SettingsDescription(R.string.settings_image_format_description) + SettingsTextPreference( + actions, + TextSetting.IMAGE_QUALITY, + R.string.settings_image_quality, + state.imageQuality.toString(), + numeric = true, + ) + SettingsDescription(R.string.settings_image_quality_description) + SettingsChoice( + state, + actions, + ChoiceSetting.RECOVERY, + R.string.settings_recovery, + RtcRecoveryPolicy.entries.map { settingsString(it.labelRes) }, + ) + SettingsDescription(R.string.settings_recovery_description) + SettingsToggle( + state, + actions, + ToggleSetting.HEVC, + R.string.settings_hevc, + R.string.settings_hevc_description, + ) + SettingsToggle( + state, + actions, + ToggleSetting.RIGHT_HAND, + R.string.settings_right_hand, + R.string.settings_right_hand_description, + ) + if (editMargins) + SafeAreaDialog( + initial = requireNotNull(CarPlaySafeMargins.decode(editInitial)), + slot = CarPlayResolutionSlot(editWidth, editHeight), + legacy = editLegacy, + onDismiss = { editMargins = false }, + ) { + actions.margins(CarPlayResolutionSlot(editWidth, editHeight), it) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/HelpSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/HelpSettingsScreen.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/settings/HelpSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/HelpSettingsScreen.kt index 2395185..fb4e330 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/HelpSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/HelpSettingsScreen.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import android.os.Build import androidx.compose.material3.* diff --git a/common/src/main/java/moe/feng/wheelplay/settings/SafeAreaDialog.kt b/common/src/main/java/moe/feng/wheelplay/settings/SafeAreaDialog.kt new file mode 100644 index 0000000..07d6053 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/settings/SafeAreaDialog.kt @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.host.R +import moe.feng.wheelplay.CarPlayResolutionSlot +import moe.feng.wheelplay.CarPlaySafeMargins + +@Composable +internal fun SafeAreaDialog( + initial: CarPlaySafeMargins, + slot: CarPlayResolutionSlot = CarPlayResolutionSlot.DEFAULT, + legacy: Boolean = false, + onDismiss: () -> Unit, + onSave: (CarPlaySafeMargins) -> Unit, +) { + var top by rememberSaveable { mutableStateOf(initial.top.toString()) } + var bottom by rememberSaveable { mutableStateOf(initial.bottom.toString()) } + var left by rememberSaveable { mutableStateOf(initial.left.toString()) } + var right by rememberSaveable { mutableStateOf(initial.right.toString()) } + var error by remember { mutableStateOf(null) } + val fields = listOf(top, bottom, left, right) + val parsed = + runCatching { + fields.map { it.toInt() }.let { CarPlaySafeMargins(it[0], it[1], it[2], it[3]) } + } + .getOrNull() + val preview = parsed ?: initial + var edited by rememberSaveable { mutableStateOf(false) } + fun update(value: CarPlaySafeMargins) { + top = value.top.toString() + bottom = value.bottom.toString() + left = value.left.toString() + right = value.right.toString() + error = null + edited = true + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(settingsString(R.string.settings_safe_area)) }, + text = { + Column( + Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + settingsString(R.string.settings_safe_area_slot, slot.width, slot.height), + style = MaterialTheme.typography.bodyMedium, + ) + SafeAreaPreview(preview, slot.aspectRatio, parsed != null, ::update) + SettingsDescription(R.string.settings_safe_area_hint) + if (legacy) SettingsDescription(R.string.settings_safe_area_legacy_edit) + TextButton(onClick = { update(CarPlaySafeMargins()) }) { + Text(settingsString(R.string.settings_restore_fullscreen)) + } + val labels = + listOf( + R.string.settings_margin_top, + R.string.settings_margin_bottom, + R.string.settings_margin_left, + R.string.settings_margin_right, + ) + val changes: List<(String) -> Unit> = + listOf({ top = it }, { bottom = it }, { left = it }, { right = it }) + fields.forEachIndexed { index, value -> + OutlinedTextField( + value, + { + changes[index](it) + edited = true + error = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(settingsString(labels[index])) }, + suffix = { Text("%") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = error != null, + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + val invalidMessage = settingsString(R.string.settings_safe_area_invalid) + TextButton( + onClick = { + if (parsed == null) error = invalidMessage + else { + if (edited && (legacy || parsed != initial)) onSave(parsed) + onDismiss() + } + } + ) { + Text(settingsString(R.string.settings_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_cancel)) } + }, + ) +} diff --git a/common/src/main/java/moe/feng/wheelplay/settings/SafeAreaPreview.kt b/common/src/main/java/moe/feng/wheelplay/settings/SafeAreaPreview.kt new file mode 100644 index 0000000..2d20da8 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/settings/SafeAreaPreview.kt @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.settings + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.* +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.host.R +import kotlin.math.abs +import kotlin.math.roundToInt +import moe.feng.wheelplay.CarPlaySafeMargins + +internal enum class SafeAreaEdge { + TOP, + BOTTOM, + LEFT, + RIGHT, +} + +/** + * A padded, aspect-correct viewport keeps even zero-margin handles away from the touch boundary. + */ +internal fun safeAreaFrame(size: Size, aspect: Float, padding: Float): Rect { + val availableWidth = (size.width - padding * 2).coerceAtLeast(1f) + val availableHeight = (size.height - padding * 2).coerceAtLeast(1f) + val ratio = aspect.takeIf { it.isFinite() && it > 0 } ?: (16f / 9f) + val width = minOf(availableWidth, availableHeight * ratio) + val height = width / ratio + return Rect( + (size.width - width) / 2, + (size.height - height) / 2, + (size.width + width) / 2, + (size.height + height) / 2, + ) +} + +internal fun CarPlaySafeMargins.move(edge: SafeAreaEdge, percent: Float): CarPlaySafeMargins { + val value = percent.roundToInt().coerceIn(0, 40) + return when (edge) { + SafeAreaEdge.TOP -> copy(top = value) + SafeAreaEdge.BOTTOM -> copy(bottom = value) + SafeAreaEdge.LEFT -> copy(left = value) + SafeAreaEdge.RIGHT -> copy(right = value) + } +} + +@Composable +internal fun SafeAreaPreview( + margins: CarPlaySafeMargins, + aspectRatio: Float, + enabled: Boolean, + onChange: (CarPlaySafeMargins) -> Unit, +) { + val current by rememberUpdatedState(margins) + val change by rememberUpdatedState(onChange) + val padding = with(LocalDensity.current) { 24.dp.toPx() } + val background = MaterialTheme.colorScheme.surfaceContainerHighest + val foreground = MaterialTheme.colorScheme.primary + val outline = MaterialTheme.colorScheme.outline + val description = settingsString(R.string.settings_safe_area_preview) + val summary = + settingsString( + R.string.settings_safe_area_value, + margins.top, + margins.bottom, + margins.left, + margins.right, + ) + val edgeLabels = + listOf( + R.string.settings_margin_top, + R.string.settings_margin_bottom, + R.string.settings_margin_left, + R.string.settings_margin_right, + ) + .map { settingsString(it) } + val increase = edgeLabels.map { settingsString(R.string.settings_safe_area_increase, it) } + val decrease = edgeLabels.map { settingsString(R.string.settings_safe_area_decrease, it) } + Canvas( + Modifier.fillMaxWidth() + .height(208.dp) + .testTag("safe-area:preview") + .semantics { + contentDescription = description + stateDescription = summary + if (enabled) + customActions = + SafeAreaEdge.entries.flatMapIndexed { index, edge -> + val value = + listOf(current.top, current.bottom, current.left, current.right)[ + index] + listOf( + CustomAccessibilityAction(increase[index]) { + change(current.move(edge, value + 1f)) + true + }, + CustomAccessibilityAction(decrease[index]) { + change(current.move(edge, value - 1f)) + true + }, + ) + } + } + .pointerInput(aspectRatio, padding, enabled) { + if (!enabled) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val frame = + safeAreaFrame( + Size(size.width.toFloat(), size.height.toFloat()), + aspectRatio, + padding, + ) + val bounds = + Rect( + frame.left + frame.width * current.left / 100, + frame.top + frame.height * current.top / 100, + frame.right - frame.width * current.right / 100, + frame.bottom - frame.height * current.bottom / 100, + ) + val point = down.position + if (!frame.inflate(padding).contains(point)) return@awaitEachGesture + val edge = + listOf( + SafeAreaEdge.TOP to abs(point.y - bounds.top), + SafeAreaEdge.BOTTOM to abs(point.y - bounds.bottom), + SafeAreaEdge.LEFT to abs(point.x - bounds.left), + SafeAreaEdge.RIGHT to abs(point.x - bounds.right), + ) + .filter { it.second <= padding } + .minByOrNull { it.second } + ?.first ?: return@awaitEachGesture + // Preserve the grab offset; touching the 48dp hit area must not jump the + // boundary. + val grabOffset = + when (edge) { + SafeAreaEdge.TOP -> point.y - bounds.top + SafeAreaEdge.BOTTOM -> point.y - bounds.bottom + SafeAreaEdge.LEFT -> point.x - bounds.left + SafeAreaEdge.RIGHT -> point.x - bounds.right + } + down.consume() + drag(down.id) { event -> + event.consume() + val value = + when (edge) { + SafeAreaEdge.TOP -> + (event.position.y - grabOffset - frame.top) * 100 / frame.height + SafeAreaEdge.BOTTOM -> + (frame.bottom - event.position.y + grabOffset) * 100 / + frame.height + SafeAreaEdge.LEFT -> + (event.position.x - grabOffset - frame.left) * 100 / frame.width + SafeAreaEdge.RIGHT -> + (frame.right - event.position.x + grabOffset) * 100 / + frame.width + } + change(current.move(edge, value)) + } + } + } + ) { + val frame = safeAreaFrame(size, aspectRatio, padding) + val safe = + Rect( + frame.left + frame.width * margins.left / 100, + frame.top + frame.height * margins.top / 100, + frame.right - frame.width * margins.right / 100, + frame.bottom - frame.height * margins.bottom / 100, + ) + drawRect(background, frame.topLeft, frame.size) + drawRect(outline, frame.topLeft, frame.size, style = Stroke(1.dp.toPx())) + drawRect(foreground.copy(alpha = .18f), safe.topLeft, safe.size) + drawRect(foreground, safe.topLeft, safe.size, style = Stroke(2.dp.toPx())) + listOf( + Offset(safe.center.x, safe.top), + Offset(safe.center.x, safe.bottom), + Offset(safe.left, safe.center.y), + Offset(safe.right, safe.center.y), + ) + .forEach { drawCircle(foreground, 6.dp.toPx(), it) } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsComponents.kt b/common/src/main/java/moe/feng/wheelplay/settings/SettingsComponents.kt similarity index 99% rename from common/src/main/java/com/shilapi/xcertplay/settings/SettingsComponents.kt rename to common/src/main/java/moe/feng/wheelplay/settings/SettingsComponents.kt index 8a6f0b8..ade72c5 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsComponents.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/SettingsComponents.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.annotation.StringRes import androidx.compose.foundation.clickable diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsDialogHost.kt b/common/src/main/java/moe/feng/wheelplay/settings/SettingsDialogHost.kt similarity index 99% rename from common/src/main/java/com/shilapi/xcertplay/settings/SettingsDialogHost.kt rename to common/src/main/java/moe/feng/wheelplay/settings/SettingsDialogHost.kt index de6f5b1..6930b58 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsDialogHost.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/SettingsDialogHost.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.compose.material3.* import androidx.compose.runtime.* diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/SettingsScreen.kt similarity index 88% rename from common/src/main/java/com/shilapi/xcertplay/settings/SettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/SettingsScreen.kt index b7af2eb..61b3411 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/SettingsScreen.kt @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings + +import moe.feng.wheelplay.ui.theme.WheelPlayTheme import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -9,7 +11,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role @@ -19,38 +20,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.shilapi.xcertplay.host.R import kotlinx.coroutines.flow.distinctUntilChanged -@Composable -internal fun SettingsTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = - darkColorScheme( - primary = Color(0xffb4d9b1), - onPrimary = Color(0xff20391f), - background = Color(0xff11161a), - surface = Color(0xff11161a), - surfaceContainer = Color(0xff1c2428), - onSurface = Color(0xffe2e8e3), - onSurfaceVariant = Color(0xffc1c9be), - outline = Color(0xff8b9389), - ), - content = content, - ) -} - @Composable internal fun SettingsRoute( model: SettingsViewModel, actions: SettingsActions, dialogs: SettingsDialogHost, + showDialogs: Boolean = true, onScroll: (Int) -> Unit, ) { val state by model.state.collectAsStateWithLifecycle() - SettingsTheme { + WheelPlayTheme { SettingsScreen(state, actions, model::open, model::scrollPosition) { category, offset -> model.rememberScroll(category, offset) onScroll(offset) } - SettingsPlatformDialog(dialogs) + if (showDialogs) SettingsPlatformDialog(dialogs) } } @@ -145,17 +129,17 @@ private fun SettingsIndexScreen( onCategory: (SettingsCategory?) -> Unit, ) { Card( - onClick = { actions.command(SettingsCommand.DIPLAY) }, + onClick = { actions.command(SettingsCommand.UPSTREAM_PROJECT) }, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { Column(Modifier.padding(20.dp)) { Text( - settingsString(R.string.settings_diplay_title), + settingsString(R.string.settings_upstream_title), style = MaterialTheme.typography.titleLarge, ) - SettingsDescription(R.string.settings_diplay_description) + SettingsDescription(R.string.settings_upstream_description) } } SettingsCategory.entries.forEach { category -> @@ -202,13 +186,13 @@ private fun SettingsIndexScreen( ) @Composable private fun SettingsIndexPreview() { - SettingsTheme { SettingsScreen(SettingsState(), previewActions, {}, { 0 }, { _, _ -> }) } + WheelPlayTheme { SettingsScreen(SettingsState(), previewActions, {}, { 0 }, { _, _ -> }) } } @Preview(name = "Audio", locale = "zh", widthDp = 360, heightDp = 800) @Composable private fun AudioSettingsPreview() { - SettingsTheme { + WheelPlayTheme { SettingsScreen( SettingsState( category = SettingsCategory.AUDIO, @@ -229,7 +213,7 @@ private val previewActions = { _, _ -> }, { _, _ -> null }, { _, _ -> null }, - {}, + { _, _ -> }, {}, {}, { _, _ -> }, diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsState.kt b/common/src/main/java/moe/feng/wheelplay/settings/SettingsState.kt similarity index 92% rename from common/src/main/java/com/shilapi/xcertplay/settings/SettingsState.kt rename to common/src/main/java/moe/feng/wheelplay/settings/SettingsState.kt index 26fed3e..1854064 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsState.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/SettingsState.kt @@ -1,12 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.annotation.StringRes -import com.shilapi.xcertplay.CarPlaySafeMargins import com.shilapi.xcertplay.host.R import com.shilapi.xcertplay.network.TeslaHttpConfig import com.shilapi.xcertplay.web.RememberedBrowsers import com.shilapi.xcertplay.web.WebListenSettings +import moe.feng.wheelplay.CarPlayResolutionSlot +import moe.feng.wheelplay.CarPlaySafeMargins internal enum class SettingsCategory( @param:StringRes val title: Int, @@ -81,7 +82,7 @@ internal enum class SettingsCommand { BLUETOOTH_SETTINGS, WIRELESS_HELP, EXPORT_REPORT, - DIPLAY, + UPSTREAM_PROJECT, SOURCE, DOWNLOAD, } @@ -98,6 +99,8 @@ internal data class SettingsState( val iconVersion: Long = 0, val margins: CarPlaySafeMargins? = null, val legacySafeArea: Boolean = false, + val legacyMargins: CarPlaySafeMargins? = null, + val safeAreaSlot: CarPlayResolutionSlot = CarPlayResolutionSlot.DEFAULT, val ssid: String = "", val password: String = "", val httpPort: Int = 8080, @@ -139,7 +142,7 @@ internal class SettingsActions( val toggle: (ToggleSetting, Boolean) -> Unit, val text: (TextSetting, String) -> String?, val credentials: (String, String) -> String?, - val margins: (CarPlaySafeMargins) -> Unit, + val margins: (CarPlayResolutionSlot, CarPlaySafeMargins) -> Unit, val resetBranding: (Boolean) -> Unit, val command: (SettingsCommand) -> Unit, val renameBrowser: (String, String) -> Unit, diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsViewModel.kt b/common/src/main/java/moe/feng/wheelplay/settings/SettingsViewModel.kt similarity index 90% rename from common/src/main/java/com/shilapi/xcertplay/settings/SettingsViewModel.kt rename to common/src/main/java/moe/feng/wheelplay/settings/SettingsViewModel.kt index 7ef11c3..1e3b7e4 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsViewModel.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/SettingsViewModel.kt @@ -1,10 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.SavedStateHandle -import com.shilapi.xcertplay.* import com.shilapi.xcertplay.airplay.CarPlaySize import com.shilapi.xcertplay.host.R import com.shilapi.xcertplay.hud.BydOutputSettings @@ -15,6 +14,7 @@ import com.shilapi.xcertplay.web.* import java.util.Locale import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import moe.feng.wheelplay.* internal sealed interface SettingsEffect { data object None : SettingsEffect @@ -66,8 +66,14 @@ internal class SettingsViewModel( mutableState.value = mutableState.value.copy(runtime = runtime) } - private fun readPreferences(previous: SettingsState): SettingsState = - previous.copy( + private fun readPreferences(previous: SettingsState): SettingsState { + val (width, height) = safeAreaDisplaySize() + val margins = AirPlayPersistence.loadSafeMargins(app, width, height) + val legacyRect = + if (margins == null) AirPlayPersistence.loadSafeAreaRect(app, width, height) else null + fun percent(pixels: Int, size: Int) = + kotlin.math.round(pixels * 100.0 / size).toInt().coerceIn(0, 40) + return previous.copy( choices = mapOf( ChoiceSetting.APPEARANCE to AirPlayPersistence.loadAppearance(app).ordinal, @@ -108,7 +114,7 @@ internal class SettingsViewModel( ), toggles = mapOf( - ToggleSetting.AUTO_CONNECT to DiPlayPreferences.autoConnect(app), + ToggleSetting.AUTO_CONNECT to WheelPlayPreferences.autoConnect(app), ToggleSetting.AUTO_START to AirPlayPersistence.loadAutoStartOnBoot(app), ToggleSetting.ADAPTIVE_SIZE to AirPlayPersistence.loadAdaptiveBrowserSize(app), ToggleSetting.HEVC to AirPlayPersistence.loadHevcEnabled(app), @@ -119,17 +125,24 @@ internal class SettingsViewModel( ), imageQuality = AirPlayPersistence.loadImageQuality(app), phoneName = - if (DiPlayPreferences.phoneAddress(app) == null) + if (WheelPlayPreferences.phoneAddress(app) == null) app.getString(R.string.settings_unselected) - else DiPlayPreferences.phoneName(app), + else WheelPlayPreferences.phoneName(app), manufacturer = AirPlayPersistence.loadSimulatedManufacturerName(app), iconPath = AirPlayPersistence.loadCustomAirPlayIconFile(app)?.absolutePath, iconVersion = AirPlayPersistence.loadCustomAirPlayIconFile(app)?.lastModified() ?: 0, - margins = AirPlayPersistence.loadSafeMargins(app), - legacySafeArea = - (WebSession.currentBrowserViewport ?: (1280 to 720)).let { (w, h) -> - AirPlayPersistence.loadSafeAreaRect(app, w, h) != null + margins = margins, + legacySafeArea = legacyRect != null, + legacyMargins = + legacyRect?.let { + CarPlaySafeMargins( + percent(it.top, height), + percent(height - it.bottom, height), + percent(it.left, width), + percent(width - it.right, width), + ) }, + safeAreaSlot = CarPlayResolutionSlot(width, height), ssid = AirPlayPersistence.loadManualHotspotSsid(app), password = AirPlayPersistence.loadManualHotspotPassphrase(app), httpPort = WebListenSettings.httpPort(app), @@ -142,6 +155,13 @@ internal class SettingsViewModel( tesla = TeslaHttpCompatibility.config(app), browsers = WebSession.rememberedBrowsers(app).list(), ) + } + + private fun safeAreaDisplaySize(): CarPlayResolutionSlot = + CarPlayResolutionSlot.forBrowser( + WebSession.currentBrowserViewport, + AirPlayPersistence.loadAdaptiveBrowserSize(app), + ) fun choose(setting: ChoiceSetting, index: Int): SettingsEffect { if (state.value.selected(setting) == index) return SettingsEffect.None @@ -244,7 +264,7 @@ internal class SettingsViewModel( val effect = when (setting) { ToggleSetting.AUTO_CONNECT -> { - DiPlayPreferences.saveAutoConnect(app, checked) + WheelPlayPreferences.saveAutoConnect(app, checked) SettingsEffect.None } ToggleSetting.AUTO_START -> { @@ -372,10 +392,11 @@ internal class SettingsViewModel( reload() } - fun saveMargins(margins: CarPlaySafeMargins): SettingsEffect { - AirPlayPersistence.saveSafeMargins(app, margins) + fun saveMargins(slot: CarPlayResolutionSlot, margins: CarPlaySafeMargins): SettingsEffect { + AirPlayPersistence.saveSafeMargins(app, margins, slot.width, slot.height) reload() - return SettingsEffect.Reconnect() + return if (slot == safeAreaDisplaySize()) SettingsEffect.Reconnect() + else SettingsEffect.None } fun resetBranding(includeName: Boolean) { diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/TeslaSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/TeslaSettingsScreen.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/settings/TeslaSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/TeslaSettingsScreen.kt index eb178fb..dd62a80 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/TeslaSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/TeslaSettingsScreen.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/VehicleSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/VehicleSettingsScreen.kt similarity index 98% rename from common/src/main/java/com/shilapi/xcertplay/settings/VehicleSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/VehicleSettingsScreen.kt index d9428b0..4a99cf2 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/VehicleSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/VehicleSettingsScreen.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import android.graphics.BitmapFactory import androidx.compose.foundation.Image diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/WebSettingsScreen.kt b/common/src/main/java/moe/feng/wheelplay/settings/WebSettingsScreen.kt similarity index 99% rename from common/src/main/java/com/shilapi/xcertplay/settings/WebSettingsScreen.kt rename to common/src/main/java/moe/feng/wheelplay/settings/WebSettingsScreen.kt index 032718d..1f578e6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/settings/WebSettingsScreen.kt +++ b/common/src/main/java/moe/feng/wheelplay/settings/WebSettingsScreen.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -package com.shilapi.xcertplay.settings +package moe.feng.wheelplay.settings import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.selectable diff --git a/common/src/main/java/moe/feng/wheelplay/ui/CarPlayConnectionDialog.kt b/common/src/main/java/moe/feng/wheelplay/ui/CarPlayConnectionDialog.kt new file mode 100644 index 0000000..241cf06 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/CarPlayConnectionDialog.kt @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.shilapi.xcertplay.i18n.UiText +import com.shilapi.xcertplay.shared.R as UiR + +internal data class CarPlayConnectionUiState( + val stage: UiText = UiText(UiR.string.host_preparing_carplay), + val failed: Boolean = false, + val wifiResetRequired: Boolean = false, + val stopping: Boolean = false, + val requestChannelPermission: Boolean = false, +) + +@Composable +internal fun CarPlayConnectionDialog( + state: CarPlayConnectionUiState, + onCancel: () -> Unit, + onBackground: () -> Unit, + onSettings: () -> Unit, + onResetWifi: () -> Unit, + onChannelPermission: (Boolean) -> Unit, +) { + if (state.requestChannelPermission) { + AlertDialog( + onDismissRequest = { onChannelPermission(false) }, + title = { Text(stringResource(UiR.string.host_improve_hotspot_channel_detection)) }, + text = { + Text( + stringResource( + UiR.string.ui_android_requires_precise_location_permission_to_read_wi_fi + ), + Modifier.verticalScroll(rememberScrollState()), + ) + }, + confirmButton = { + TextButton(onClick = { onChannelPermission(true) }) { + Text(stringResource(UiR.string.host_allow)) + } + }, + dismissButton = { + TextButton(onClick = { onChannelPermission(false) }) { + Text(stringResource(UiR.string.host_continue_with_auto)) + } + }, + properties = DialogProperties(dismissOnClickOutside = false), + ) + return + } + AlertDialog( + modifier = Modifier.testTag("carplay:connection-dialog"), + onDismissRequest = { if (!state.stopping) onCancel() }, + properties = + DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = !state.stopping), + title = { + Text( + stringResource( + when { + state.stopping -> UiR.string.carplay_connection_stopping + state.failed -> UiR.string.carplay_connection_failed + else -> UiR.string.host_connecting_to_iphone + } + ) + ) + }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + if (state.stopping) stringResource(UiR.string.carplay_connection_stopping_hint) + else state.stage.resolve(LocalContext.current), + Modifier.testTag("carplay:connection-stage").semantics { + liveRegion = LiveRegionMode.Polite + }, + ) + if (!state.failed || state.stopping) { + LinearProgressIndicator( + Modifier.fillMaxWidth().testTag("carplay:connection-progress") + ) + } + } + }, + confirmButton = { + if (!state.stopping) { + TextButton( + onClick = + when { + state.wifiResetRequired -> onResetWifi + state.failed -> onSettings + else -> onBackground + }, + modifier = Modifier.testTag("carplay:connection-action"), + ) { + Text( + stringResource( + when { + state.wifiResetRequired -> UiR.string.ui_reset_carplay_wi_fi + state.failed -> UiR.string.carplay_connection_settings + else -> UiR.string.carplay_connection_background + } + ) + ) + } + } + }, + dismissButton = { + TextButton( + onClick = onCancel, + enabled = !state.stopping, + modifier = Modifier.testTag("carplay:connection-cancel"), + ) { + Text(stringResource(UiR.string.carplay_connection_cancel)) + } + }, + ) +} diff --git a/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayDialogs.kt b/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayDialogs.kt new file mode 100644 index 0000000..1edf7bd --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayDialogs.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +/** Transient requests: secrets and callbacks never enter saved state or a retained ViewModel. */ +internal class WheelPlayDialogs { + data class Action(val label: String, val run: () -> Unit) + data class Field(val key: String, val label: String, val initial: String, val secret: Boolean = false, val numeric: Boolean = false) + sealed interface Request { + val title: String + data class Message(override val title: String, val text: String, val actions: List, val onCancel: () -> Unit) : Request + data class Choices(override val title: String, val options: List, val selected: Int?, val extra: Action?, val onCancel: () -> Unit, val choose: (Int) -> Unit) : Request + data class Form(override val title: String, val description: String, val fields: List, val checkLabel: String?, val checked: Boolean, + val resetLabel: String?, val defaults: List?, val submit: (List, Boolean) -> String?) : Request + } + var request by mutableStateOf(null) + private set + fun message(title: String, text: String, actions: List, onCancel: () -> Unit = {}) { + cancel(); request = Request.Message(title, text, actions, onCancel) + } + fun choices(title: String, options: List, selected: Int? = null, extra: Action? = null, onCancel: () -> Unit = {}, choose: (Int) -> Unit) { + cancel(); request = Request.Choices(title, options, selected, extra, onCancel, choose) + } + fun form(title: String, description: String, fields: List, checkLabel: String? = null, checked: Boolean = false, + resetLabel: String? = null, defaults: List? = null, submit: (List, Boolean) -> String?) { + cancel(); request = Request.Form(title, description, fields, checkLabel, checked, resetLabel, defaults, submit) + } + fun complete(current: Request, action: () -> Unit) { if (request === current) { request = null; action() } } + fun cancel() { + val previous = request; request = null + when (previous) { is Request.Message -> previous.onCancel(); is Request.Choices -> previous.onCancel(); else -> Unit } + } +} + +@Composable internal fun WheelPlayDialog(host: WheelPlayDialogs) { + val request = host.request ?: return + key(request) { + when (request) { + is WheelPlayDialogs.Request.Message -> AlertDialog( + onDismissRequest = host::cancel, title = { Text(request.title) }, + text = { Text(request.text, Modifier.verticalScroll(rememberScrollState())) }, + confirmButton = { Column { request.actions.forEachIndexed { index, action -> + TextButton(onClick = { host.complete(request, action.run) }, modifier = Modifier.testTag("dialog:action:$index")) { Text(action.label) } + } } }, dismissButton = { TextButton(onClick = host::cancel) { Text(stringResource(android.R.string.cancel)) } }, + ) + is WheelPlayDialogs.Request.Choices -> AlertDialog( + onDismissRequest = host::cancel, title = { Text(request.title) }, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + request.options.forEachIndexed { index, option -> + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("dialog:choice:$index") + .selectable(request.selected == index, role = Role.RadioButton, onClick = { host.complete(request) { request.choose(index) } }), + verticalAlignment = Alignment.CenterVertically) { + if (request.selected != null) RadioButton(selected = request.selected == index, onClick = null) + Text(option, Modifier.padding(8.dp)) + } + } + } }, confirmButton = { request.extra?.let { action -> TextButton(onClick = { host.complete(request, action.run) }) { Text(action.label) } } }, + dismissButton = { TextButton(onClick = host::cancel) { Text(stringResource(android.R.string.cancel)) } }, + ) + is WheelPlayDialogs.Request.Form -> { + val values = remember { mutableStateListOf().also { it.addAll(request.fields.map { field -> field.initial }) } } + var checked by remember { mutableStateOf(request.checked) } + var error by remember { mutableStateOf(null) } + DisposableEffect(Unit) { onDispose { values.clear() } } + AlertDialog(onDismissRequest = host::cancel, title = { Text(request.title) }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(request.description) + request.fields.forEachIndexed { index, field -> + OutlinedTextField(value = values[index], onValueChange = { values[index] = it; error = null }, + label = { Text(field.label) }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("form:${field.key}"), + visualTransformation = if (field.secret) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = when { field.secret -> KeyboardType.Password; field.numeric -> KeyboardType.Number; else -> KeyboardType.Text })) + } + request.checkLabel?.let { label -> + Row(Modifier.fillMaxWidth().selectable(checked, role = Role.Checkbox, onClick = { checked = !checked }), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked, onCheckedChange = null); Text(label) + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("form:error")) } + request.defaults?.let { defaults -> + TextButton(onClick = { values.clear(); values.addAll(defaults); error = null }, modifier = Modifier.testTag("form:reset")) { Text(request.resetLabel.orEmpty()) } + } + } }, + confirmButton = { TextButton(onClick = { + error = request.submit(values.toList(), checked) + if (error == null) host.complete(request) {} + }, modifier = Modifier.testTag("dialog:confirm")) { Text(stringResource(android.R.string.ok)) } }, + dismissButton = { TextButton(onClick = host::cancel) { Text(stringResource(android.R.string.cancel)) } }, + ) + } + } + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayScreen.kt b/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayScreen.kt new file mode 100644 index 0000000..673ca39 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayScreen.kt @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.shared.R as UiR +import moe.feng.wheelplay.settings.* +import moe.feng.wheelplay.ui.theme.WheelPlayTheme + +internal typealias UiDispatch = (UiAction, String?) -> Unit + +@Composable +internal fun WheelPlayScreen( + page: String, mode: AaConnection, state: WheelPlayUiState, settings: SettingsViewModel, + settingsActions: SettingsActions, dialogs: SettingsDialogHost, sourceDialogs: WheelPlayDialogs, snackbar: SnackbarHostState, dispatch: UiDispatch, +) { + WheelPlayTheme { + val savedPages = rememberSaveableStateHolder() + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + snackbarHost = { SnackbarHost(snackbar) }, + bottomBar = { + NavigationBar { + listOf(Triple("service", UiR.string.ui_start, R.drawable.ic_server_launch), + Triple("phone", UiR.string.source_tab, R.drawable.ic_server_phone), + Triple("settings", R.string.settings_title, R.drawable.ic_server_settings)).forEach { (id, title, icon) -> + NavigationBarItem( + selected = page == id || (id == "phone" && SourcePage.entries.any { it.id == page }), + onClick = { dispatch(UiAction.NAVIGATE, id) }, + icon = { Icon(painterResource(icon), null) }, label = { Text(stringResource(title)) }, + modifier = Modifier.testTag("nav:$id"), + ) + } + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) { + savedPages.SaveableStateProvider(page) { + if (page == "settings") SettingsRoute(settings, settingsActions, dialogs, showDialogs = false) {} + else PageContent(page) { + when (page) { + "service" -> HomeScreen(state.home, dispatch) + "phone" -> SourceIndex(state.sources, dispatch) + else -> SourcePage.entries.find { it.id == page }?.let { SourceDetail(it, mode, state.sources, dispatch) } + } + } + } + } + } + // Certificate/import flows may finish while another tab is selected. + SettingsPlatformDialog(dialogs) + WheelPlayDialog(sourceDialogs) + } +} + +@Composable +private fun PageContent(page: String, content: @Composable ColumnScope.() -> Unit) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val gutter = maxOf(if (maxWidth < 360.dp) 16.dp else 24.dp, (maxWidth - 760.dp) / 2) + Column(Modifier.fillMaxSize().testTag("page:$page").verticalScroll(rememberScrollState()) + .padding(horizontal = gutter).padding(top = 24.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), content = content) + } +} + +@Composable private fun Description(text: String) { Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) } +@Composable private fun Heading(text: String) { Text(text, style = MaterialTheme.typography.titleLarge, modifier = Modifier.semantics { heading() }) } +@Composable private fun SourcePage.name() = if (title != 0) stringResource(title) else fallback + +@Composable +private fun Action(label: String, action: UiAction, dispatch: UiDispatch, argument: String? = null, + primary: Boolean = false, enabled: Boolean = true, tag: String = "action:${action.name}") { + val modifier = Modifier.testTag(tag).heightIn(min = if (primary) 56.dp else 48.dp) + if (primary) Button(onClick = { dispatch(action, argument) }, enabled = enabled, modifier = modifier.fillMaxWidth()) { Text(label) } + else Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { dispatch(action, argument) }, enabled = enabled, modifier = modifier) { Text(label) } + } +} + +@Composable private fun CardSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Card(shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) { + Column(Modifier.fillMaxWidth().padding(if (LocalConfiguration.current.screenWidthDp < 360) 16.dp else 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Heading(title); content() + } + } +} + +@Composable private fun Disclosure(title: String, tag: String, content: @Composable ColumnScope.() -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + val expansion = stringResource(if (expanded) UiR.string.ui_expanded else UiR.string.ui_collapsed) + TextButton(onClick = { expanded = !expanded }, modifier = Modifier.fillMaxWidth().testTag(tag).semantics { stateDescription = expansion }) { + Text(title, Modifier.weight(1f)) + Icon(painterResource(R.drawable.ic_settings_chevron), null, Modifier.size(20.dp).rotate(if (expanded) 270f else 90f)) + } + if (expanded) Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), content = content) +} + +@Composable private fun HomeScreen(state: HomeUi, dispatch: UiDispatch) { + Heading(stringResource(R.string.app_name)) + Text(stringResource(UiR.string.home_projection_headline), style = MaterialTheme.typography.headlineSmall) + CardSection(stringResource(UiR.string.ui_head_unit_access)) { + Description(stringResource(UiR.string.ui_local_network_address)) + SelectionContainer { Text(state.address, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) } + if (state.addressDescription.isNotEmpty()) Description(state.addressDescription) + Action(stringResource(UiR.string.ui_try_in_this_device_s_browser), UiAction.BROWSER, dispatch, enabled = state.browserEnabled) + if (state.otherAddresses.isNotEmpty()) Disclosure(stringResource(UiR.string.ui_other_addresses), "home:addresses") { + state.otherAddresses.forEach { entry -> Description(entry.description); SelectionContainer { Text(entry.url) } } + } + } + CardSection(stringResource(UiR.string.ui_browser_pairing)) { + if (!state.viewerConnected) { + Description(stringResource(UiR.string.ui_scan_the_qr_code_on_the_head_unit_web)) + Action(stringResource(UiR.string.ui_scan_qr_code_to_pair), UiAction.SCAN, dispatch, primary = true) + Description(stringResource(UiR.string.ui_or_enter_the_pairing_code_on_the_web_page)) + SelectionContainer { Text(state.code, style = MaterialTheme.typography.headlineLarge) } + } + Description(stringResource(if (state.viewerConnected) UiR.string.ui_head_unit_browser_connected else UiR.string.ui_waiting_for_head_unit_browser)) + } + CardSection(stringResource(UiR.string.home_source_title)) { + Action(state.sourceName, UiAction.CHOOSE_HOME, dispatch) + Text(state.stage) + if (state.showConnect) Action(state.connectLabel, UiAction.HOME_CONNECT, dispatch, primary = true, enabled = state.connectEnabled) + state.stopLabel?.let { Action(it, UiAction.HOME_STOP, dispatch) } + Action(stringResource(UiR.string.home_source_settings), UiAction.HOME_SETTINGS, dispatch) + } + if (state.showAudioHint) Description(stringResource(UiR.string.ui_audio_and_microphone_use_this_device_by_default_enable)) + Action(stringResource(UiR.string.ui_stop_service), UiAction.STOP_SERVICE, dispatch) +} + +@Composable private fun SourceIndex(state: SourcesUi, dispatch: UiDispatch) { + Text(stringResource(UiR.string.source_tab), style = MaterialTheme.typography.headlineSmall) + Description(state.overview) + SourceRows(listOf(SourcePage.CARPLAY, SourcePage.ANDROID_AUTO, SourcePage.ICCOA, SourcePage.VNC, SourcePage.DESKTOP), state, dispatch) + Heading(stringResource(UiR.string.source_tools)) + SourceRows(listOf(SourcePage.PLUGINS, SourcePage.DIAGNOSTICS), state, dispatch) +} + +@Composable private fun SourceRows(pages: List, state: SourcesUi, dispatch: UiDispatch) { + BoxWithConstraints { + // Available content width, including the centered tablet gutter, determines the grid. + val count = if (maxWidth >= 552.dp && LocalDensity.current.fontScale <= 1.3f) 2 else 1 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + pages.chunked(count).forEach { row -> + Row(Modifier.height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { source -> + Card(onClick = { dispatch(UiAction.NAVIGATE, source.id) }, modifier = Modifier.weight(1f).fillMaxHeight().testTag(source.id), + shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) { + Row(Modifier.fillMaxWidth().heightIn(min = 88.dp).padding(16.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(painterResource(source.icon), null, Modifier.size(28.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(source.name(), style = MaterialTheme.typography.titleMedium) + Text(stringResource(source.summary), style = MaterialTheme.typography.bodyMedium) + state.flags[source]?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) } + } + Icon(painterResource(R.drawable.ic_settings_chevron), null, Modifier.size(20.dp)) + } + } + } + if (row.size < count) Spacer(Modifier.weight(1f)) + } + } + } + } +} + +@Composable private fun SessionSummary(page: SourcePage, state: SourcesUi, dispatch: UiDispatch) { + val session = state.sessions[page.group] ?: return + Description(session.message) + if (session.canStop) Action(stringResource(UiR.string.projection_stop), UiAction.STOP_SOURCE, dispatch, page.group) +} + +@Composable private fun SourceDetail(page: SourcePage, mode: AaConnection, state: SourcesUi, dispatch: UiDispatch) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { dispatch(UiAction.NAVIGATE, "phone") }, modifier = Modifier.testTag("source:back")) { + Icon(painterResource(R.drawable.ic_settings_back), stringResource(UiR.string.source_back)) + } + Text(page.name(), style = MaterialTheme.typography.titleLarge, modifier = Modifier.semantics { heading() }) + } + if (page != SourcePage.CARPLAY) SessionSummary(page, state, dispatch) + when (page) { + SourcePage.CARPLAY -> CarPlayScreen(state, dispatch) + SourcePage.ANDROID_AUTO -> AndroidAutoScreen(mode, dispatch) + SourcePage.ICCOA, SourcePage.PLUGINS -> PluginsScreen(page == SourcePage.ICCOA, state, dispatch) + SourcePage.VNC -> { + Description(stringResource(UiR.string.source_vnc_brief)) + Action(stringResource(UiR.string.vnc_connect), UiAction.VNC, dispatch, primary = true) + Disclosure(stringResource(UiR.string.source_vnc_help), "source:help") { + Description(stringResource(UiR.string.vnc_hint)); Description(stringResource(UiR.string.vnc_security_hint)) + } + } + SourcePage.DESKTOP -> { + Description(stringResource(UiR.string.desktop_hint)) + Action(state.desktopDisplay, UiAction.DESKTOP_DISPLAY, dispatch) + Description(state.desktopStatus) + Action(stringResource(UiR.string.desktop_open_shizuku), UiAction.DESKTOP_SHIZUKU, dispatch) + Action(stringResource(UiR.string.desktop_authorize), UiAction.DESKTOP_AUTHORIZE, dispatch) + Action(stringResource(UiR.string.desktop_choose_app), UiAction.DESKTOP_APP, dispatch) + if (state.desktopApp.isNotEmpty()) Description(state.desktopApp) + Action(stringResource(UiR.string.desktop_start), UiAction.DESKTOP_START, dispatch, primary = true, enabled = state.desktopEnabled) + } + SourcePage.DIAGNOSTICS -> { + Description(stringResource(UiR.string.projection_demo_hint)) + Action(stringResource(UiR.string.projection_choose_demo), UiAction.DEMO, dispatch) + } + } +} + +@Composable private fun CarPlayScreen(state: SourcesUi, dispatch: UiDispatch) { + Description(state.carPlayStatus) + CardSection(stringResource(UiR.string.ui_wireless_connection)) { + Description(stringResource(UiR.string.ui_pair_the_iphone_with_this_android_device_over_bluetooth)) + Action("iPhone · ${state.phone}", UiAction.CHOOSE_PHONE, dispatch) + if (state.carPlayRunning) Action(stringResource(UiR.string.ui_disconnect_iphone), UiAction.CARPLAY_STOP, dispatch) + else Action(stringResource(UiR.string.ui_connect_iphone), UiAction.CARPLAY_CONNECT, dispatch, primary = true, enabled = state.carPlayEnabled) + state.hotspotHint?.let { + Description(it); Action(stringResource(UiR.string.ui_open_hotspot_settings), UiAction.HOTSPOT, dispatch) + } + } + CardSection(stringResource(UiR.string.ui_usb_connection)) { + Description(stringResource(UiR.string.ui_connect_the_iphone_to_this_device_with_a_data)) + Action(stringResource(UiR.string.ui_connect_over_usb), UiAction.CARPLAY_USB, dispatch) + } + state.setupError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Disclosure(stringResource(UiR.string.source_connection_help), "source:help") { + Description(stringResource(UiR.string.ui_this_device_receives_carplay_the_head_unit_browser_provides)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable private fun AndroidAutoScreen(mode: AaConnection, dispatch: UiDispatch) { + Heading(stringResource(UiR.string.source_connection_method)) + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + AaConnection.entries.forEachIndexed { index, item -> + SegmentedButton(selected = mode == item, onClick = { dispatch(UiAction.AA_MODE, item.name) }, + shape = SegmentedButtonDefaults.itemShape(index, 3), icon = {}, modifier = Modifier.testTag("aa:${item.name}")) { + Text(stringResource(when (item) { AaConnection.LOCAL -> UiR.string.source_aa_local; AaConnection.USB -> UiR.string.source_aa_usb; AaConnection.NETWORK -> UiR.string.source_aa_network })) + } + } + } + key(mode) { + Description(stringResource(when (mode) { AaConnection.LOCAL -> UiR.string.source_aa_local_brief; AaConnection.USB -> UiR.string.aa_usb_hint; AaConnection.NETWORK -> UiR.string.source_aa_network_brief })) + when (mode) { + AaConnection.LOCAL -> { + Action(stringResource(UiR.string.aa_local_connect), UiAction.AA_LOCAL, dispatch, primary = true) + Action(stringResource(UiR.string.aa_local_help), UiAction.AA_HELP, dispatch) + } + AaConnection.USB -> Action(stringResource(UiR.string.aa_usb_connect), UiAction.AA_USB, dispatch, primary = true) + AaConnection.NETWORK -> Action(stringResource(UiR.string.aa_remote_connect), UiAction.AA_NETWORK, dispatch, primary = true) + } + Disclosure(stringResource(UiR.string.source_connection_help), "source:help") { + Description(stringResource(when (mode) { AaConnection.LOCAL -> UiR.string.aa_local_summary; AaConnection.USB -> UiR.string.source_aa_usb_help; AaConnection.NETWORK -> UiR.string.aa_tcp_hint })) + } + } +} + +@Composable private fun PluginsScreen(carLink: Boolean, state: SourcesUi, dispatch: UiDispatch) { + val plugins = state.plugins.filter { !carLink || it.carLink } + if (plugins.isEmpty()) { + Heading(stringResource(if (carLink) UiR.string.plugin_iccoa_missing else UiR.string.source_no_plugins)) + Description(stringResource(UiR.string.source_plugin_empty_hint)) + Disclosure(stringResource(UiR.string.source_install_help), "plugin:install-help") { + Description(stringResource(if (carLink) UiR.string.plugin_iccoa_hint else UiR.string.source_plugin_install_steps)) + } + } + plugins.forEach { plugin -> key(plugin.id) { + CardSection(plugin.name) { + plugin.failure?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Description(plugin.problem ?: stringResource(if (plugin.enabled) UiR.string.plugin_enabled else UiR.string.source_plugin_enable_hint)) + if (plugin.problem == null) { + if (!plugin.enabled) Action(stringResource(UiR.string.plugin_enable), UiAction.PLUGIN_ENABLE, dispatch, plugin.id, primary = true, tag = "plugin:enable:${plugin.packageName}") + else if (plugin.canConnect) Action(stringResource(UiR.string.plugin_connect), UiAction.PLUGIN_CONNECT, dispatch, plugin.id, primary = true, tag = "plugin:connect:${plugin.packageName}") + } + if (plugin.hasSettings) Action(stringResource(UiR.string.plugin_settings), UiAction.PLUGIN_SETTINGS, dispatch, plugin.id, tag = "plugin:settings:${plugin.packageName}") + Disclosure(stringResource(UiR.string.source_plugin_manage), "plugin:manage:${plugin.packageName}") { + SelectionContainer { Text(plugin.packageName) } + if (plugin.enabled) Action(stringResource(UiR.string.plugin_disable), UiAction.PLUGIN_DISABLE, dispatch, plugin.id, tag = "plugin:disable:${plugin.packageName}") + } + } + } } + Action(stringResource(UiR.string.plugin_refresh), UiAction.PLUGIN_REFRESH, dispatch) + if (carLink) { + if (plugins.isEmpty()) Action(stringResource(UiR.string.plugin_sources), UiAction.NAVIGATE, dispatch, SourcePage.PLUGINS.id) + } else Disclosure(stringResource(UiR.string.source_plugin_capabilities), "plugin:capabilities") { + Description(stringResource(UiR.string.plugin_install_hint)) + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayUiState.kt b/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayUiState.kt new file mode 100644 index 0000000..53fab0b --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/WheelPlayUiState.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.ui + +import com.shilapi.xcertplay.projection.ProjectionSourceIds as Id +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.shared.R as UiR + +internal enum class SourcePage(val id: String, val title: Int, val fallback: String, val summary: Int, val icon: Int, val group: String) { + CARPLAY("source-carplay", UiR.string.source_carplay, "", UiR.string.source_carplay_summary, R.drawable.ic_server_phone, Id.CARPLAY), + ANDROID_AUTO("source-android-auto", 0, "Android Auto", UiR.string.source_aa_summary, R.drawable.ic_server_phone, Id.ANDROID_AUTO), + ICCOA("source-iccoa", 0, "ICCOA CarLink", UiR.string.source_carlink_brief, R.drawable.ic_server_wifi, Id.ICCOA), + VNC("source-vnc", 0, "VNC", UiR.string.vnc_summary, R.drawable.ic_server_server, Id.VNC), + DESKTOP("source-desktop", UiR.string.desktop_title, "", UiR.string.source_desktop_brief, R.drawable.ic_server_phone, Id.ANDROID_DESKTOP), + DIAGNOSTICS("source-diagnostics", UiR.string.source_diagnostics, "", UiR.string.source_diagnostics_summary, R.drawable.ic_server_server, Id.DEMO), + PLUGINS("source-plugins", UiR.string.plugin_sources, "", UiR.string.plugin_summary, R.drawable.ic_server_settings, Id.PLUGIN), +} +internal enum class AaConnection { LOCAL, USB, NETWORK } +internal enum class UiAction { + NAVIGATE, AA_MODE, BROWSER, SCAN, CHOOSE_HOME, HOME_CONNECT, HOME_STOP, HOME_SETTINGS, STOP_SERVICE, + CHOOSE_PHONE, CARPLAY_CONNECT, CARPLAY_USB, CARPLAY_STOP, HOTSPOT, + AA_LOCAL, AA_USB, AA_NETWORK, AA_HELP, VNC, STOP_SOURCE, + PLUGIN_REFRESH, PLUGIN_ENABLE, PLUGIN_DISABLE, PLUGIN_SETTINGS, PLUGIN_CONNECT, + DESKTOP_DISPLAY, DESKTOP_SHIZUKU, DESKTOP_AUTHORIZE, DESKTOP_APP, DESKTOP_START, DEMO, +} +internal data class AddressUi(val description: String, val url: String) +internal data class HomeUi( + val address: String = "", val addressDescription: String = "", val otherAddresses: List = emptyList(), + val browserEnabled: Boolean = false, val code: String = "", val viewerConnected: Boolean = false, + val sourceName: String = "", val stage: String = "", val connectLabel: String = "", + val showConnect: Boolean = false, val connectEnabled: Boolean = false, val stopLabel: String? = null, + val showAudioHint: Boolean = true, +) +internal data class SessionUi(val message: String = "", val canStop: Boolean = false) +internal data class PluginUi( + val id: String, val name: String, val packageName: String, val carLink: Boolean, + val enabled: Boolean, val problem: String?, val failure: String?, val hasSettings: Boolean, val canConnect: Boolean, +) +internal data class SourcesUi( + val overview: String = "", val flags: Map = emptyMap(), + val sessions: Map = emptyMap(), val plugins: List = emptyList(), + val carPlayStatus: String = "", val carPlayRunning: Boolean = false, val carPlayEnabled: Boolean = false, + val phone: String = "", val setupError: String? = null, val hotspotHint: String? = null, + val desktopStatus: String = "", val desktopEnabled: Boolean = false, val desktopDisplay: String = "", val desktopApp: String = "", +) +internal data class WheelPlayUiState(val home: HomeUi = HomeUi(), val sources: SourcesUi = SourcesUi()) diff --git a/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropScreen.kt b/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropScreen.kt new file mode 100644 index 0000000..4c5cfe4 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropScreen.kt @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Canvas overlay and grid drawing adapted from crop-kit (MIT), Copyright (c) 2022 Tanish Ranjan. +// See docs/CROP-KIT-LICENSE.txt and the packaged third-party notice. +package moe.feng.wheelplay.ui.crop + +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.* +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.shared.R as UiR +import kotlin.math.min + +@Composable +internal fun IconCropScreen( + bitmap: Bitmap?, + selection: IconCropSelection, + error: Int?, + saving: Boolean, + onSelectionChange: UpdateIconCropSelection, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { insets -> + Column(Modifier.fillMaxSize().padding(insets)) { + Text( + stringResource(UiR.string.ui_crop_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + ) + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { + val wide = maxWidth >= 600.dp && maxWidth > maxHeight + val toolsMaxHeight = maxHeight * .48f + val tools: @Composable (Modifier) -> Unit = { modifier -> + CropTools(bitmap, selection, error, saving, onSelectionChange, modifier) + } + val preview: @Composable (Modifier) -> Unit = { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { + if (bitmap != null) { + CropCanvas( + bitmap, + selection, + !saving, + onSelectionChange, + Modifier.fillMaxSize(), + ) + } else if (error == null) { + CircularProgressIndicator(Modifier.size(40.dp)) + } + } + } + if (wide) { + Row(Modifier.fillMaxSize()) { + preview(Modifier.weight(1f).fillMaxHeight()) + tools(Modifier.width(280.dp).fillMaxHeight()) + } + } else { + Column(Modifier.fillMaxSize()) { + preview(Modifier.weight(1f).fillMaxWidth()) + tools(Modifier.fillMaxWidth().heightIn(max = toolsMaxHeight)) + } + } + } + Row( + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onCancel, enabled = !saving) { + Text(stringResource(UiR.string.ui_cancel)) + } + Button( + onClick = onSave, + enabled = bitmap != null && !saving, + modifier = Modifier.testTag("crop:save"), + ) { + if (saving) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text( + stringResource( + if (saving) UiR.string.ui_crop_saving else UiR.string.ui_save_icon + ) + ) + } + } + } + } +} + +@Composable +private fun CropTools( + bitmap: Bitmap?, + selection: IconCropSelection, + error: Int?, + saving: Boolean, + onSelectionChange: UpdateIconCropSelection, + modifier: Modifier, +) { + Column( + modifier.verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 12.dp) + ) { + Text(stringResource(UiR.string.ui_crop_format), style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(8.dp)) + Text( + stringResource( + error + ?: if (bitmap == null) UiR.string.ui_reading_image + else UiR.string.ui_drag_to_reposition_and_pinch_to_zoom_saved_as + ), + style = MaterialTheme.typography.bodyMedium, + color = + if (error == null) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (bitmap != null) { + Spacer(Modifier.height(12.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(UiR.string.ui_crop_zoom_value, (selection.zoom * 100).toInt()), + style = MaterialTheme.typography.labelLarge, + ) + TextButton( + onClick = { onSelectionChange { IconCropSelection() } }, + enabled = !saving, + modifier = Modifier.testTag("crop:reset"), + ) { + Text(stringResource(UiR.string.ui_crop_reset)) + } + } + val zoomLabel = stringResource(UiR.string.ui_crop_zoom) + Slider( + value = selection.zoom, + onValueChange = { + onSelectionChange { current -> + current.withZoom(bitmap.width, bitmap.height, it) + } + }, + valueRange = 1f..IconCropSelection.MAX_ZOOM, + enabled = !saving, + modifier = + Modifier.testTag("crop:zoom").semantics { contentDescription = zoomLabel }, + ) + } + } +} + +@Composable +private fun CropCanvas( + bitmap: Bitmap, + selection: IconCropSelection, + enabled: Boolean, + onSelectionChange: UpdateIconCropSelection, + modifier: Modifier, +) { + val image = remember(bitmap) { bitmap.asImageBitmap() } + val onChange by rememberUpdatedState(onSelectionChange) + val description = stringResource(UiR.string.ui_crop_preview) + val actions = + listOf( + stringResource(UiR.string.ui_crop_move_left) to Offset(-1f, 0f), + stringResource(UiR.string.ui_crop_move_right) to Offset(1f, 0f), + stringResource(UiR.string.ui_crop_move_up) to Offset(0f, -1f), + stringResource(UiR.string.ui_crop_move_down) to Offset(0f, 1f), + ) + Canvas( + modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + .clipToBounds() + .testTag("crop:preview") + .semantics { + contentDescription = description + if (enabled) + customActions = + actions.map { (label, direction) -> + CustomAccessibilityAction(label) { + onChange { current -> + current.transform( + bitmap.width, + bitmap.height, + Rect(0f, 0f, 100f, 100f), + Offset(50f, 50f), + direction * 10f, + 1f, + ) + } + true + } + } + } + .pointerInput(bitmap, enabled) { + if (enabled) + detectTransformGestures { centroid, pan, zoom, _ -> + val viewport = + cropViewport( + Size(size.width.toFloat(), size.height.toFloat()), + 24.dp.toPx(), + ) + onChange { current -> + current.transform( + bitmap.width, + bitmap.height, + viewport, + centroid, + pan, + zoom, + ) + } + } + } + ) { + val viewport = cropViewport(size, 24.dp.toPx()) + if (viewport.width <= 0) return@Canvas + // Checkerboard makes transparency visible without baking a background into the saved PNG. + val tile = 12.dp.toPx() + clipPath(Path().apply { addRect(viewport) }) { + for (y in 0..(viewport.height / tile).toInt()) { + for (x in 0..(viewport.width / tile).toInt()) { + drawRect( + if ((x + y) % 2 == 0) Color(0xff41494d) else Color(0xff30383c), + viewport.topLeft + Offset(x * tile, y * tile), + Size(tile, tile), + ) + } + } + } + val source = selection.sourceRect(bitmap.width, bitmap.height) + val scale = viewport.width / source.width + withTransform({ + translate(viewport.left - source.left * scale, viewport.top - source.top * scale) + scale(scale, scale, Offset.Zero) + }) { + drawImage(image) + } + clipPath(Path().apply { addRect(viewport) }, clipOp = ClipOp.Difference) { + drawRect(Color.Black.copy(alpha = .65f)) + } + drawRect(Color.White, viewport.topLeft, viewport.size, style = Stroke(2.dp.toPx())) + for (i in 1..2) { + val step = viewport.width * i / 3 + drawLine( + Color.White.copy(alpha = .45f), + Offset(viewport.left + step, viewport.top), + Offset(viewport.left + step, viewport.bottom), + 1.dp.toPx(), + ) + drawLine( + Color.White.copy(alpha = .45f), + Offset(viewport.left, viewport.top + step), + Offset(viewport.right, viewport.top + step), + 1.dp.toPx(), + ) + } + } +} + +private fun cropViewport(size: Size, padding: Float): Rect { + val side = (min(size.width, size.height) - 2 * padding).coerceAtLeast(0f) + return Rect(Offset((size.width - side) / 2, (size.height - side) / 2), Size(side, side)) +} diff --git a/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropState.kt b/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropState.kt new file mode 100644 index 0000000..c25303b --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropState.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.ui.crop + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.math.min + +internal typealias UpdateIconCropSelection = ((IconCropSelection) -> IconCropSelection) -> Unit + +/** Coordinates are relative to the decoded source, independent of the window size. */ +internal data class IconCropSelection( + val centerX: Float = .5f, + val centerY: Float = .5f, + val zoom: Float = 1f, +) { + fun sourceRect(width: Int, height: Int): Rect { + val edge = min(width, height) / zoom.coerceIn(1f, MAX_ZOOM) + val left = (centerX * width - edge / 2).coerceIn(0f, width - edge) + val top = (centerY * height - edge / 2).coerceIn(0f, height - edge) + return Rect(left, top, left + edge, top + edge) + } + + fun transform( + width: Int, + height: Int, + viewport: Rect, + centroid: Offset, + pan: Offset, + zoomChange: Float, + ): IconCropSelection { + if (viewport.width <= 0f || !zoomChange.isFinite() || zoomChange <= 0f) return this + val oldRect = sourceRect(width, height) + val newZoom = (zoom * zoomChange).coerceIn(1f, MAX_ZOOM) + val newEdge = min(width, height) / newZoom + val anchor = + oldRect.topLeft + (centroid - viewport.topLeft) * (oldRect.width / viewport.width) + val topLeft = anchor - (centroid + pan - viewport.topLeft) * (newEdge / viewport.width) + return IconCropSelection( + (topLeft.x + newEdge / 2).coerceIn(newEdge / 2, width - newEdge / 2) / width, + (topLeft.y + newEdge / 2).coerceIn(newEdge / 2, height - newEdge / 2) / height, + newZoom, + ) + } + + fun withZoom(width: Int, height: Int, value: Float): IconCropSelection { + val rect = copy(zoom = value.coerceIn(1f, MAX_ZOOM)).sourceRect(width, height) + return IconCropSelection( + rect.center.x / width, + rect.center.y / height, + value.coerceIn(1f, MAX_ZOOM), + ) + } + + /** + * Render the same floating-point source rectangle used by the preview, without rounding drift. + */ + fun render(source: Bitmap): Bitmap { + val rect = sourceRect(source.width, source.height) + val output = Bitmap.createBitmap(OUTPUT_PIXELS, OUTPUT_PIXELS, Bitmap.Config.ARGB_8888) + val scale = OUTPUT_PIXELS / rect.width + Canvas(output).apply { + scale(scale, scale) + translate(-rect.left, -rect.top) + drawBitmap(source, 0f, 0f, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)) + } + return output + } + + companion object { + const val MAX_ZOOM = 8f + const val OUTPUT_PIXELS = 1024 + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropViewModel.kt b/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropViewModel.kt new file mode 100644 index 0000000..fb7910a --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/crop/IconCropViewModel.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.ui.crop + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.shilapi.xcertplay.shared.R as UiR +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import moe.feng.wheelplay.AirPlayPersistence + +/** Retains decoding and a single save operation across Activity recreation. */ +internal class IconCropViewModel( + application: Application, + private val savedState: SavedStateHandle, +) : AndroidViewModel(application) { + var bitmap by mutableStateOf(null) + private set + + var error by mutableStateOf(null) + private set + + var saving by mutableStateOf(false) + private set + + var complete by mutableStateOf(false) + private set + + private var started = false + + var selection by + mutableStateOf( + savedState.get("selection")?.let { IconCropSelection(it[0], it[1], it[2]) } + ?: IconCropSelection() + ) + private set + + fun select(update: (IconCropSelection) -> IconCropSelection) { + if (saving) return + val value = update(selection) + selection = value + savedState["selection"] = floatArrayOf(value.centerX, value.centerY, value.zoom) + } + + fun load(uri: Uri) { + if (started) return + started = true + viewModelScope.launch { + try { + bitmap = + withContext(Dispatchers.IO) { + val source = + ImageDecoder.createSource( + getApplication().contentResolver, + uri, + ) + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + // ImageDecoder applies EXIF orientation before reporting/displaying the + // image. + val edge = maxOf(info.size.width, info.size.height) + decoder.setTargetSampleSize( + ((edge.toLong() + MAX_DECODE_EDGE - 1) / MAX_DECODE_EDGE).toInt() + ) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + error = UiR.string.ui_could_not_read_image_go_back_and_choose_a + } + } + } + + fun save() { + val source = bitmap ?: return + if (saving || complete) return + val crop = selection + saving = true + error = null + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + val output = crop.render(source) + val encoded = + try { + ByteArrayOutputStream().use { bytes -> + check(output.compress(Bitmap.CompressFormat.PNG, 100, bytes)) + bytes.toByteArray() + } + } finally { + output.recycle() + } + AirPlayPersistence.saveCustomAirPlayIcon(getApplication(), encoded) + } + complete = true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + error = UiR.string.ui_could_not_save_icon_try_again + } finally { + saving = false + } + } + } + + // The source bitmap can still be referenced by a Compose draw frame or an IO job after + // onCleared. Let Android reclaim it when those references disappear instead of recycling it. + private companion object { + const val MAX_DECODE_EDGE = 2048 + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/ui/theme/WheelPlayTheme.kt b/common/src/main/java/moe/feng/wheelplay/ui/theme/WheelPlayTheme.kt new file mode 100644 index 0000000..2501d8c --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/ui/theme/WheelPlayTheme.kt @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package moe.feng.wheelplay.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color + +@Composable +internal fun WheelPlayTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = + darkColorScheme( + primary = Color(0xffb4d9b1), + onPrimary = Color(0xff20391f), + secondaryContainer = Color(0xff3c4b39), + onSecondaryContainer = Color(0xffd9e7d3), + background = Color(0xff11161a), + surface = Color(0xff11161a), + surfaceContainer = Color(0xff1c2428), + onSurface = Color(0xffe2e8e3), + onSurfaceVariant = Color(0xffc1c9be), + outline = Color(0xff8b9389), + ), + content = content, + ) +} diff --git a/common/src/main/res/drawable/ic_diplay_notification.xml b/common/src/main/res/drawable/ic_wheelplay_notification.xml similarity index 100% rename from common/src/main/res/drawable/ic_diplay_notification.xml rename to common/src/main/res/drawable/ic_wheelplay_notification.xml diff --git a/common/src/main/res/layout/server_button_filled.xml b/common/src/main/res/layout/server_button_filled.xml deleted file mode 100644 index 548f226..0000000 --- a/common/src/main/res/layout/server_button_filled.xml +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/common/src/main/res/layout/server_button_outlined.xml b/common/src/main/res/layout/server_button_outlined.xml deleted file mode 100644 index bf45f63..0000000 --- a/common/src/main/res/layout/server_button_outlined.xml +++ /dev/null @@ -1,11 +0,0 @@ - diff --git a/common/src/main/res/values-zh-rCN/settings_strings.xml b/common/src/main/res/values-zh-rCN/settings_strings.xml index 62ecdbe..9b654f8 100644 --- a/common/src/main/res/values-zh-rCN/settings_strings.xml +++ b/common/src/main/res/values-zh-rCN/settings_strings.xml @@ -25,8 +25,8 @@ 昼夜模式、图片格式与质量立即生效;其他画面或串流参数修改后会重新连接。 音频输出立即生效;修改麦克风来源会重新连接。 修改后在下次连接 iPhone 生效。 - 本项目基于 DiPlay 修改 - 如果车机可以直接安装任意 Android 应用,可以尝试直接使用 DiPlay 以获得更佳的体验。本项目旨在通过浏览器提供无侵入 CarPlay 支持。 + 本项目基于 DiPlay 修改 + 如果车机可以直接安装任意 Android 应用,可以尝试直接使用 DiPlay 以获得更佳的体验。本项目旨在通过浏览器提供无侵入 CarPlay 支持。 尚未配置有效资源 · 导入后可启动 CarPlay 保存 取消 @@ -83,7 +83,7 @@ 右舵布局 将 CarPlay 控件移到右侧。 画面安全区域预览 - 高亮区域为 CarPlay 布局区域。每边 0–40%,0 表示不留白。 + 拖动边界或输入百分比。每边 0–40%,0 表示不留白。保存后生效,已连接的 CarPlay 将重新连接。 上边留白 下边留白 左边留白 @@ -152,8 +152,8 @@ 开源安装包不包含认证资源。可选择现有的两份文件,或下载 DiPlay 官网 APK 后在此导入,无需安装 DiPlay。 从 DiPlay APK 导入 手动选择认证资源 - DiPlay 官网下载 - 下载 APK 后返回导入,或分享给 WheelPlay + DiPlay 官网下载 + 下载 APK 后返回导入,或分享给 WheelPlay 仅提取认证资源并保存在此设备的应用私有目录,不上传。导入结束后删除临时 APK;卸载 WheelPlay 将删除已导入资源。 选择两份匹配的资源 先选择 identity.pk8(PKCS#8 私钥),再选择 certificate.p7b(P-256 证书)。两份文件必须来自同一套资源;取消或校验失败不会替换原有资源。 @@ -200,4 +200,8 @@ 图片质量(1–100) 默认 75。数值越高,细节越多,通常也更占带宽;JPEG 与 WebP 的相同数值不代表相同画质。保存后静态画面也会刷新,不影响 WebRTC 视频质量。 请输入 1 到 100 之间的整数。 + 此设置来自旧版像素配置。编辑并保存后将按预览转换为整数百分比(每边 0–40%);取消会保留原配置。 + %1$s增加 1%% + %1$s减少 1%% + 分辨率:%1$d × %2$d · 独立保存 diff --git a/common/src/main/res/values-zh-rTW/settings_strings.xml b/common/src/main/res/values-zh-rTW/settings_strings.xml index bd1bbab..98df829 100644 --- a/common/src/main/res/values-zh-rTW/settings_strings.xml +++ b/common/src/main/res/values-zh-rTW/settings_strings.xml @@ -25,8 +25,8 @@ 晝夜模式、圖片格式與品質立即生效;其他畫面或串流引數修改後會重新連線。 音訊輸出立即生效;修改麥克風來源會重新連線。 修改後在下次連線 iPhone 生效。 - 本專案基於 DiPlay 修改 - 如果車機可以直接安裝任意 Android 應用程式,可以嘗試直接使用 DiPlay 以獲得更佳的體驗。本專案旨在透過瀏覽器提供無侵入 CarPlay 支援。 + 本專案基於 DiPlay 修改 + 如果車機可以直接安裝任意 Android 應用程式,可以嘗試直接使用 DiPlay 以獲得更佳的體驗。本專案旨在透過瀏覽器提供無侵入 CarPlay 支援。 尚未配置有效資源 · 匯入後可啟動 CarPlay 儲存 取消 @@ -83,7 +83,7 @@ 右舵佈局 將 CarPlay 控制項移到右側。 畫面安全區域預覽 - 高亮區域為 CarPlay 佈局區域。每邊 0–40%,0 表示不留白。 + 拖曳邊界或輸入百分比。每邊 0–40%,0 表示不留白。儲存後生效,已連線的 CarPlay 將重新連線。 上邊留白 下邊留白 左邊留白 @@ -152,8 +152,8 @@ 開源安裝包不包含認證資源。可選擇現有的兩份檔案,或下載 DiPlay 官網 APK 後在此匯入,無需安裝 DiPlay。 從 DiPlay APK 匯入 手動選擇認證資源 - DiPlay 官網下載 - 下載 APK 後返回匯入,或分享給 WheelPlay + DiPlay 官網下載 + 下載 APK 後返回匯入,或分享給 WheelPlay 僅提取認證資源並儲存在此裝置的應用私有目錄,不上傳。匯入結束後刪除臨時 APK;解除安裝 WheelPlay 將刪除已匯入資源。 選擇兩份匹配的資源 先選擇 identity.pk8(PKCS#8 私密金鑰),再選擇 certificate.p7b(P-256 憑證)。兩份檔案必須來自同一套資源;取消或校驗失敗不會替換原有資源。 @@ -200,4 +200,8 @@ 圖片品質(1–100) 預設 75。數值越高,細節越多,通常也更占頻寬;JPEG 與 WebP 的相同數值不代表相同畫質。儲存後靜態畫面也會重新整理,不影響 WebRTC 視訊品質。 請輸入 1 到 100 之間的整數。 + 此設定來自舊版像素配置。編輯並儲存後將依預覽轉換為整數百分比(每邊 0–40%);取消會保留原設定。 + %1$s增加 1%% + %1$s減少 1%% + 解析度:%1$d × %2$d · 獨立儲存 diff --git a/common/src/main/res/values/settings_ids.xml b/common/src/main/res/values/settings_ids.xml deleted file mode 100644 index 03e31ca..0000000 --- a/common/src/main/res/values/settings_ids.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/common/src/main/res/values/settings_strings.xml b/common/src/main/res/values/settings_strings.xml index a734646..d654875 100644 --- a/common/src/main/res/values/settings_strings.xml +++ b/common/src/main/res/values/settings_strings.xml @@ -25,8 +25,8 @@ Day/night mode and image format/quality apply immediately. Other display and streaming changes reconnect the iPhone. Audio output changes apply immediately. Changing the microphone source reconnects the iPhone. Changes apply the next time the iPhone connects. - Based on DiPlay - If your head unit can install Android apps, try DiPlay for a better experience. WheelPlay provides CarPlay through a browser without modifying the head unit. + Based on DiPlay + If your head unit can install Android apps, try DiPlay for a better experience. WheelPlay provides CarPlay through a browser without modifying the head unit. No valid resources configured · Import to enable CarPlay Save Cancel @@ -83,7 +83,7 @@ Right-hand drive layout Move CarPlay controls to the right. Display safe area preview - The highlighted region is the CarPlay layout area. Each margin accepts 0–40%; 0 means no margin. + Drag an edge or enter a percentage. Each margin accepts 0–40%; 0 means no margin. Save to apply; an active CarPlay connection will reconnect. Top margin Bottom margin Left margin @@ -152,8 +152,8 @@ The open-source APK does not include authentication resources. Select your two existing files, or download and import the official DiPlay APK. Installing DiPlay is not required. Import from DiPlay APK Choose authentication files - DiPlay official download - Download the APK, then return to import it or share it with WheelPlay + DiPlay official download + Download the APK, then return to import it or share it with WheelPlay Only authentication resources are extracted and stored privately on this device; nothing is uploaded. The temporary APK is deleted after import. Uninstalling WheelPlay deletes imported resources. Choose two matching files First select identity.pk8 (PKCS#8 private key), then certificate.p7b (P-256 certificate). Both must belong to the same identity. Canceling or failed validation preserves existing resources. @@ -200,4 +200,8 @@ Image quality (1–100) Default: 75. Higher values preserve more detail and usually use more bandwidth. JPEG and WebP quality values are not equivalent. Saving also refreshes a static image; WebRTC video quality is unaffected. Enter a whole number from 1 to 100. + This older setting uses pixels. Editing and saving converts the preview to whole percentages (0–40% per edge). Cancel keeps the original setting. + Increase %1$s by 1%% + Decrease %1$s by 1%% + Resolution: %1$d × %2$d · Saved separately diff --git a/common/src/main/res/values/themes.xml b/common/src/main/res/values/themes.xml index 9115508..26372e8 100644 --- a/common/src/main/res/values/themes.xml +++ b/common/src/main/res/values/themes.xml @@ -22,4 +22,13 @@ #11161A true + + diff --git a/common/src/test/java/com/shilapi/xcertplay/ServerCardStateTest.kt b/common/src/test/java/com/shilapi/xcertplay/ServerCardStateTest.kt deleted file mode 100644 index a699c20..0000000 --- a/common/src/test/java/com/shilapi/xcertplay/ServerCardStateTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.shilapi.xcertplay - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import com.google.android.material.bottomnavigation.BottomNavigationView -import com.shilapi.xcertplay.web.WebSession -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import org.robolectric.android.controller.ActivityController -import org.robolectric.util.ReflectionHelpers -import java.io.File -import java.net.Socket -import java.time.Duration - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [29], qualifiers = "zh-rCN-w360dp-h800dp") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ServerCardStateTest { - private val activities = mutableListOf>() - private val output = File("build/reports/material-ui/cards").apply { mkdirs() } - - @Before fun resetSession() { - CarPlayBackgroundSession.clear() - WebSession.stop() - val identity = SyntheticMfiIdentity.create() - MfiAssetStore(RuntimeEnvironment.getApplication().noBackupFilesDir).installFiles( - { identity.key.inputStream() }, { identity.certificate.inputStream() }) - RuntimeEnvironment.getApplication().getSharedPreferences("diplay", 0).edit().clear().commit() - } - - @After fun cleanup() { - activities.forEach { it.pause().stop().destroy() } - CarPlayBackgroundSession.clear() - WebSession.stop() - } - - private fun activity() = Robolectric.buildActivity(DiPlayActivity::class.java).setup() - .also { activities.add(it) }.get() - private fun views(view: View): List = listOf(view) + - if (view is ViewGroup) (0 until view.childCount).flatMap { views(view.getChildAt(it)) } else emptyList() - private fun button(activity: DiPlayActivity, text: String) = views(activity.window.decorView) - .filterIsInstance