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"
minSdk = 26
targetSdk = 36
versionCode = 5
versionName = "0.0.5"
versionCode = 6
versionName = "0.1.0"
externalNativeBuild {
cmake {

View File

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

View File

@@ -4,8 +4,10 @@ import android.content.Context
import android.os.Build
import android.util.Base64
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.Storage
import kotlinx.coroutines.runBlocking
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.EOFException
@@ -44,7 +46,7 @@ import kotlin.concurrent.thread
*/
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 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).
*/
private fun loadOrCreate(): Pair<PrivateKey, ByteArray> {
// TODO: migrate to data store
val prefs = context.getSharedPreferences(
"nativecore_adb_rsa",
Context.MODE_PRIVATE
)
val privB64 = prefs.getString("priv", null)
if (privB64 != null) {
private suspend fun loadOrCreate(
forceNew: Boolean = false,
): Pair<PrivateKey, ByteArray> {
val adbClientData = Storage.adbClientData
val privB64 = adbClientData.rsaPrivateKey.get()
if (privB64.isNotBlank() && !forceNew) {
try {
val kf = KeyFactory.getInstance("RSA")
val priv =
kf.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(privB64, Base64.DEFAULT)))
val priv = kf.generatePrivate(
PKCS8EncodedKeySpec(
Base64.decode(privB64, Base64.DEFAULT)
)
)
val pub = derivePublicX509(priv)
Log.i(TAG, "loadOrCreate(): loaded persisted RSA key pair, fp=${fingerprint(pub)}")
Log.i(
TAG,
"loadOrCreate(): loaded persisted RSA key pair from DataStore, " +
"fp=${fingerprint(pub)}"
)
return Pair(priv, pub)
} 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")
kpg.initialize(2048)
val kp = kpg.generateKeyPair()
prefs.edit {
putString(
"priv",
Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)
)
}
// 保存到 DataStore
val privateKeyB64 = 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(
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)
}

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.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior
@@ -89,10 +88,9 @@ internal fun AdvancedConfigPage(
onRefreshEncoders: () -> Unit,
onRefreshCameraSizes: () -> Unit,
) {
val context = LocalContext.current
val scrcpyOptions = Storage.scrcpyOptions
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val context = LocalContext.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.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget
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.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
@@ -131,11 +127,11 @@ fun DeviceTabScreen(
onOpenAdvancedPage: () -> 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 quickDevices = remember(context) { QuickDevices(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val context = LocalContext.current
val haptics = rememberAppHaptics()
@@ -216,11 +212,19 @@ fun DeviceTabScreen(
}
var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
var savedShortcuts = rememberSaveable(saver = DeviceShortcutsSaver) {
var savedShortcuts = rememberSaveable(quickDevicesList, saver = DeviceShortcutsSaver) {
DeviceShortcuts.unmarshalFrom(quickDevicesList)
}
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.
*
@@ -232,7 +236,7 @@ fun DeviceTabScreen(
* - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort).
* - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`,
* `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.
* - Shows an optional snackbar message asynchronously (launched on the composition scope)
* so callers don't get blocked by `snack.showSnackbar` (it is suspending).
@@ -263,8 +267,9 @@ fun DeviceTabScreen(
connectedDeviceLabel = "未连接"
clearQuickOnlineForTarget?.let { target ->
if (target.host.isNotBlank())
savedShortcuts =
savedShortcuts.update(host = target.host, port = target.port, online = false)
savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port, online = false
)
}
logMessage?.let { logEvent(it) }
if (!showSnackMessage.isNullOrBlank()) {
@@ -539,10 +544,24 @@ fun DeviceTabScreen(
connectedDeviceLabel = info.model
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
quickDevices.updateQuickDeviceNameIfEmpty(host, port, fullLabel)
savedShortcuts = savedShortcuts.update(
host = host,
port = port,
name = fullLabel,
updateNameOnlyWhenEmpty = true
)
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 {
snack.showSnackbar("ADB 已连接")
}
@@ -857,7 +876,8 @@ fun DeviceTabScreen(
try {
connectWithTimeout(host, port)
adbConnected = true
savedShortcuts = savedShortcuts.update(host = host, port = port, online = true)
savedShortcuts =
savedShortcuts.update(host = host, port = port, online = true)
handleAdbConnected(host, port)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
@@ -886,23 +906,32 @@ fun DeviceTabScreen(
// "快速连接"
QuickConnectCard(
input = quickConnectInput,
onInputChange = { quickConnectInput = it },
onValueChange = { quickConnectInput = it },
enabled = !adbConnecting,
onAddDevice = {
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
savedShortcuts = savedShortcuts.update(host = target.host, port = target.port)
val target = ConnectionTarget.unmarshalFrom(quickConnectInput)
?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port)
)
Log.i("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList")
scope.launch {
snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
}
},
onConnect = {
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
val target = ConnectionTarget.unmarshalFrom(quickConnectInput)
?: return@QuickConnectCard
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try {
connectWithTimeout(target.host, target.port)
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)
} catch (e: Exception) {
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.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
@@ -46,10 +45,9 @@ fun FullscreenControlPage(
// Disable predictive back handler temporarily to avoid decoding issues.
BackHandler(enabled = true, onBack = onDismiss)
val context = LocalContext.current
val appSettings = Storage.appSettings
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val context = LocalContext.current
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.scrcpy.Scrcpy
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.Dispatchers
import kotlinx.coroutines.launch
@@ -191,8 +191,8 @@ fun MainPage() {
}
}
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val appSettings = Storage.appSettings
val scrcpyOptions = Storage.scrcpyOptions
val videoEncoderOptions = remember { mutableStateListOf<String>() }
val audioEncoderOptions = remember { mutableStateListOf<String>() }

View File

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import android.os.Process
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.SuperSlide
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 kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
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.theme.ColorSchemeMode
import kotlin.math.roundToInt
import kotlin.system.exitProcess
private data class ThemeModeOption(
val label: String,
@@ -68,10 +75,13 @@ fun SettingsScreen(
onPickServer: () -> Unit,
scrollBehavior: ScrollBehavior,
) {
val appContext = LocalContext.current.applicationContext
val appSettings = Storage.appSettings
val context = LocalContext.current
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val scope = rememberCoroutineScope()
val snackHostState = remember { SnackbarHostState() }
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("关于")
Card {
SuperArrow(
title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid",
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri()
context.startActivity(
Intent(
Intent.ACTION_VIEW,
"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.ui.platform.LocalContext
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -22,10 +21,9 @@ internal fun VirtualButtonOrderPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
) {
val context = LocalContext.current
val appSettings = Storage.appSettings
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val context = LocalContext.current
var previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asMutableState()
var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState()

View File

@@ -4,7 +4,6 @@ import android.content.Context
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.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)
}
}
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 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?
override fun validate(): Boolean = true
// fun validate(): Boolean = true
}

View File

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

View File

@@ -2,9 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
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") {
companion object {
@@ -20,145 +17,4 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
val quickDevicesList by setting(QUICK_DEVICES_LIST)
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 vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
override suspend fun toMap(): Map<String, Any> = mapOf(
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 {
fun validate(): Boolean = runBlocking {
runCatching {
toClientOptions().validate()
true

View File

@@ -42,11 +42,16 @@ abstract class Settings(
}
/**
* 设置项委托类,自动提供 get/set/observe/observeAsState/asMutableState 方法
* 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法
*/
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
suspend fun get(): T = getValue(pair)
@@ -62,6 +67,15 @@ abstract class Settings(
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 单例
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = this.name,
@@ -69,21 +83,16 @@ abstract class Settings(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
)
abstract suspend fun toMap(): Map<String, Any>
abstract fun validate(): Boolean
// 对外暴露的 DataStore 实例
protected val dataStore: DataStore<Preferences> = context.dataStore
protected fun <T> setting(pair: Pair<T>): SettingProperty<T> =
SettingProperty(pair)
protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
protected suspend fun <T> getValue(pair: Pair<T>): T =
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 }
}
protected fun <T> observe(pair: Pair<T>): Flow<T> =
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.Modifier
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.graphics.Color
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.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -132,10 +134,9 @@ internal fun StatusCard(
busyLabel: String?,
connectedDeviceLabel: String,
) {
val context = LocalContext.current
val appSettings = Storage.appSettings
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val context = LocalContext.current
val themeBaseIndex by appSettings.themeBaseIndex.asState()
@@ -381,10 +382,9 @@ internal fun ConfigPanel(
onStop: () -> Unit,
sessionStarted: Boolean,
) {
val context = LocalContext.current
val scrcpyOptions = Storage.scrcpyOptions
// val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val context = LocalContext.current
SectionSmallTitle("Scrcpy")
Card {
@@ -1221,12 +1221,16 @@ internal fun DeviceTile(
@Composable
internal fun QuickConnectCard(
input: String,
onInputChange: (String) -> Unit,
onValueChange: (String) -> Unit,
onConnect: () -> Unit,
onAddDevice: () -> Unit,
enabled: Boolean = true,
) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var tempText by remember(input) { mutableStateOf(input) }
var tempFocusState by remember(input) { mutableStateOf(false) }
Card(
colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer),
@@ -1256,10 +1260,8 @@ internal fun QuickConnectCard(
)
}
TextField(
value = input,
onValueChange = {
if (enabled) onInputChange(it)
},
value = tempText,
onValueChange = { tempText = it },
label = "IP:PORT",
enabled = enabled,
useLabelAsPlaceholder = true,
@@ -1267,7 +1269,15 @@ internal fun QuickConnectCard(
modifier = Modifier
.fillMaxWidth()
.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() }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)