refactor: improve base class Settings

This commit is contained in:
Miuzarte
2026-03-29 01:11:05 +08:00
parent 82d06ceeae
commit ebbf9f6d4b
19 changed files with 303 additions and 433 deletions

View File

@@ -23,8 +23,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 5 versionCode = 6
versionName = "0.0.5" versionName = "0.1.0"
externalNativeBuild { externalNativeBuild {
cmake { cmake {

View File

@@ -5,10 +5,15 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import io.github.miuzarte.scrcpyforandroid.pages.MainPage import io.github.miuzarte.scrcpyforandroid.pages.MainPage
import io.github.miuzarte.scrcpyforandroid.storage.Storage
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// initialize settings singleton
Storage.init(applicationContext)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {

View File

@@ -4,8 +4,10 @@ import android.content.Context
import android.os.Build import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.content.edit import io.github.miuzarte.scrcpyforandroid.storage.AdbClientData
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.Closeable import java.io.Closeable
import java.io.EOFException import java.io.EOFException
@@ -44,7 +46,7 @@ import kotlin.concurrent.thread
*/ */
internal class DirectAdbTransport(private val context: Context) { internal class DirectAdbTransport(private val context: Context) {
private val keys: Pair<PrivateKey, ByteArray> by lazy { loadOrCreate() } private val keys: Pair<PrivateKey, ByteArray> by lazy { runBlocking { loadOrCreate() } }
val privateKey: PrivateKey get() = keys.first val privateKey: PrivateKey get() = keys.first
val publicKeyX509: ByteArray get() = keys.second val publicKeyX509: ByteArray get() = keys.second
@@ -101,40 +103,52 @@ internal class DirectAdbTransport(private val context: Context) {
} }
/** /**
* Load persisted RSA keypair from shared preferences, or generate a new one. * Load persisted RSA keypair from DataStore, or generate a new one.
* Returns (privateKey, publicX509Bytes). * Returns (privateKey, publicX509Bytes).
*/ */
private fun loadOrCreate(): Pair<PrivateKey, ByteArray> { private suspend fun loadOrCreate(
// TODO: migrate to data store forceNew: Boolean = false,
val prefs = context.getSharedPreferences( ): Pair<PrivateKey, ByteArray> {
"nativecore_adb_rsa", val adbClientData = Storage.adbClientData
Context.MODE_PRIVATE
) val privB64 = adbClientData.rsaPrivateKey.get()
val privB64 = prefs.getString("priv", null)
if (privB64 != null) { if (privB64.isNotBlank() && !forceNew) {
try { try {
val kf = KeyFactory.getInstance("RSA") val kf = KeyFactory.getInstance("RSA")
val priv = val priv = kf.generatePrivate(
kf.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(privB64, Base64.DEFAULT))) PKCS8EncodedKeySpec(
Base64.decode(privB64, Base64.DEFAULT)
)
)
val pub = derivePublicX509(priv) val pub = derivePublicX509(priv)
Log.i(TAG, "loadOrCreate(): loaded persisted RSA key pair, fp=${fingerprint(pub)}") Log.i(
TAG,
"loadOrCreate(): loaded persisted RSA key pair from DataStore, " +
"fp=${fingerprint(pub)}"
)
return Pair(priv, pub) return Pair(priv, pub)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "loadOrCreate(): failed to load persisted key, regenerating", e) Log.w(
TAG,
"loadOrCreate(): failed to load persisted key from DataStore, regenerating",
e
)
} }
} }
val kpg = KeyPairGenerator.getInstance("RSA") val kpg = KeyPairGenerator.getInstance("RSA")
kpg.initialize(2048) kpg.initialize(2048)
val kp = kpg.generateKeyPair() val kp = kpg.generateKeyPair()
prefs.edit {
putString( // 保存到 DataStore
"priv", val privateKeyB64 = Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)
Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP) val publicKeyB64 = Base64.encodeToString(kp.public.encoded, Base64.NO_WRAP)
) adbClientData.rsaPrivateKey.set(privateKeyB64)
} adbClientData.rsaPublicKeyX509.set(publicKeyB64)
Log.i( Log.i(
TAG, TAG,
"loadOrCreate(): generated new RSA key pair, fp=${fingerprint(kp.public.encoded)}" "loadOrCreate(): generated new RSA key pair and saved to DataStore, fp=${fingerprint(kp.public.encoded)}"
) )
return Pair(kp.private, kp.public.encoded) return Pair(kp.private, kp.public.encoded)
} }

View File

@@ -28,8 +28,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.ScrollBehavior
@@ -89,10 +88,9 @@ internal fun AdvancedConfigPage(
onRefreshEncoders: () -> Unit, onRefreshEncoders: () -> Unit,
onRefreshCameraSizes: () -> Unit, onRefreshCameraSizes: () -> Unit,
) { ) {
val context = LocalContext.current val scrcpyOptions = Storage.scrcpyOptions
val appSettings = remember(context) { AppSettings(context) } val context = LocalContext.current
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current

View File

@@ -34,11 +34,7 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.QuickDevices
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
@@ -131,11 +127,11 @@ fun DeviceTabScreen(
onOpenAdvancedPage: () -> Unit, onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit,
) { ) {
val context = LocalContext.current val appSettings = Storage.appSettings
val quickDevices = Storage.quickDevices
val scrcpyOptions = Storage.scrcpyOptions
val appSettings = remember(context) { AppSettings(context) } val context = LocalContext.current
val quickDevices = remember(context) { QuickDevices(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
@@ -216,11 +212,19 @@ fun DeviceTabScreen(
} }
var quickDevicesList by quickDevices.quickDevicesList.asMutableState() var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
var savedShortcuts = rememberSaveable(saver = DeviceShortcutsSaver) { var savedShortcuts = rememberSaveable(quickDevicesList, saver = DeviceShortcutsSaver) {
DeviceShortcuts.unmarshalFrom(quickDevicesList) DeviceShortcuts.unmarshalFrom(quickDevicesList)
} }
var quickConnectInput by quickDevices.quickConnectInput.asMutableState() var quickConnectInput by quickDevices.quickConnectInput.asMutableState()
// save changes when [savedShortcuts] was modified
LaunchedEffect(savedShortcuts) {
val serialized = savedShortcuts.marshalToString()
if (serialized != quickDevicesList) {
quickDevicesList = serialized
}
}
/** /**
* Disconnect the current ADB connection and stop any running scrcpy session. * Disconnect the current ADB connection and stop any running scrcpy session.
* *
@@ -232,7 +236,7 @@ fun DeviceTabScreen(
* - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort). * - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort).
* - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`, * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`,
* `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`.
* - Updates the saved quick-device list via [upsertQuickDevice] when a target is provided. * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided.
* - Logs an optional [logMessage] to the local event log. * - Logs an optional [logMessage] to the local event log.
* - Shows an optional snackbar message asynchronously (launched on the composition scope) * - Shows an optional snackbar message asynchronously (launched on the composition scope)
* so callers don't get blocked by `snack.showSnackbar` (it is suspending). * so callers don't get blocked by `snack.showSnackbar` (it is suspending).
@@ -263,8 +267,9 @@ fun DeviceTabScreen(
connectedDeviceLabel = "未连接" connectedDeviceLabel = "未连接"
clearQuickOnlineForTarget?.let { target -> clearQuickOnlineForTarget?.let { target ->
if (target.host.isNotBlank()) if (target.host.isNotBlank())
savedShortcuts = savedShortcuts = savedShortcuts.update(
savedShortcuts.update(host = target.host, port = target.port, online = false) host = target.host, port = target.port, online = false
)
} }
logMessage?.let { logEvent(it) } logMessage?.let { logEvent(it) }
if (!showSnackMessage.isNullOrBlank()) { if (!showSnackMessage.isNullOrBlank()) {
@@ -539,10 +544,24 @@ fun DeviceTabScreen(
connectedDeviceLabel = info.model connectedDeviceLabel = info.model
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
quickDevices.updateQuickDeviceNameIfEmpty(host, port, fullLabel) savedShortcuts = savedShortcuts.update(
host = host,
port = port,
name = fullLabel,
updateNameOnlyWhenEmpty = true
)
statusLine = "$host:$port" statusLine = "$host:$port"
logEvent("ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}") logEvent(
"ADB 已连接: " +
"model=${info.model}, " +
"serial=${info.serial.ifBlank { "unknown" }}, " +
"manufacturer=${info.manufacturer.ifBlank { "unknown" }}, " +
"brand=${info.brand.ifBlank { "unknown" }}, " +
"device=${info.device.ifBlank { "unknown" }}, " +
"android=${info.androidRelease.ifBlank { "unknown" }}, " +
"sdk=${info.sdkInt}"
)
scope.launch { scope.launch {
snack.showSnackbar("ADB 已连接") snack.showSnackbar("ADB 已连接")
} }
@@ -857,7 +876,8 @@ fun DeviceTabScreen(
try { try {
connectWithTimeout(host, port) connectWithTimeout(host, port)
adbConnected = true adbConnected = true
savedShortcuts = savedShortcuts.update(host = host, port = port, online = true) savedShortcuts =
savedShortcuts.update(host = host, port = port, online = true)
handleAdbConnected(host, port) handleAdbConnected(host, port)
} catch (e: Exception) { } catch (e: Exception) {
statusLine = "ADB 连接失败" statusLine = "ADB 连接失败"
@@ -886,23 +906,32 @@ fun DeviceTabScreen(
// "快速连接" // "快速连接"
QuickConnectCard( QuickConnectCard(
input = quickConnectInput, input = quickConnectInput,
onInputChange = { quickConnectInput = it }, onValueChange = { quickConnectInput = it },
enabled = !adbConnecting, enabled = !adbConnecting,
onAddDevice = { onAddDevice = {
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard val target = ConnectionTarget.unmarshalFrom(quickConnectInput)
savedShortcuts = savedShortcuts.update(host = target.host, port = target.port) ?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port)
)
Log.i("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList")
scope.launch { scope.launch {
snack.showSnackbar("已添加设备: ${target.host}:${target.port}") snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
} }
}, },
onConnect = { onConnect = {
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard val target = ConnectionTarget.unmarshalFrom(quickConnectInput)
?: return@QuickConnectCard
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port) disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try { try {
connectWithTimeout(target.host, target.port) connectWithTimeout(target.host, target.port)
adbConnected = true adbConnected = true
savedShortcuts = savedShortcuts.update(host = target.host, port = target.port, online = true) savedShortcuts = savedShortcuts.update(
host = target.host,
port = target.port,
online = true
)
handleAdbConnected(target.host, target.port) handleAdbConnected(target.host, target.port)
} catch (e: Exception) { } catch (e: Exception) {
statusLine = "ADB 连接失败" statusLine = "ADB 连接失败"

View File

@@ -22,8 +22,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
@@ -46,10 +45,9 @@ fun FullscreenControlPage(
// Disable predictive back handler temporarily to avoid decoding issues. // Disable predictive back handler temporarily to avoid decoding issues.
BackHandler(enabled = true, onBack = onDismiss) BackHandler(enabled = true, onBack = onDismiss)
val context = LocalContext.current val appSettings = Storage.appSettings
val appSettings = remember(context) { AppSettings(context) } val context = LocalContext.current
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()

View File

@@ -51,7 +51,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -191,8 +191,8 @@ fun MainPage() {
} }
} }
val appSettings = remember(context) { AppSettings(context) } val appSettings = Storage.appSettings
val scrcpyOptions = remember(context) { ScrcpyOptions(context) } val scrcpyOptions = Storage.scrcpyOptions
val videoEncoderOptions = remember { mutableStateListOf<String>() } val videoEncoderOptions = remember { mutableStateListOf<String>() }
val audioEncoderOptions = remember { mutableStateListOf<String>() } val audioEncoderOptions = remember { mutableStateListOf<String>() }

View File

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.pages package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent import android.content.Intent
import android.os.Process
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -13,6 +14,7 @@ import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -22,12 +24,16 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow import top.yukonga.miuix.kmp.extra.SuperArrow
@@ -35,6 +41,7 @@ import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.system.exitProcess
private data class ThemeModeOption( private data class ThemeModeOption(
val label: String, val label: String,
@@ -68,10 +75,13 @@ fun SettingsScreen(
onPickServer: () -> Unit, onPickServer: () -> Unit,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
) { ) {
val appContext = LocalContext.current.applicationContext
val appSettings = Storage.appSettings
val context = LocalContext.current val context = LocalContext.current
val appSettings = remember(context) { AppSettings(context) } val scope = rememberCoroutineScope()
val scrcpyOptions = remember(context) { ScrcpyOptions(context) } val snackHostState = remember { SnackbarHostState() }
val baseModeItems = THEME_BASE_OPTIONS.map { it.label } val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
@@ -255,17 +265,49 @@ fun SettingsScreen(
) )
} }
SectionSmallTitle("应用")
Card {
SuperArrow(
title = "恢复旧版本配置",
summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
onClick = {
scope.launch {
val migration = PreferenceMigration(appContext)
migration.migrate(clearSharedPrefs = false)
snackHostState.showSnackbar("迁移完成,应用将重启")
delay(1000)
val intent = context.packageManager.getLaunchIntentForPackage(
context.packageName
)
intent?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
)
}
context.startActivity(intent)
Process.killProcess(Process.myPid())
exitProcess(0)
}
},
)
}
SectionSmallTitle("关于") SectionSmallTitle("关于")
Card { Card {
SuperArrow( SuperArrow(
title = "前往仓库", title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid", summary = "github.com/Miuzarte/ScrcpyForAndroid",
onClick = { onClick = {
val intent = Intent( context.startActivity(
Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri() "https://github.com/Miuzarte/ScrcpyForAndroid".toUri(),
)
) )
context.startActivity(intent)
}, },
) )
} }

View File

@@ -8,8 +8,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -22,10 +21,9 @@ internal fun VirtualButtonOrderPage(
contentPadding: PaddingValues, contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
) { ) {
val context = LocalContext.current val appSettings = Storage.appSettings
val appSettings = remember(context) { AppSettings(context) } val context = LocalContext.current
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
var previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asMutableState() var previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asMutableState()
var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState()

View File

@@ -4,7 +4,6 @@ import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
internal fun loadQuickDevices(context: Context): List<DeviceShortcut> { internal fun loadQuickDevices(context: Context): List<DeviceShortcut> {
@@ -63,70 +62,3 @@ internal fun saveQuickDevices(context: Context, quickDevices: List<DeviceShortcu
putString(AppPreferenceKeys.QUICK_DEVICES, raw) putString(AppPreferenceKeys.QUICK_DEVICES, raw)
} }
} }
internal fun parseQuickTarget(raw: String): ConnectionTarget? {
val value = raw.trim()
if (value.isEmpty()) return null
val host = value.substringBefore(':').trim()
if (host.isEmpty()) return null
val port = value.substringAfter(':', Defaults.ADB_PORT.toString()).trim().toIntOrNull()
?: Defaults.ADB_PORT
return ConnectionTarget(host, port)
}
internal fun upsertQuickDevice(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
port: Int,
online: Boolean,
) {
val idx = quickDevices.indexOfFirst { it.id == "$host:$port" }
val existingName = if (idx >= 0) quickDevices[idx].name else ""
val item = DeviceShortcut(
name = existingName,
host = host,
port = port,
online = online,
)
if (idx >= 0) quickDevices[idx] = item else quickDevices.add(0, item)
saveQuickDevices(context, quickDevices)
}
internal fun updateQuickDeviceNameIfEmpty(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
port: Int,
fallbackName: String,
) {
val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
if (idx >= 0 && quickDevices[idx].name.isBlank()) {
quickDevices[idx] = quickDevices[idx].copy(name = fallbackName)
saveQuickDevices(context, quickDevices)
}
}
internal fun replaceQuickDevicePort(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
oldPort: Int,
newPort: Int,
online: Boolean,
) {
val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort }
if (idx < 0) return
val old = quickDevices[idx]
val updated = old.copy(
port = newPort,
online = online,
)
quickDevices[idx] = updated
val dedup = quickDevices.distinctBy { it.id }
quickDevices.clear()
quickDevices.addAll(dedup)
saveQuickDevices(context, quickDevices)
}

View File

@@ -0,0 +1,20 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
class AdbClientData(context: Context) : Settings(context, "AdbClient") {
companion object {
val RSA_PRIVATE_KEY = Pair(
stringPreferencesKey("rsa_private_key"),
"",
)
val RSA_PUBLIC_KEY_X509 = Pair(
stringPreferencesKey("rsa_public_key_x509"),
"",
)
}
val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
}

View File

@@ -87,30 +87,6 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
override suspend fun toMap(): Map<String, Any> = mapOf(
// Theme Settings
THEME_BASE_INDEX.name to themeBaseIndex.get(),
MONET.name to monet.get(),
// Scrcpy Settings
FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(),
SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(),
KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(),
DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(),
PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(),
VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(),
// Scrcpy Server Settings
CUSTOM_SERVER_URI.name to customServerUri.get(),
SERVER_REMOTE_PATH.name to serverRemotePath.get(),
// ADB Settings
ADB_KEY_NAME.name to adbKeyName.get(),
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(),
ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(),
ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get()
)
// TODO? // TODO?
override fun validate(): Boolean = true // fun validate(): Boolean = true
} }

View File

@@ -3,40 +3,46 @@ package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private const val TAG = "PreferenceMigration" private const val TAG = "PreferenceMigration"
private const val MIGRATION_COMPLETED_KEY = "migration_completed"
/** /**
* 从旧的 SharedPreferences 迁移到新的 DataStore * 从旧的 SharedPreferences 迁移到新的 DataStore
*/ */
class PreferenceMigration(private val context: Context) { class PreferenceMigration(private val appContext: Context) {
private val appSharedPrefs: SharedPreferences by lazy {
appContext.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
}
private val sharedPrefs: SharedPreferences by lazy { private val sharedPrefs: SharedPreferences by lazy {
context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE)
} }
/** /**
* 检查是否已完成迁移 * 检查是否需要迁移SharedPreferences 是否包含数据)
*/ */
@Deprecated("做成设置内入口手动调用,这个没必要") suspend fun needsMigration(): Boolean = withContext(Dispatchers.IO) {
suspend fun isMigrationCompleted(): Boolean = withContext(Dispatchers.IO) { appSharedPrefs.all.isNotEmpty() || sharedPrefs.all.isNotEmpty()
sharedPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)
} }
/** /**
* 执行完整迁移 * 执行完整迁移
* @return 迁移是否成功
*/ */
suspend fun migrate(): Boolean = withContext(Dispatchers.IO) { suspend fun migrate(
try { clearSharedPrefs: Boolean = false,
if (isMigrationCompleted()) { ) = withContext(Dispatchers.IO) {
Log.i(TAG, "Migration already completed, skipping") if (!needsMigration()) {
return@withContext true Log.i(TAG, "No data to migrate, skipping")
return@withContext
} else {
val appList = appSharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
val adbList = sharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
Log.d(TAG, "Migrating appSharedPrefs ($appList)")
} }
Log.i(TAG, "Starting migration from SharedPreferences to DataStore") Log.i(TAG, "Starting migration from SharedPreferences to DataStore")
@@ -50,22 +56,27 @@ class PreferenceMigration(private val context: Context) {
// 迁移 QuickDevices // 迁移 QuickDevices
migrateQuickDevices() migrateQuickDevices()
// 标记迁移完成 // 迁移 ADB 密钥
markMigrationCompleted() migrateAdbClientData()
Log.i(TAG, "Migration completed successfully") // 清空 SharedPreferences
true if (clearSharedPrefs) {
} catch (e: Exception) { appSharedPrefs.edit { clear() }
Log.e(TAG, "Migration failed", e) sharedPrefs.edit { clear() }
false Log.d(TAG, "SharedPreferences cleared")
} }
Log.i(
TAG, "Migration completed successfully" +
" and SharedPreferences ${if (clearSharedPrefs) "" else "is not "}cleared"
)
} }
/** /**
* 迁移应用设置 * 迁移应用设置
*/ */
private suspend fun migrateAppSettings() { private suspend fun migrateAppSettings() {
val appSettings = AppSettings(context) val appSettings = Storage.appSettings
// Theme Settings // Theme Settings
migrateInt( migrateInt(
@@ -152,7 +163,7 @@ class PreferenceMigration(private val context: Context) {
* 迁移 Scrcpy 选项 * 迁移 Scrcpy 选项
*/ */
private suspend fun migrateScrcpyOptions() { private suspend fun migrateScrcpyOptions() {
val scrcpyOptions = ScrcpyOptions(context) val scrcpyOptions = Storage.scrcpyOptions
// Audio & Video Codecs // Audio & Video Codecs
migrateString( migrateString(
@@ -383,10 +394,10 @@ class PreferenceMigration(private val context: Context) {
* 迁移快速设备列表 * 迁移快速设备列表
*/ */
private suspend fun migrateQuickDevices() { private suspend fun migrateQuickDevices() {
val quickDevices = QuickDevices(context) val quickDevices = Storage.quickDevices
// Migrate quick devices list // Migrate quick devices list
val quickDevicesRaw = sharedPrefs.getString( val quickDevicesRaw = appSharedPrefs.getString(
AppPreferenceKeys.QUICK_DEVICES, AppPreferenceKeys.QUICK_DEVICES,
"" ""
).orEmpty() ).orEmpty()
@@ -405,10 +416,19 @@ class PreferenceMigration(private val context: Context) {
} }
/** /**
* 标记迁移完成 * 迁移 ADB 客户端数据RSA 密钥)
*/ */
private fun markMigrationCompleted() { private suspend fun migrateAdbClientData() {
sharedPrefs.edit().putBoolean(MIGRATION_COMPLETED_KEY, true).apply() val adbClientData = Storage.adbClientData
// 迁移 RSA 私钥
val privKey = sharedPrefs.getString("priv", null)
if (privKey != null) {
adbClientData.rsaPrivateKey.set(privKey)
Log.d(TAG, "ADB RSA private key migrated")
}
Log.d(TAG, "AdbClientData migration completed")
} }
// Helper methods for different data types // Helper methods for different data types
@@ -418,7 +438,7 @@ class PreferenceMigration(private val context: Context) {
defaultValue: String, defaultValue: String,
settingProperty: Settings.SettingProperty<String> settingProperty: Settings.SettingProperty<String>
) { ) {
val value = sharedPrefs.getString(key, defaultValue) val value = appSharedPrefs.getString(key, defaultValue)
.orEmpty() .orEmpty()
.ifBlank { defaultValue } .ifBlank { defaultValue }
settingProperty.set(value) settingProperty.set(value)
@@ -429,7 +449,7 @@ class PreferenceMigration(private val context: Context) {
defaultValue: String, defaultValue: String,
action: suspend (String) -> Unit action: suspend (String) -> Unit
) { ) {
val value = sharedPrefs.getString(key, defaultValue) val value = appSharedPrefs.getString(key, defaultValue)
.orEmpty() .orEmpty()
.ifBlank { defaultValue } .ifBlank { defaultValue }
action(value) action(value)
@@ -440,7 +460,7 @@ class PreferenceMigration(private val context: Context) {
defaultValue: Int, defaultValue: Int,
settingProperty: Settings.SettingProperty<Int> settingProperty: Settings.SettingProperty<Int>
) { ) {
val value = sharedPrefs.getInt(key, defaultValue) val value = appSharedPrefs.getInt(key, defaultValue)
settingProperty.set(value) settingProperty.set(value)
} }
@@ -449,7 +469,7 @@ class PreferenceMigration(private val context: Context) {
defaultValue: Boolean, defaultValue: Boolean,
settingProperty: Settings.SettingProperty<Boolean> settingProperty: Settings.SettingProperty<Boolean>
) { ) {
val value = sharedPrefs.getBoolean(key, defaultValue) val value = appSharedPrefs.getBoolean(key, defaultValue)
settingProperty.set(value) settingProperty.set(value)
} }
@@ -458,7 +478,7 @@ class PreferenceMigration(private val context: Context) {
defaultValue: Boolean, defaultValue: Boolean,
action: suspend (Boolean) -> Unit action: suspend (Boolean) -> Unit
) { ) {
val value = sharedPrefs.getBoolean(key, defaultValue) val value = appSharedPrefs.getBoolean(key, defaultValue)
action(value) action(value)
} }
} }

View File

@@ -2,9 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class QuickDevices(context: Context) : Settings(context, "QuickDevices") { class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
companion object { companion object {
@@ -20,145 +17,4 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
val quickDevicesList by setting(QUICK_DEVICES_LIST) val quickDevicesList by setting(QUICK_DEVICES_LIST)
val quickConnectInput by setting(QUICK_CONNECT_INPUT) val quickConnectInput by setting(QUICK_CONNECT_INPUT)
override suspend fun toMap(): Map<String, Any> = mapOf(
QUICK_DEVICES_LIST.name to quickDevicesList.get(),
QUICK_CONNECT_INPUT.name to quickConnectInput.get(),
)
override fun validate(): Boolean = true
suspend fun getQuickDevices(): List<DeviceShortcut> {
val raw = quickDevicesList.get()
if (raw.isBlank()) return emptyList()
val result = mutableListOf<DeviceShortcut>()
raw.lineSequence().forEach { line ->
DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) }
}
return result
}
suspend fun setQuickDevices(list: List<DeviceShortcut>) =
quickDevicesList.set(list.joinToString("\n") { it.marshalToString() })
fun observeQuickDevices(): Flow<List<DeviceShortcut>> = quickDevicesList.observe()
.map { raw ->
if (raw.isBlank()) return@map emptyList()
val result = mutableListOf<DeviceShortcut>()
raw.lineSequence().forEach { line ->
DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) }
}
result
}
/**
* 插入或更新快速设备
*/
suspend fun upsertQuickDevice(
host: String,
port: Int,
online: Boolean,
index: Int? = 0,
) {
val quickDevices = getQuickDevices().toMutableList()
val id = "$host:$port"
val idx = quickDevices.indexOfFirst { it.id == id }
val existingName = if (idx >= 0) quickDevices[idx].name else ""
val item = DeviceShortcut(
name = existingName,
host = host,
port = port,
online = online,
)
if (idx >= 0) {
quickDevices[idx] = item
} else {
if (index != null)
quickDevices.add(index, item)
else quickDevices.add(item)
}
setQuickDevices(quickDevices)
}
/**
* 如果设备名称为空,则更新为备用名称
*/
suspend fun updateQuickDeviceNameIfEmpty(
host: String,
port: Int,
fallbackName: String,
) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
if (idx >= 0 && quickDevices[idx].name.isBlank()) {
quickDevices[idx] = quickDevices[idx].copy(name = fallbackName)
setQuickDevices(quickDevices)
}
}
/**
* 替换快速设备的端口
*/
suspend fun replaceQuickDevicePort(
host: String,
oldPort: Int,
newPort: Int,
online: Boolean,
) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort }
if (idx < 0) return
val old = quickDevices[idx]
val updated = old.copy(
port = newPort,
online = online,
)
quickDevices[idx] = updated
val dedup = quickDevices.distinctBy { it.id }
setQuickDevices(dedup)
}
/**
* 删除快速设备
*/
suspend fun removeQuickDevice(id: String) {
val quickDevices = getQuickDevices().toMutableList()
quickDevices.removeAll { it.id == id }
setQuickDevices(quickDevices)
}
/**
* 更新设备在线状态
*/
suspend fun updateDeviceOnlineStatus(host: String, port: Int, online: Boolean) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
if (idx >= 0) {
quickDevices[idx] = quickDevices[idx].copy(online = online)
setQuickDevices(quickDevices)
}
}
/**
* 更新设备名称
*/
suspend fun updateDeviceName(id: String, name: String) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.id == id }
if (idx >= 0) {
quickDevices[idx] = quickDevices[idx].copy(name = name)
setQuickDevices(quickDevices)
}
}
/**
* 清空所有快速设备
*/
suspend fun clearQuickDevices() {
quickDevicesList.set("")
}
} }

View File

@@ -278,61 +278,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val vdDestroyContent by setting(VD_DESTROY_CONTENT) val vdDestroyContent by setting(VD_DESTROY_CONTENT)
val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS) val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
override suspend fun toMap(): Map<String, Any> = mapOf( fun validate(): Boolean = runBlocking {
CROP.name to crop.get(),
RECORD_FILENAME.name to recordFilename.get(),
VIDEO_CODEC_OPTIONS.name to videoCodecOptions.get(),
AUDIO_CODEC_OPTIONS.name to audioCodecOptions.get(),
VIDEO_ENCODER.name to videoEncoder.get(),
AUDIO_ENCODER.name to audioEncoder.get(),
CAMERA_ID.name to cameraId.get(),
CAMERA_SIZE.name to cameraSize.get(),
CAMERA_AR.name to cameraAr.get(),
CAMERA_FPS.name to cameraFps.get(),
LOG_LEVEL.name to logLevel.get(),
VIDEO_CODEC.name to videoCodec.get(),
AUDIO_CODEC.name to audioCodec.get(),
VIDEO_SOURCE.name to videoSource.get(),
AUDIO_SOURCE.name to audioSource.get(),
RECORD_FORMAT.name to recordFormat.get(),
CAMERA_FACING.name to cameraFacing.get(),
MAX_SIZE.name to maxSize.get(),
VIDEO_BIT_RATE.name to videoBitRate.get(),
AUDIO_BIT_RATE.name to audioBitRate.get(),
MAX_FPS.name to maxFps.get(),
ANGLE.name to angle.get(),
CAPTURE_ORIENTATION.name to captureOrientation.get(),
CAPTURE_ORIENTATION_LOCK.name to captureOrientationLock.get(),
DISPLAY_ORIENTATION.name to displayOrientation.get(),
RECORD_ORIENTATION.name to recordOrientation.get(),
DISPLAY_IME_POLICY.name to displayImePolicy.get(),
DISPLAY_ID.name to displayId.get(),
SCREEN_OFF_TIMEOUT.name to screenOffTimeout.get(),
SHOW_TOUCHES.name to showTouches.get(),
FULLSCREEN.name to fullscreen.get(),
CONTROL.name to control.get(),
VIDEO_PLAYBACK.name to videoPlayback.get(),
AUDIO_PLAYBACK.name to audioPlayback.get(),
TURN_SCREEN_OFF.name to turnScreenOff.get(),
STAY_AWAKE.name to stayAwake.get(),
DISABLE_SCREENSAVER.name to disableScreensaver.get(),
POWER_OFF_ON_CLOSE.name to powerOffOnClose.get(),
CLEANUP.name to cleanup.get(),
POWER_ON.name to powerOn.get(),
VIDEO.name to video.get(),
AUDIO.name to audio.get(),
REQUIRE_AUDIO.name to requireAudio.get(),
KILL_ADB_ON_CLOSE.name to killAdbOnClose.get(),
CAMERA_HIGH_SPEED.name to cameraHighSpeed.get(),
LIST.name to list.get(),
AUDIO_DUP.name to audioDup.get(),
NEW_DISPLAY.name to newDisplay.get(),
START_APP.name to startApp.get(),
VD_DESTROY_CONTENT.name to vdDestroyContent.get(),
VD_SYSTEM_DECORATIONS.name to vdSystemDecorations.get()
)
override fun validate(): Boolean = runBlocking {
runCatching { runCatching {
toClientOptions().validate() toClientOptions().validate()
true true

View File

@@ -42,11 +42,16 @@ abstract class Settings(
} }
/** /**
* 设置项委托类,自动提供 get/set/observe/observeAsState/asMutableState 方法 * 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法
*/ */
inner class SettingProperty<T>( inner class SettingProperty<T>(
private val pair: Pair<T> val pair: Pair<T>
) { ) {
// 创建时注册自身
init {
registerProperty(pair.name, this)
}
operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty<T> = this operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty<T> = this
suspend fun get(): T = getValue(pair) suspend fun get(): T = getValue(pair)
@@ -62,6 +67,15 @@ abstract class Settings(
fun asMutableState(): MutableState<T> = this@Settings.asMutableState(pair) fun asMutableState(): MutableState<T> = this@Settings.asMutableState(pair)
} }
// 注册表, 用于遍历
private val propertyRegistry = mutableMapOf<String, SettingProperty<*>>()
private fun <T> registerProperty(name: String, property: SettingProperty<T>) {
propertyRegistry[name] = property
}
protected fun getAllProperties(): Map<String, SettingProperty<*>> = propertyRegistry.toMap()
// 为 Context 添加扩展委托属性,确保 DataStore 单例 // 为 Context 添加扩展委托属性,确保 DataStore 单例
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore( private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = this.name, name = this.name,
@@ -69,21 +83,16 @@ abstract class Settings(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
) )
abstract suspend fun toMap(): Map<String, Any>
abstract fun validate(): Boolean
// 对外暴露的 DataStore 实例 // 对外暴露的 DataStore 实例
protected val dataStore: DataStore<Preferences> = context.dataStore protected val dataStore: DataStore<Preferences> = context.dataStore
protected fun <T> setting(pair: Pair<T>): SettingProperty<T> = protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
SettingProperty(pair)
protected suspend fun <T> getValue(pair: Pair<T>): T = protected suspend fun <T> getValue(pair: Pair<T>): T =
dataStore.data.first()[pair.key] ?: pair.defaultValue dataStore.data.first()[pair.key] ?: pair.defaultValue
protected suspend fun <T> setValue(pair: Pair<T>, value: T) { protected suspend fun <T> setValue(pair: Pair<T>, value: T) =
dataStore.edit { preferences -> preferences[pair.key] = value } dataStore.edit { preferences -> preferences[pair.key] = value }
}
protected fun <T> observe(pair: Pair<T>): Flow<T> = protected fun <T> observe(pair: Pair<T>): Flow<T> =
dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue } dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue }

View File

@@ -0,0 +1,17 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
// settings singleton
object Storage {
private lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext
}
val appSettings: AppSettings by lazy { AppSettings(appContext) }
val quickDevices: QuickDevices by lazy { QuickDevices(appContext) }
val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) }
val adbClientData: AdbClientData by lazy { AdbClientData(appContext) }
}

View File

@@ -48,6 +48,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -71,8 +74,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -132,10 +134,9 @@ internal fun StatusCard(
busyLabel: String?, busyLabel: String?,
connectedDeviceLabel: String, connectedDeviceLabel: String,
) { ) {
val context = LocalContext.current val appSettings = Storage.appSettings
val appSettings = remember(context) { AppSettings(context) } val context = LocalContext.current
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val themeBaseIndex by appSettings.themeBaseIndex.asState() val themeBaseIndex by appSettings.themeBaseIndex.asState()
@@ -381,10 +382,9 @@ internal fun ConfigPanel(
onStop: () -> Unit, onStop: () -> Unit,
sessionStarted: Boolean, sessionStarted: Boolean,
) { ) {
val context = LocalContext.current val scrcpyOptions = Storage.scrcpyOptions
// val appSettings = remember(context) { AppSettings(context) } val context = LocalContext.current
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
SectionSmallTitle("Scrcpy") SectionSmallTitle("Scrcpy")
Card { Card {
@@ -1221,12 +1221,16 @@ internal fun DeviceTile(
@Composable @Composable
internal fun QuickConnectCard( internal fun QuickConnectCard(
input: String, input: String,
onInputChange: (String) -> Unit, onValueChange: (String) -> Unit,
onConnect: () -> Unit, onConnect: () -> Unit,
onAddDevice: () -> Unit, onAddDevice: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var tempText by remember(input) { mutableStateOf(input) }
var tempFocusState by remember(input) { mutableStateOf(false) }
Card( Card(
colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer),
@@ -1256,10 +1260,8 @@ internal fun QuickConnectCard(
) )
} }
TextField( TextField(
value = input, value = tempText,
onValueChange = { onValueChange = { tempText = it },
if (enabled) onInputChange(it)
},
label = "IP:PORT", label = "IP:PORT",
enabled = enabled, enabled = enabled,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
@@ -1267,7 +1269,15 @@ internal fun QuickConnectCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.SectionTitleLeadingGap), .padding(bottom = UiSpacing.SectionTitleLeadingGap)
.onFocusChanged { focusState ->
// 失去焦点时回调
if (!focusState.isFocused && tempFocusState) {
onValueChange(tempText)
}
tempFocusState = focusState.isFocused
}
.focusRequester(focusRequester),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
) )