feat: biometric auth and lockscreen password autofill
This commit is contained in:
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://github.com/user-attachments/assets/ff7ea4ff-c868-46c8-83cf-df904e083a8f" height="300" alt="Screenshot 1" />
|
<img src="https://github.com/user-attachments/assets/ff7ea4ff-c868-46c8-83cf-df904e083a8f" height="300" alt="Screenshot 1" />
|
||||||
<img src="https://github.com/user-attachments/assets/f8bacd9f-c99d-4196-9474-1939291bdbee" height="300" alt="Screenshot 2" />
|
<img src="https://github.com/user-attachments/assets/79eb4c43-c298-4510-b75c-8f1fd367706d" height="300" alt="Screenshot 2" />
|
||||||
<img src="https://github.com/user-attachments/assets/06a171c4-0c6b-4ef3-b9ea-1c964800cb30" height="300" alt="Screenshot 3" />
|
<img src="https://github.com/user-attachments/assets/4c2f202d-b114-4e53-8dd6-fd60e55d6e04" height="300" alt="Screenshot 3" />
|
||||||
<img src="https://github.com/user-attachments/assets/d6409c96-56fb-4c59-88c2-54008eb6026a" height="300" alt="Screenshot 4" />
|
<img src="https://github.com/user-attachments/assets/92f1f62b-c4d9-40f5-8613-8495b66eff13" height="300" alt="Screenshot 4" />
|
||||||
<img src="https://github.com/user-attachments/assets/3831d760-fcdf-4ce2-b999-10fc4d2b143b" height="300" alt="Screenshot 5" />
|
<img src="https://github.com/user-attachments/assets/3831d760-fcdf-4ce2-b999-10fc4d2b143b" height="300" alt="Screenshot 5" />
|
||||||
<img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Screenshot 6" />
|
<img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Screenshot 6" />
|
||||||
<img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Screenshot 7" />
|
<img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Screenshot 7" />
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ dependencies {
|
|||||||
implementation("sh.calvin.reorderable:reorderable:3.0.0")
|
implementation("sh.calvin.reorderable:reorderable:3.0.0")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
||||||
implementation(libs.androidx.compose.runtime)
|
implementation(libs.androidx.compose.runtime)
|
||||||
|
implementation(libs.androidx.biometric)
|
||||||
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true" />
|
android:supportsPictureInPicture="true" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".LockscreenPasswordActivity"
|
||||||
|
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- <profileable android:shell="true" /> -->
|
<!-- <profileable android:shell="true" /> -->
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -0,0 +1,618 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Block
|
||||||
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
|
import androidx.compose.material.icons.rounded.MoreVert
|
||||||
|
import androidx.compose.material.icons.rounded.Password
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordCreatedState
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordEntry
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
|
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.ListPopupColumn
|
||||||
|
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.extended.Close
|
||||||
|
import top.yukonga.miuix.kmp.icon.extended.Ok
|
||||||
|
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
||||||
|
import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
||||||
|
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||||
|
import top.yukonga.miuix.kmp.preference.ArrowPreference
|
||||||
|
import top.yukonga.miuix.kmp.preference.SwitchPreference
|
||||||
|
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||||
|
|
||||||
|
class LockscreenPasswordActivity : FragmentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
PasswordRepository.refresh()
|
||||||
|
setContent {
|
||||||
|
val asBundle by appSettings.bundleState.collectAsState()
|
||||||
|
val hostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbarController = remember(scope, hostState) {
|
||||||
|
SnackbarController(scope = scope, hostState = hostState)
|
||||||
|
}
|
||||||
|
val themeMode =
|
||||||
|
when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) {
|
||||||
|
1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight
|
||||||
|
2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark
|
||||||
|
else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem
|
||||||
|
}
|
||||||
|
val themeController =
|
||||||
|
remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
||||||
|
val haptics = rememberAppHaptics()
|
||||||
|
|
||||||
|
MiuixTheme(controller = themeController) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSnackbarController provides snackbarController,
|
||||||
|
LocalAppHaptics provides haptics,
|
||||||
|
) {
|
||||||
|
LockscreenPasswordScreen(
|
||||||
|
activity = this,
|
||||||
|
hostState = hostState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun createIntent(context: Context): Intent {
|
||||||
|
return Intent(context, LockscreenPasswordActivity::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class PasswordDialogMode {
|
||||||
|
Create,
|
||||||
|
Rename,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockscreenPasswordScreen(
|
||||||
|
activity: LockscreenPasswordActivity,
|
||||||
|
hostState: SnackbarHostState,
|
||||||
|
) {
|
||||||
|
val snackbar = LocalSnackbarController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
val scrollBehavior = MiuixScrollBehavior()
|
||||||
|
|
||||||
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(asBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
taskScope.launch { appSettings.saveBundle(asBundleLatest) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val entries by PasswordRepository.entriesState.collectAsState()
|
||||||
|
var showMenu by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var pendingCreate by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showRiskDialog by rememberSaveable { mutableStateOf(!BiometricGate.isDeviceSecure()) }
|
||||||
|
var showDisableDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var dialogMode by rememberSaveable { mutableStateOf<PasswordDialogMode?>(null) }
|
||||||
|
var editingId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var editorInitialName by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||||
|
if (
|
||||||
|
event == androidx.lifecycle.Lifecycle.Event.ON_PAUSE ||
|
||||||
|
event == androidx.lifecycle.Lifecycle.Event.ON_STOP
|
||||||
|
) {
|
||||||
|
editorInitialName = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activity.lifecycle.addObserver(observer)
|
||||||
|
onDispose { activity.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(pendingCreate, showMenu) {
|
||||||
|
if (!pendingCreate || showMenu) return@LaunchedEffect
|
||||||
|
if (asBundle.passwordRequireAuth) {
|
||||||
|
val ok = BiometricGate.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
title = "验证以创建新密码",
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
snackbar.show("认证失败")
|
||||||
|
pendingCreate = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogMode = PasswordDialogMode.Create
|
||||||
|
editingId = null
|
||||||
|
editorInitialName = ""
|
||||||
|
pendingCreate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = "锁屏密码自动填充",
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(Icons.Rounded.MoreVert, contentDescription = "更多")
|
||||||
|
}
|
||||||
|
PasswordMenuPopup(
|
||||||
|
show = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
onCreate = {
|
||||||
|
showMenu = false
|
||||||
|
pendingCreate = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(hostState) },
|
||||||
|
) { pagePadding ->
|
||||||
|
LockscreenPasswordPage(
|
||||||
|
contentPadding = pagePadding,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
entries = entries,
|
||||||
|
requireAuth = asBundle.passwordRequireAuth,
|
||||||
|
canAuthenticate = BiometricGate.canAuthenticate(),
|
||||||
|
onToggleRequireAuth = { checked ->
|
||||||
|
if (checked) {
|
||||||
|
asBundle = asBundle.copy(passwordRequireAuth = true)
|
||||||
|
} else {
|
||||||
|
if (entries.any { it.cipherText != null }) {
|
||||||
|
showDisableDialog = true
|
||||||
|
} else {
|
||||||
|
asBundle = asBundle.copy(passwordRequireAuth = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCreate = {
|
||||||
|
pendingCreate = true
|
||||||
|
},
|
||||||
|
onRename = { entry ->
|
||||||
|
dialogMode = PasswordDialogMode.Rename
|
||||||
|
editingId = entry.id
|
||||||
|
editorInitialName = entry.name
|
||||||
|
},
|
||||||
|
onDelete = { entry ->
|
||||||
|
pendingDeleteId = entry.id
|
||||||
|
},
|
||||||
|
onMove = { fromIndex, toIndex ->
|
||||||
|
val reordered = entries.toMutableList().apply {
|
||||||
|
add(toIndex, removeAt(fromIndex))
|
||||||
|
}
|
||||||
|
PasswordRepository.updateOrder(reordered.map { it.id })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showRiskDialog) {
|
||||||
|
OverlayDialog(
|
||||||
|
show = true,
|
||||||
|
title = "当前设备未设置锁屏保护",
|
||||||
|
summary = "继续使用将允许在无认证保护的情况下保存和填充锁屏密码",
|
||||||
|
onDismissRequest = activity::finish,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) {
|
||||||
|
TextButton(
|
||||||
|
text = "取消",
|
||||||
|
onClick = activity::finish,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
text = "同意",
|
||||||
|
onClick = { showRiskDialog = false },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDisableDialog) {
|
||||||
|
OverlayDialog(
|
||||||
|
show = true,
|
||||||
|
title = "关闭验证后密码将失去保护",
|
||||||
|
summary = "关闭后每次填充密码时将不再强制认证" +
|
||||||
|
"\n同时会熔断当前经认证创建的密码",
|
||||||
|
onDismissRequest = { showDisableDialog = false },
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) {
|
||||||
|
TextButton(
|
||||||
|
text = "取消",
|
||||||
|
onClick = { showDisableDialog = false },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
text = "继续关闭",
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (entries.any { it.cipherText != null }) {
|
||||||
|
val ok = BiometricGate.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
title = "验证以禁用“填充密码时需要验证”",
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
snackbar.show("认证失败")
|
||||||
|
showDisableDialog = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asBundle = asBundle.copy(passwordRequireAuth = false)
|
||||||
|
entries.forEach { entry ->
|
||||||
|
PasswordRepository.update(
|
||||||
|
entry.copy(
|
||||||
|
cipherText = entry.cipherText?.copyOf(),
|
||||||
|
createdWithAuth = entry.createdWithAuth
|
||||||
|
.takeIf { it != PasswordCreatedState.AuthenticatedCreated }
|
||||||
|
?: PasswordCreatedState.AuthenticatedCreatedModified
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showDisableDialog = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDeleteId?.let { deleteId ->
|
||||||
|
val target = entries.firstOrNull { it.id == deleteId }
|
||||||
|
if (target != null) {
|
||||||
|
OverlayDialog(
|
||||||
|
show = true,
|
||||||
|
title = "删除密码",
|
||||||
|
summary = "将删除 ${target.name}",
|
||||||
|
onDismissRequest = { pendingDeleteId = null },
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) {
|
||||||
|
TextButton(
|
||||||
|
text = "取消",
|
||||||
|
onClick = { pendingDeleteId = null },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
text = "删除",
|
||||||
|
onClick = {
|
||||||
|
PasswordRepository.delete(target.id)
|
||||||
|
pendingDeleteId = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordEditorSheet(
|
||||||
|
show = dialogMode != null,
|
||||||
|
mode = dialogMode ?: PasswordDialogMode.Create,
|
||||||
|
initialName = editorInitialName,
|
||||||
|
onDismissRequest = {
|
||||||
|
dialogMode = null
|
||||||
|
editingId = null
|
||||||
|
editorInitialName = ""
|
||||||
|
},
|
||||||
|
onConfirm = { nameInput, passwordInput ->
|
||||||
|
val sanitizedName = PasswordSanitizer.filterName(nameInput)
|
||||||
|
val resolvedName = sanitizedName.ifBlank { "密码 ${entries.size + 1}" }
|
||||||
|
when (dialogMode) {
|
||||||
|
PasswordDialogMode.Create -> {
|
||||||
|
val sanitizedPassword = PasswordSanitizer.filterPassword(passwordInput)
|
||||||
|
val passwordChars = sanitizedPassword.toCharArray()
|
||||||
|
if (passwordChars.isEmpty()) {
|
||||||
|
scope.launch { snackbar.show("密码不能为空") }
|
||||||
|
return@PasswordEditorSheet
|
||||||
|
}
|
||||||
|
PasswordRepository.create(
|
||||||
|
name = resolvedName,
|
||||||
|
cipherText = passwordChars,
|
||||||
|
createdWithAuth = if (asBundle.passwordRequireAuth) {
|
||||||
|
PasswordCreatedState.AuthenticatedCreated
|
||||||
|
} else {
|
||||||
|
PasswordCreatedState.UnauthenticatedCreated
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialogMode.Rename -> {
|
||||||
|
val targetId = editingId ?: return@PasswordEditorSheet
|
||||||
|
PasswordRepository.rename(targetId, resolvedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> return@PasswordEditorSheet
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogMode = null
|
||||||
|
editingId = null
|
||||||
|
editorInitialName = ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockscreenPasswordPage(
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
scrollBehavior: ScrollBehavior,
|
||||||
|
entries: List<PasswordEntry>,
|
||||||
|
requireAuth: Boolean,
|
||||||
|
canAuthenticate: Boolean,
|
||||||
|
onToggleRequireAuth: (Boolean) -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
onRename: (PasswordEntry) -> Unit,
|
||||||
|
onDelete: (PasswordEntry) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Card {
|
||||||
|
SwitchPreference(
|
||||||
|
title = "填充密码时需要验证",
|
||||||
|
summary =
|
||||||
|
if (canAuthenticate) "关闭后将允许直接填充锁屏密码" +
|
||||||
|
"\n同时会熔断当前经认证创建的密码"
|
||||||
|
else "当前设备无认证认证能力",
|
||||||
|
checked = requireAuth,
|
||||||
|
enabled = canAuthenticate || requireAuth,
|
||||||
|
onCheckedChange = onToggleRequireAuth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Spacer(Modifier.height(UiSpacing.Medium)) }
|
||||||
|
|
||||||
|
if (entries.isEmpty()) item {
|
||||||
|
Card {
|
||||||
|
ArrowPreference(
|
||||||
|
title = "创建新密码",
|
||||||
|
summary = "或在右上角菜单中",
|
||||||
|
onClick = onCreate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else item {
|
||||||
|
ReorderableList(
|
||||||
|
itemsProvider = {
|
||||||
|
entries.map { entry ->
|
||||||
|
ReorderableList.Item(
|
||||||
|
id = entry.id,
|
||||||
|
icon =
|
||||||
|
if (entry.cipherText == null) Icons.Rounded.Block
|
||||||
|
else Icons.Rounded.Password,
|
||||||
|
title = entry.name,
|
||||||
|
subtitle =
|
||||||
|
if (entry.cipherText == null) "已失效"
|
||||||
|
else when (entry.createdWithAuth) {
|
||||||
|
PasswordCreatedState.AuthenticatedCreated -> "创建时已验证"
|
||||||
|
PasswordCreatedState.UnauthenticatedCreated -> "创建时未经验证"
|
||||||
|
PasswordCreatedState.AuthenticatedCreatedModified -> "创建时已验证(熔断)"
|
||||||
|
},
|
||||||
|
onClick = { if (entry.cipherText != null) onRename(entry) },
|
||||||
|
endActions = listOf(
|
||||||
|
ReorderableList.EndAction.Icon(
|
||||||
|
icon = Icons.Rounded.Edit,
|
||||||
|
contentDescription = "编辑名称",
|
||||||
|
onClick = { if (entry.cipherText != null) onRename(entry) },
|
||||||
|
),
|
||||||
|
ReorderableList.EndAction.Icon(
|
||||||
|
icon = Icons.Rounded.DeleteOutline,
|
||||||
|
contentDescription = "删除密码",
|
||||||
|
onClick = { onDelete(entry) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettle = onMove,
|
||||||
|
).invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "免责声明" +
|
||||||
|
"\n0. 无法保证没有 bug" +
|
||||||
|
"\n1. 本功能的防护边界仅包括加密存储、按需认证和使用后内存清理,不构成绝对安全保证" +
|
||||||
|
"\n2. 在 root / posed / hook / 调试器 / 恶意输入法 等环境下,密码仍可能泄露" +
|
||||||
|
"\n3. 本功能不会绕过系统锁屏认证,仅用于你已合法授权控制的设备" +
|
||||||
|
"\n4. 关闭“填充密码时需要验证”会显著降低安全性,请谨慎选择",
|
||||||
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = UiSpacing.Large)
|
||||||
|
.padding(horizontal = UiSpacing.Large),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordMenuPopup(
|
||||||
|
show: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onCreate: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
OverlayListPopup(
|
||||||
|
show = show,
|
||||||
|
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||||
|
alignment = PopupPositionProvider.Align.TopEnd,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
enableWindowDim = false,
|
||||||
|
) {
|
||||||
|
ListPopupColumn {
|
||||||
|
SpinnerItemImpl(
|
||||||
|
entry = SpinnerEntry(title = "创建新密码"),
|
||||||
|
entryCount = 1,
|
||||||
|
isSelected = false,
|
||||||
|
index = 0,
|
||||||
|
spinnerColors = SpinnerDefaults.spinnerColors(),
|
||||||
|
dialogMode = false,
|
||||||
|
onSelectedIndexChange = onCreate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordEditorSheet(
|
||||||
|
show: Boolean,
|
||||||
|
mode: PasswordDialogMode,
|
||||||
|
initialName: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onConfirm: (String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
var nameBuffer by rememberSaveable(mode, show, initialName) { mutableStateOf(initialName) }
|
||||||
|
var passwordBuffer by rememberSaveable(mode, show) { mutableStateOf("") }
|
||||||
|
OverlayBottomSheet(
|
||||||
|
show = show,
|
||||||
|
title = if (mode == PasswordDialogMode.Create) "创建新密码" else "重命名密码",
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
startAction = {
|
||||||
|
IconButton(onClick = onDismissRequest) {
|
||||||
|
Icon(MiuixIcons.Close, contentDescription = "关闭")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
endAction = {
|
||||||
|
IconButton(onClick = { onConfirm(nameBuffer, passwordBuffer) }) {
|
||||||
|
Icon(MiuixIcons.Ok, contentDescription = "保存")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = UiSpacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = nameBuffer,
|
||||||
|
onValueChange = { nameBuffer = it },
|
||||||
|
label = "名称",
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = UiSpacing.Large),
|
||||||
|
)
|
||||||
|
AnimatedVisibility(mode == PasswordDialogMode.Create) {
|
||||||
|
TextField(
|
||||||
|
value = passwordBuffer,
|
||||||
|
onValueChange = { passwordBuffer = it },
|
||||||
|
label = "锁屏密码",
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = UiSpacing.Large),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(UiSpacing.SheetBottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,30 +2,42 @@ package io.github.miuzarte.scrcpyforandroid
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
|
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
|
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
// 生物认证需要 FragmentActivity
|
||||||
|
class MainActivity : FragmentActivity() {
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
Storage.init(applicationContext)
|
|
||||||
AppRuntime.init(applicationContext)
|
AppRuntime.init(applicationContext)
|
||||||
AppWakeLocks.init(applicationContext)
|
AppWakeLocks.init(applicationContext)
|
||||||
|
|
||||||
val migration = PreferenceMigration(applicationContext)
|
val migration = PreferenceMigration(applicationContext)
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
// 旧版设置迁移
|
||||||
if (migration.needsMigration())
|
if (migration.needsMigration())
|
||||||
migration.migrate(clearSharedPrefs = true)
|
migration.migrate(clearSharedPrefs = true)
|
||||||
|
|
||||||
|
PasswordRepository.refresh()
|
||||||
|
// 认证不可用时, 清除经认证创建的密码
|
||||||
|
if (!BiometricGate.canAuthenticate()) {
|
||||||
|
PasswordRepository.getAll()
|
||||||
|
.filter { it.createdWithAuth.hasAuthenticatedOrigin && it.cipherText != null }
|
||||||
|
.forEach { PasswordRepository.markInvalid(it.id) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.drawable.Icon
|
import android.graphics.drawable.Icon
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.core.app.PictureInPictureParamsCompat.Builder
|
import androidx.core.app.PictureInPictureParamsCompat.Builder
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.pip.BasicPictureInPicture
|
import androidx.core.pip.BasicPictureInPicture
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
|
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
|
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
class StreamActivity : ComponentActivity() {
|
class StreamActivity : FragmentActivity() {
|
||||||
private val basicPip = BasicPictureInPicture(this)
|
private val basicPip = BasicPictureInPicture(this)
|
||||||
|
|
||||||
private val pipActionReceiver = PictureInPictureActionReceiver()
|
private val pipActionReceiver = PictureInPictureActionReceiver()
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
|
|||||||
DeviceShortcuts(devices)
|
DeviceShortcuts(devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: 增加 id 字段,解决编辑端口的问题
|
||||||
data class DeviceShortcut(
|
data class DeviceShortcut(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val host: String,
|
val host: String,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.pages
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -49,6 +51,7 @@ import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
|||||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
@@ -74,6 +77,7 @@ import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
|
|||||||
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -180,6 +184,8 @@ fun DeviceTabPage(
|
|||||||
scrollBehavior: ScrollBehavior,
|
scrollBehavior: ScrollBehavior,
|
||||||
scrcpy: Scrcpy,
|
scrcpy: Scrcpy,
|
||||||
) {
|
) {
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
val fragmentActivity = remember(activity) {activity as? FragmentActivity}
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -314,7 +320,6 @@ fun DeviceTabPage(
|
|||||||
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
|
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var savedShortcuts by remember {
|
var savedShortcuts by remember {
|
||||||
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
|
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
|
||||||
}
|
}
|
||||||
@@ -1124,12 +1129,26 @@ fun DeviceTabPage(
|
|||||||
moreActions = virtualButtonLayout.second,
|
moreActions = virtualButtonLayout.second,
|
||||||
showText = asBundle.previewVirtualButtonShowText,
|
showText = asBundle.previewVirtualButtonShowText,
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
|
if (action == VirtualButtonAction.PASSWORD_INPUT) {
|
||||||
|
snackbar.show("当前页面无法拉起验证")
|
||||||
|
return@VirtualButtonCard
|
||||||
|
}
|
||||||
val keycode = action.keycode ?: return@VirtualButtonCard
|
val keycode = action.keycode ?: return@VirtualButtonCard
|
||||||
runBusy("发送 ${action.title}") {
|
runBusy("发送 ${action.title}") {
|
||||||
scrcpy.injectKeycode(0, keycode)
|
scrcpy.injectKeycode(0, keycode)
|
||||||
scrcpy.injectKeycode(1, keycode)
|
scrcpy.injectKeycode(1, keycode)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
passwordPopupContent =
|
||||||
|
if (fragmentActivity == null) null
|
||||||
|
else { onDismissRequest ->
|
||||||
|
PasswordPickerPopupContent(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onMessage = { message ->
|
||||||
|
scope.launch { snackbar.show(message) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.app.Activity
|
|||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -44,10 +45,13 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
|
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
|
||||||
@@ -61,6 +65,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -73,11 +78,12 @@ fun FullscreenControlScreen(
|
|||||||
) {
|
) {
|
||||||
BackHandler(enabled = true, onBack = onBack)
|
BackHandler(enabled = true, onBack = onBack)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val activity = LocalActivity.current
|
||||||
|
val fragmentActivity = remember(activity) { activity as? FragmentActivity }
|
||||||
|
|
||||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
|
||||||
val activity = remember(context) { context as? Activity }
|
val snackbar = LocalSnackbarController.current
|
||||||
val currentSession by scrcpy.currentSessionState.collectAsState()
|
val currentSession by scrcpy.currentSessionState.collectAsState()
|
||||||
|
|
||||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
@@ -174,7 +180,10 @@ fun FullscreenControlScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
|
Scaffold(
|
||||||
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
|
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
||||||
|
) { contentPadding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -210,7 +219,23 @@ fun FullscreenControlScreen(
|
|||||||
bar.Fullscreen(
|
bar.Fullscreen(
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
action.keycode?.let { sendKeycode(it) }
|
if (action == VirtualButtonAction.PASSWORD_INPUT) {
|
||||||
|
snackbar.show("当前页面无法拉起验证")
|
||||||
|
} else {
|
||||||
|
action.keycode?.let { sendKeycode(it) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
passwordPopupContent = if (fragmentActivity == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{ onDismissRequest ->
|
||||||
|
PasswordPickerPopupContent(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onMessage = { message ->
|
||||||
|
taskScope.launch(Dispatchers.Main) { snackbar.show(message) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -220,7 +245,23 @@ fun FullscreenControlScreen(
|
|||||||
actions = floatingActions,
|
actions = floatingActions,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
action.keycode?.let { sendKeycode(it) }
|
if (action == VirtualButtonAction.PASSWORD_INPUT) {
|
||||||
|
snackbar.show("当前页面无法拉起验证")
|
||||||
|
} else {
|
||||||
|
action.keycode?.let { sendKeycode(it) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
passwordPopupContent = if (fragmentActivity == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{ onDismissRequest ->
|
||||||
|
PasswordPickerPopupContent(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onMessage = { message ->
|
||||||
|
taskScope.launch(Dispatchers.Main) { snackbar.show(message) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ import top.yukonga.miuix.kmp.basic.TextField
|
|||||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
import top.yukonga.miuix.kmp.icon.extended.Store
|
import top.yukonga.miuix.kmp.icon.extended.Store
|
||||||
import top.yukonga.miuix.kmp.icon.extended.Tune
|
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||||
@@ -1502,8 +1501,8 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
)
|
)
|
||||||
if (it) snackbar.show(
|
if (it) snackbar.show(
|
||||||
"默认情况下,在 MediaCodec 出错时," +
|
"默认情况下,在 MediaCodec 出错时," +
|
||||||
"scrcpy 会自动尝试使用更低的分辨率重新开始。" +
|
"scrcpy 会自动尝试使用更低的分辨率重新开始" +
|
||||||
"\n此选项将禁用此行为。"
|
"\n此选项将禁用此行为"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import io.github.miuzarte.scrcpyforandroid.BuildConfig
|
import io.github.miuzarte.scrcpyforandroid.BuildConfig
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
@@ -251,6 +252,13 @@ fun SettingsPage(
|
|||||||
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
|
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
|
||||||
onClick = { navigator.push(RootScreen.VirtualButtonOrder) },
|
onClick = { navigator.push(RootScreen.VirtualButtonOrder) },
|
||||||
)
|
)
|
||||||
|
ArrowPreference(
|
||||||
|
title = "锁屏密码自动填充",
|
||||||
|
summary = "管理用于自动填充的锁屏密码",
|
||||||
|
onClick = {
|
||||||
|
context.startActivity(LockscreenPasswordActivity.createIntent(context))
|
||||||
|
},
|
||||||
|
)
|
||||||
SwitchPreference(
|
SwitchPreference(
|
||||||
title = "全屏显示虚拟按钮",
|
title = "全屏显示虚拟按钮",
|
||||||
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
|
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
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 io.github.miuzarte.scrcpyforandroid.StreamActivity
|
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
|
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
|
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||||
@@ -89,9 +93,15 @@ fun StreamScreen(activity: StreamActivity) {
|
|||||||
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
||||||
|
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val snackbarScope = rememberCoroutineScope()
|
||||||
|
val snackbarController = remember(snackbarScope, snackbarHostState) {
|
||||||
|
SnackbarController(scope = snackbarScope, hostState = snackbarHostState)
|
||||||
|
}
|
||||||
|
|
||||||
MiuixTheme(controller = themeController) {
|
MiuixTheme(controller = themeController) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
|
LocalSnackbarController provides snackbarController,
|
||||||
LocalAppHaptics provides haptics,
|
LocalAppHaptics provides haptics,
|
||||||
) {
|
) {
|
||||||
FullscreenControlScreen(
|
FullscreenControlScreen(
|
||||||
@@ -100,11 +110,10 @@ fun StreamScreen(activity: StreamActivity) {
|
|||||||
isInPip = isInPip,
|
isInPip = isInPip,
|
||||||
onVideoSizeChanged = { width, height ->
|
onVideoSizeChanged = { width, height ->
|
||||||
// 只在全屏时跟随视频方向
|
// 只在全屏时跟随视频方向
|
||||||
if (!isInPip) {
|
if (!isInPip)
|
||||||
activity.requestedOrientation =
|
activity.requestedOrientation =
|
||||||
if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onVideoBoundsInWindowChanged = {
|
onVideoBoundsInWindowChanged = {
|
||||||
// 记录下一次进入 PiP 时可用的 sourceRectHint
|
// 记录下一次进入 PiP 时可用的 sourceRectHint
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.pages
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,6 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
@@ -117,6 +120,8 @@ internal fun VirtualButtonOrderPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item { Spacer(Modifier.height(UiSpacing.Medium)) }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
ReorderableList(
|
ReorderableList(
|
||||||
itemsProvider = {
|
itemsProvider = {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.password
|
||||||
|
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
object BiometricGate {
|
||||||
|
|
||||||
|
private const val ALLOWED_AUTHENTICATORS =
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
|
||||||
|
private val sessionIds = AtomicLong(0L)
|
||||||
|
|
||||||
|
fun canAuthenticate(): Boolean {
|
||||||
|
return BiometricManager.from(AppRuntime.context)
|
||||||
|
.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isDeviceSecure(): Boolean {
|
||||||
|
val manager = AppRuntime.context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
return manager.isDeviceSecure
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun authenticate(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
title: String = "验证身份",
|
||||||
|
subtitle: String = "确认后继续",
|
||||||
|
): Boolean = suspendCancellableCoroutine { continuation ->
|
||||||
|
val sessionId = sessionIds.incrementAndGet()
|
||||||
|
var resumed = false
|
||||||
|
lateinit var prompt: BiometricPrompt
|
||||||
|
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||||
|
if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP) {
|
||||||
|
prompt.cancelAuthentication()
|
||||||
|
if (!resumed && continuation.isActive) {
|
||||||
|
resumed = true
|
||||||
|
continuation.resume(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activity.lifecycle.addObserver(observer)
|
||||||
|
|
||||||
|
fun finish(result: Boolean) {
|
||||||
|
if (resumed || !continuation.isActive) return
|
||||||
|
if (sessionId != sessionIds.get()) return
|
||||||
|
resumed = true
|
||||||
|
activity.runOnMain { activity.lifecycle.removeObserver(observer) }
|
||||||
|
continuation.resume(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
ContextCompat.getMainExecutor(activity),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
finish(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() = Unit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setAllowedAuthenticators(ALLOWED_AUTHENTICATORS)
|
||||||
|
.setTitle(title)
|
||||||
|
.setSubtitle(subtitle)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
activity.runOnMain {
|
||||||
|
activity.lifecycle.removeObserver(observer)
|
||||||
|
prompt.cancelAuthentication()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun FragmentActivity.runOnMain(crossinline block: () -> Unit) {
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
block()
|
||||||
|
} else {
|
||||||
|
runOnUiThread { block() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.password
|
||||||
|
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
object InjectionController {
|
||||||
|
suspend fun inject(password: CharArray) {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
AppRuntime.scrcpy?.injectText(String(password))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
password.fill('\u0000')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.password
|
||||||
|
|
||||||
|
enum class PasswordCreatedState {
|
||||||
|
AuthenticatedCreated,
|
||||||
|
UnauthenticatedCreated,
|
||||||
|
AuthenticatedCreatedModified,
|
||||||
|
}
|
||||||
|
|
||||||
|
val PasswordCreatedState.hasAuthenticatedOrigin: Boolean
|
||||||
|
get() = this != PasswordCreatedState.UnauthenticatedCreated
|
||||||
|
|
||||||
|
data class PasswordEntry(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val cipherText: CharArray?,
|
||||||
|
val createdWithAuth: PasswordCreatedState,
|
||||||
|
)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.password
|
||||||
|
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Block
|
||||||
|
import androidx.compose.material.icons.rounded.Password
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PasswordPickerPopupContent(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val activity = LocalActivity.current as? FragmentActivity
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val passwordUseCase = remember { PasswordUseCase() }
|
||||||
|
val entries by PasswordRepository.entriesState.collectAsState()
|
||||||
|
val appSettingsBundle by appSettings.bundleState.collectAsState()
|
||||||
|
|
||||||
|
val spinnerEntries = remember(entries) {
|
||||||
|
entries.map { entry ->
|
||||||
|
SpinnerEntry(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (entry.cipherText == null) Icons.Rounded.Block
|
||||||
|
else Icons.Rounded.Password,
|
||||||
|
contentDescription = entry.name,
|
||||||
|
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = entry.name,
|
||||||
|
summary =
|
||||||
|
if (entry.cipherText == null) "已失效"
|
||||||
|
else when (entry.createdWithAuth) {
|
||||||
|
PasswordCreatedState.AuthenticatedCreated -> "创建时已验证"
|
||||||
|
PasswordCreatedState.UnauthenticatedCreated -> "创建时未经验证"
|
||||||
|
PasswordCreatedState.AuthenticatedCreatedModified -> "创建时已验证(熔断)"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListPopupColumn {
|
||||||
|
if (spinnerEntries.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "无可用密码",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(UiSpacing.PopupHorizontal),
|
||||||
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
return@ListPopupColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity == null) {
|
||||||
|
Text(
|
||||||
|
text = "当前页面无法拉起验证",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(UiSpacing.PopupHorizontal),
|
||||||
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
return@ListPopupColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
spinnerEntries.forEachIndexed { index, spinnerEntry ->
|
||||||
|
SpinnerItemImpl(
|
||||||
|
entry = spinnerEntry,
|
||||||
|
entryCount = spinnerEntries.size,
|
||||||
|
isSelected = false,
|
||||||
|
index = index,
|
||||||
|
spinnerColors = SpinnerDefaults.spinnerColors(),
|
||||||
|
dialogMode = false,
|
||||||
|
onSelectedIndexChange = { selectedIndex ->
|
||||||
|
val target = entries[selectedIndex]
|
||||||
|
scope.launch {
|
||||||
|
passwordUseCase.preparePassword(
|
||||||
|
activity = activity,
|
||||||
|
entry = target,
|
||||||
|
globalRequiresAuth = appSettingsBundle.passwordRequireAuth,
|
||||||
|
).onSuccess { password ->
|
||||||
|
InjectionController.inject(password)
|
||||||
|
onDismissRequest()
|
||||||
|
}.onFailure {
|
||||||
|
onMessage(it.message ?: "密码填充失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.password
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
import java.security.KeyStoreException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
object PasswordRepository {
|
||||||
|
private const val PREFS_NAME = "LockscreenPasswords"
|
||||||
|
private const val KEY_ORDER = "__order"
|
||||||
|
private const val ENTRY_PREFIX = "entry."
|
||||||
|
private const val NAME_SUFFIX = ".name"
|
||||||
|
private const val PASSWORD_SUFFIX = ".password"
|
||||||
|
private const val CREATED_WITH_AUTH_SUFFIX = ".created_with_auth"
|
||||||
|
|
||||||
|
class PasswordStorageCorruptedException(cause: Throwable) : Exception(cause)
|
||||||
|
|
||||||
|
private val _entriesState = MutableStateFlow<List<PasswordEntry>>(emptyList())
|
||||||
|
val entriesState: StateFlow<List<PasswordEntry>> = _entriesState.asStateFlow()
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences by lazy {
|
||||||
|
val context = AppRuntime.context
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
_entriesState.value = runCatching { getAll() }.getOrElse { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAll(): List<PasswordEntry> {
|
||||||
|
return runCatching {
|
||||||
|
val all = prefs.all
|
||||||
|
val nameIds = all.keys
|
||||||
|
.filter { it.startsWith(ENTRY_PREFIX) && it.endsWith(NAME_SUFFIX) }
|
||||||
|
.map { it.removePrefix(ENTRY_PREFIX).removeSuffix(NAME_SUFFIX) }
|
||||||
|
.distinct()
|
||||||
|
val storedOrder = (all[KEY_ORDER] as? String)
|
||||||
|
.orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
val orderedIds = (storedOrder + nameIds).distinct()
|
||||||
|
orderedIds.mapNotNull { id ->
|
||||||
|
val name = all[nameKey(id)] as? String ?: return@mapNotNull null
|
||||||
|
val rawPassword = all[passwordKey(id)] as? String
|
||||||
|
PasswordEntry(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
cipherText = rawPassword?.toCharArray(),
|
||||||
|
createdWithAuth = parseCreatedState(all[createdWithAuthKey(id)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrElse { throwable ->
|
||||||
|
throw storageExceptionOf(throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
name: String,
|
||||||
|
cipherText: CharArray,
|
||||||
|
createdWithAuth: PasswordCreatedState,
|
||||||
|
): PasswordEntry {
|
||||||
|
val entry = PasswordEntry(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name,
|
||||||
|
cipherText = cipherText.copyOf(),
|
||||||
|
createdWithAuth = createdWithAuth,
|
||||||
|
)
|
||||||
|
add(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(entry: PasswordEntry) {
|
||||||
|
editEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(entry: PasswordEntry) {
|
||||||
|
editEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rename(id: String, name: String) {
|
||||||
|
prefs.edit {
|
||||||
|
putString(nameKey(id), name)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateOrder(ids: List<String>) {
|
||||||
|
prefs.edit {
|
||||||
|
putString(KEY_ORDER, ids.joinToString(","))
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(id: String) {
|
||||||
|
prefs.edit {
|
||||||
|
remove(nameKey(id))
|
||||||
|
.remove(passwordKey(id))
|
||||||
|
.remove(createdWithAuthKey(id))
|
||||||
|
.putString(KEY_ORDER, getOrderedIds().filterNot { it == id }.joinToString(","))
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markInvalid(id: String) {
|
||||||
|
prefs.edit {
|
||||||
|
remove(passwordKey(id))
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAllSecretsKeepingEntries() {
|
||||||
|
val ids = getOrderedIds()
|
||||||
|
prefs.edit {
|
||||||
|
ids.forEach { remove(passwordKey(it)) }
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editEntry(entry: PasswordEntry) {
|
||||||
|
val password = entry.cipherText
|
||||||
|
try {
|
||||||
|
prefs.edit {
|
||||||
|
putString(nameKey(entry.id), entry.name)
|
||||||
|
putString(createdWithAuthKey(entry.id), entry.createdWithAuth.name)
|
||||||
|
if (password == null) {
|
||||||
|
remove(passwordKey(entry.id))
|
||||||
|
} else {
|
||||||
|
putString(passwordKey(entry.id), String(password))
|
||||||
|
}
|
||||||
|
val newOrder = (getOrderedIds() + entry.id).distinct()
|
||||||
|
putString(KEY_ORDER, newOrder.joinToString(","))
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
} finally {
|
||||||
|
password?.fill('\u0000')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrderedIds(): List<String> {
|
||||||
|
return (prefs.getString(KEY_ORDER, "").orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotBlank) + getAll().map { it.id }).distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nameKey(id: String) = "$ENTRY_PREFIX$id$NAME_SUFFIX"
|
||||||
|
private fun passwordKey(id: String) = "$ENTRY_PREFIX$id$PASSWORD_SUFFIX"
|
||||||
|
private fun createdWithAuthKey(id: String) = "$ENTRY_PREFIX$id$CREATED_WITH_AUTH_SUFFIX"
|
||||||
|
|
||||||
|
private fun parseCreatedState(raw: Any?): PasswordCreatedState {
|
||||||
|
return when (raw) {
|
||||||
|
is String -> PasswordCreatedState.entries
|
||||||
|
.firstOrNull { it.name == raw }
|
||||||
|
?: when (raw.lowercase()) {
|
||||||
|
"true" -> PasswordCreatedState.AuthenticatedCreated
|
||||||
|
"false" -> PasswordCreatedState.UnauthenticatedCreated
|
||||||
|
else -> PasswordCreatedState.UnauthenticatedCreated
|
||||||
|
}
|
||||||
|
|
||||||
|
is Boolean -> if (raw) {
|
||||||
|
PasswordCreatedState.AuthenticatedCreated
|
||||||
|
} else {
|
||||||
|
PasswordCreatedState.UnauthenticatedCreated
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> PasswordCreatedState.UnauthenticatedCreated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storageExceptionOf(throwable: Throwable): PasswordStorageCorruptedException {
|
||||||
|
val cause = when (throwable) {
|
||||||
|
is PasswordStorageCorruptedException -> throwable.cause ?: throwable
|
||||||
|
is KeyStoreException,
|
||||||
|
is GeneralSecurityException,
|
||||||
|
is KeyPermanentlyInvalidatedException -> throwable
|
||||||
|
|
||||||
|
else -> throwable
|
||||||
|
}
|
||||||
|
return PasswordStorageCorruptedException(cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.password
|
||||||
|
|
||||||
|
object PasswordSanitizer {
|
||||||
|
fun filterName(input: String): String {
|
||||||
|
return input
|
||||||
|
.filterNot(Char::isISOControl)
|
||||||
|
.replace('\u0000', ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filterPassword(input: String): String {
|
||||||
|
return input.filter { ch ->
|
||||||
|
!ch.isISOControl() && ch != '\u0000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.password
|
||||||
|
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
|
||||||
|
class PasswordUseCase {
|
||||||
|
suspend fun preparePassword(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
entry: PasswordEntry,
|
||||||
|
globalRequiresAuth: Boolean,
|
||||||
|
): Result<CharArray> {
|
||||||
|
val canAuthNow = BiometricGate.canAuthenticate()
|
||||||
|
|
||||||
|
if (globalRequiresAuth) {
|
||||||
|
val ok = BiometricGate.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
title = "验证以填充锁屏密码",
|
||||||
|
subtitle = entry.name,
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
return Result.failure(IllegalStateException("认证失败"))
|
||||||
|
}
|
||||||
|
} else if (entry.createdWithAuth.hasAuthenticatedOrigin && !canAuthNow) {
|
||||||
|
return Result.failure(IllegalStateException("设备安全状态已变更,请重新设置密码"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val password = entry.cipherText ?: return Result.failure(
|
||||||
|
IllegalStateException("密码已失效,请重新设置")
|
||||||
|
)
|
||||||
|
return Result.success(password.copyOf())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ object AppRuntime {
|
|||||||
AdbMdnsDiscoverer.init(appContext)
|
AdbMdnsDiscoverer.init(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val context: Context
|
||||||
|
get() = appContext
|
||||||
|
|
||||||
var scrcpy: Scrcpy? = null
|
var scrcpy: Scrcpy? = null
|
||||||
var currentConnectionTarget: ConnectionTarget? = null
|
var currentConnectionTarget: ConnectionTarget? = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
@@ -19,8 +20,8 @@ class PictureInPictureActionReceiver : BroadcastReceiver() {
|
|||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
Storage.init(appContext)
|
|
||||||
AppRuntime.init(appContext)
|
AppRuntime.init(appContext)
|
||||||
|
AppWakeLocks.init(appContext)
|
||||||
runBlocking {
|
runBlocking {
|
||||||
AppRuntime.scrcpy?.stop()
|
AppRuntime.scrcpy?.stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
)
|
)
|
||||||
val VIRTUAL_BUTTONS_LAYOUT = Pair(
|
val VIRTUAL_BUTTONS_LAYOUT = Pair(
|
||||||
stringPreferencesKey("virtual_buttons_layout"),
|
stringPreferencesKey("virtual_buttons_layout"),
|
||||||
"more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
|
"more:1,password_input:0,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
|
||||||
)
|
)
|
||||||
val CUSTOM_SERVER_URI = Pair(
|
val CUSTOM_SERVER_URI = Pair(
|
||||||
stringPreferencesKey("custom_server_uri"),
|
stringPreferencesKey("custom_server_uri"),
|
||||||
@@ -78,8 +78,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
booleanPreferencesKey("adb_auto_reconnect_paired_device"),
|
booleanPreferencesKey("adb_auto_reconnect_paired_device"),
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
// 没必要加开关, 保持启用
|
|
||||||
val ADB_MDNS_LAN_DISCOVERY = Pair(
|
val ADB_MDNS_LAN_DISCOVERY = Pair(
|
||||||
|
// 没必要加开关, 保持启用
|
||||||
booleanPreferencesKey("adb_mdns_lan_discovery"),
|
booleanPreferencesKey("adb_mdns_lan_discovery"),
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
@@ -87,6 +87,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
booleanPreferencesKey("adb_auto_load_app_list_on_connect"),
|
booleanPreferencesKey("adb_auto_load_app_list_on_connect"),
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
val PASSWORD_REQUIRE_AUTH = Pair(
|
||||||
|
booleanPreferencesKey("password_require_auth"),
|
||||||
|
true
|
||||||
|
)
|
||||||
val LAST_UPDATE_CHECK_AT = Pair(
|
val LAST_UPDATE_CHECK_AT = Pair(
|
||||||
longPreferencesKey("last_update_check_at"),
|
longPreferencesKey("last_update_check_at"),
|
||||||
0L
|
0L
|
||||||
@@ -118,6 +122,7 @@ 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)
|
||||||
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
|
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
|
||||||
|
val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
|
||||||
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
|
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@@ -140,6 +145,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
val adbAutoReconnectPairedDevice: Boolean,
|
val adbAutoReconnectPairedDevice: Boolean,
|
||||||
val adbMdnsLanDiscovery: Boolean,
|
val adbMdnsLanDiscovery: Boolean,
|
||||||
val adbAutoLoadAppListOnConnect: Boolean,
|
val adbAutoLoadAppListOnConnect: Boolean,
|
||||||
|
val passwordRequireAuth: Boolean,
|
||||||
val lastUpdateCheckAt: Long,
|
val lastUpdateCheckAt: Long,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
}
|
}
|
||||||
@@ -163,6 +169,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
|
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
|
||||||
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
|
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
|
||||||
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
|
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
|
||||||
|
bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth },
|
||||||
bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
|
bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,6 +196,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
|
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
|
||||||
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
|
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
|
||||||
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
|
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
|
||||||
|
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
|
||||||
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
|
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.storage
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
import android.content.Context
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
|
||||||
// settings singleton
|
// settings singleton
|
||||||
object Storage {
|
object Storage {
|
||||||
private lateinit var appContext: Context
|
val appSettings: AppSettings by lazy { AppSettings(AppRuntime.context) }
|
||||||
|
val quickDevices: QuickDevices by lazy { QuickDevices(AppRuntime.context) }
|
||||||
fun init(context: Context) {
|
val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(AppRuntime.context) }
|
||||||
appContext = context.applicationContext
|
val scrcpyProfiles: ScrcpyProfiles by lazy { ScrcpyProfiles(AppRuntime.context) }
|
||||||
}
|
val adbClientData: AdbClientData by lazy { AdbClientData(AppRuntime.context) }
|
||||||
|
|
||||||
val appSettings: AppSettings by lazy { AppSettings(appContext) }
|
|
||||||
val quickDevices: QuickDevices by lazy { QuickDevices(appContext) }
|
|
||||||
val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) }
|
|
||||||
val scrcpyProfiles: ScrcpyProfiles by lazy { ScrcpyProfiles(appContext) }
|
|
||||||
val adbClientData: AdbClientData by lazy { AdbClientData(appContext) }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ internal fun VirtualButtonCard(
|
|||||||
moreActions: List<VirtualButtonAction>,
|
moreActions: List<VirtualButtonAction>,
|
||||||
showText: Boolean,
|
showText: Boolean,
|
||||||
onAction: (VirtualButtonAction) -> Unit,
|
onAction: (VirtualButtonAction) -> Unit,
|
||||||
|
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val bar = remember(outsideActions, moreActions) {
|
val bar = remember(outsideActions, moreActions) {
|
||||||
VirtualButtonBar(
|
VirtualButtonBar(
|
||||||
@@ -386,6 +387,7 @@ internal fun VirtualButtonCard(
|
|||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
showText = showText,
|
showText = showText,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
|
passwordPopupContent = passwordPopupContent,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(UiSpacing.ContentVertical),
|
.padding(UiSpacing.ContentVertical),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.rounded.Home
|
|||||||
import androidx.compose.material.icons.rounded.Menu
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
import androidx.compose.material.icons.rounded.MoreVert
|
import androidx.compose.material.icons.rounded.MoreVert
|
||||||
import androidx.compose.material.icons.rounded.Notifications
|
import androidx.compose.material.icons.rounded.Notifications
|
||||||
|
import androidx.compose.material.icons.rounded.Password
|
||||||
import androidx.compose.material.icons.rounded.PowerSettingsNew
|
import androidx.compose.material.icons.rounded.PowerSettingsNew
|
||||||
import androidx.compose.material.icons.rounded.Screenshot
|
import androidx.compose.material.icons.rounded.Screenshot
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -138,6 +139,12 @@ enum class VirtualButtonAction(
|
|||||||
"截图",
|
"截图",
|
||||||
Icons.Rounded.Screenshot,
|
Icons.Rounded.Screenshot,
|
||||||
UiAndroidKeycodes.SYSRQ
|
UiAndroidKeycodes.SYSRQ
|
||||||
|
),
|
||||||
|
PASSWORD_INPUT(
|
||||||
|
"password_input",
|
||||||
|
"填充锁屏密码",
|
||||||
|
Icons.Rounded.Password,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,17 +159,30 @@ object VirtualButtonActions {
|
|||||||
private val byId = all.associateBy { it.id }
|
private val byId = all.associateBy { it.id }
|
||||||
|
|
||||||
fun parseStoredLayout(raw: String): List<VirtualButtonItem> {
|
fun parseStoredLayout(raw: String): List<VirtualButtonItem> {
|
||||||
if (raw.isBlank())
|
val parsed = raw.takeIf { it.isNotBlank() }
|
||||||
return parseStoredLayout(AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue)
|
?.split(',')
|
||||||
|
?.mapNotNull { item ->
|
||||||
return raw.split(',').mapNotNull { item ->
|
val parts = item.trim().split(':')
|
||||||
val parts = item.trim().split(':')
|
if (parts.size != 2) return@mapNotNull null
|
||||||
if (parts.size != 2) return@mapNotNull null
|
val id = parts[0]
|
||||||
val id = parts[0]
|
val showOutside = parts[1] == "1"
|
||||||
val showOutside = parts[1] == "1"
|
val action = byId[id] ?: return@mapNotNull null
|
||||||
val action = byId[id] ?: return@mapNotNull null
|
VirtualButtonItem(action, showOutside)
|
||||||
VirtualButtonItem(action, showOutside)
|
}
|
||||||
|
.orEmpty()
|
||||||
|
.distinctBy { it.action.id }
|
||||||
|
val base = parsed.ifEmpty {
|
||||||
|
parseStoredLayout(AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue)
|
||||||
}
|
}
|
||||||
|
val missing = all
|
||||||
|
.filterNot { action -> base.any { it.action == action } }
|
||||||
|
.map { action ->
|
||||||
|
VirtualButtonItem(
|
||||||
|
action = action,
|
||||||
|
showOutside = action == VirtualButtonAction.MORE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return base + missing
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encodeStoredLayout(items: List<VirtualButtonItem>): String {
|
fun encodeStoredLayout(items: List<VirtualButtonItem>): String {
|
||||||
@@ -182,12 +202,18 @@ class VirtualButtonBar(
|
|||||||
private val outsideActions: List<VirtualButtonAction>,
|
private val outsideActions: List<VirtualButtonAction>,
|
||||||
private val moreActions: List<VirtualButtonAction>,
|
private val moreActions: List<VirtualButtonAction>,
|
||||||
) {
|
) {
|
||||||
|
private enum class ActionPopupDestination {
|
||||||
|
Actions,
|
||||||
|
Passwords,
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Preview(
|
fun Preview(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
showText: Boolean,
|
showText: Boolean,
|
||||||
onAction: (VirtualButtonAction) -> Unit,
|
onAction: (VirtualButtonAction) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val haptics = LocalAppHaptics.current
|
val haptics = LocalAppHaptics.current
|
||||||
val activeContainerColor = MiuixTheme.colorScheme.primary
|
val activeContainerColor = MiuixTheme.colorScheme.primary
|
||||||
@@ -201,14 +227,22 @@ class VirtualButtonBar(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
) {
|
) {
|
||||||
outsideActions.forEach { action ->
|
outsideActions.forEach { action ->
|
||||||
|
var showPasswordPopup by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
haptics.contextClick()
|
haptics.contextClick()
|
||||||
if (action == VirtualButtonAction.MORE) {
|
when (action) {
|
||||||
showMorePopup = true
|
VirtualButtonAction.MORE -> {
|
||||||
} else {
|
showMorePopup = true
|
||||||
onAction(action)
|
}
|
||||||
|
|
||||||
|
VirtualButtonAction.PASSWORD_INPUT
|
||||||
|
if passwordPopupContent != null -> {
|
||||||
|
showPasswordPopup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> onAction(action)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
@@ -219,7 +253,9 @@ class VirtualButtonBar(
|
|||||||
),
|
),
|
||||||
insideMargin = PaddingValues(0.dp),
|
insideMargin = PaddingValues(0.dp),
|
||||||
) {
|
) {
|
||||||
val contentColor = if (enabled) activeContentColor else disabledContentColor
|
val contentColor =
|
||||||
|
if (enabled) activeContentColor
|
||||||
|
else disabledContentColor
|
||||||
Icon(
|
Icon(
|
||||||
action.icon,
|
action.icon,
|
||||||
contentDescription = action.title,
|
contentDescription = action.title,
|
||||||
@@ -240,9 +276,25 @@ class VirtualButtonBar(
|
|||||||
onAction(it)
|
onAction(it)
|
||||||
showMorePopup = false
|
showMorePopup = false
|
||||||
},
|
},
|
||||||
|
passwordPopupContent = passwordPopupContent,
|
||||||
renderInRootScaffold = false,
|
renderInRootScaffold = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
action == VirtualButtonAction.PASSWORD_INPUT &&
|
||||||
|
passwordPopupContent != null
|
||||||
|
) {
|
||||||
|
OverlayListPopup(
|
||||||
|
show = showPasswordPopup,
|
||||||
|
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||||
|
alignment = PopupPositionProvider.Align.TopEnd,
|
||||||
|
onDismissRequest = { showPasswordPopup = false },
|
||||||
|
renderInRootScaffold = false,
|
||||||
|
enableWindowDim = false,
|
||||||
|
) {
|
||||||
|
passwordPopupContent { showPasswordPopup = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,10 +304,12 @@ class VirtualButtonBar(
|
|||||||
fun Fullscreen(
|
fun Fullscreen(
|
||||||
onAction: suspend (VirtualButtonAction) -> Unit,
|
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val haptics = LocalAppHaptics.current
|
val haptics = LocalAppHaptics.current
|
||||||
var showMorePopup by remember { mutableStateOf(false) }
|
var showMorePopup by remember { mutableStateOf(false) }
|
||||||
|
var showPasswordPopup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
@@ -266,12 +320,17 @@ class VirtualButtonBar(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
haptics.contextClick()
|
haptics.contextClick()
|
||||||
if (action == VirtualButtonAction.MORE) {
|
when (action) {
|
||||||
showMorePopup = true
|
VirtualButtonAction.MORE -> {
|
||||||
} else {
|
showMorePopup = true
|
||||||
scope.launch {
|
|
||||||
onAction(action)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VirtualButtonAction.PASSWORD_INPUT
|
||||||
|
if passwordPopupContent != null -> {
|
||||||
|
showPasswordPopup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> scope.launch { onAction(action) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -291,15 +350,33 @@ class VirtualButtonBar(
|
|||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
onDismiss = { showMorePopup = false },
|
onDismiss = { showMorePopup = false },
|
||||||
onAction = {
|
onAction = {
|
||||||
onAction(it)
|
if (it == VirtualButtonAction.PASSWORD_INPUT
|
||||||
|
&& passwordPopupContent != null
|
||||||
|
) showPasswordPopup = true
|
||||||
|
else onAction(it)
|
||||||
|
|
||||||
showMorePopup = false
|
showMorePopup = false
|
||||||
},
|
},
|
||||||
|
passwordPopupContent = passwordPopupContent,
|
||||||
renderInRootScaffold = true,
|
renderInRootScaffold = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (passwordPopupContent != null) {
|
||||||
|
OverlayListPopup(
|
||||||
|
show = showPasswordPopup,
|
||||||
|
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||||
|
alignment = PopupPositionProvider.Align.TopEnd,
|
||||||
|
onDismissRequest = { showPasswordPopup = false },
|
||||||
|
renderInRootScaffold = true,
|
||||||
|
enableWindowDim = false,
|
||||||
|
) {
|
||||||
|
passwordPopupContent { showPasswordPopup = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -307,11 +384,13 @@ class VirtualButtonBar(
|
|||||||
actions: List<VirtualButtonAction>,
|
actions: List<VirtualButtonAction>,
|
||||||
onAction: suspend (VirtualButtonAction) -> Unit,
|
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
val haptics = LocalAppHaptics.current
|
val haptics = LocalAppHaptics.current
|
||||||
var showActions by remember { mutableStateOf(false) }
|
var showActions by remember { mutableStateOf(false) }
|
||||||
|
var showPasswordPopup by remember { mutableStateOf(false) }
|
||||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
var offsetXFraction by rememberSaveable(asBundleShared.fullscreenFloatingButtonXFraction) {
|
var offsetXFraction by rememberSaveable(asBundleShared.fullscreenFloatingButtonXFraction) {
|
||||||
@@ -374,18 +453,16 @@ class VirtualButtonBar(
|
|||||||
},
|
},
|
||||||
) { change, dragAmount ->
|
) { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
val nextX =
|
val nextX = (maxX.toPx() * dragStartXFraction + dragAmount.x)
|
||||||
(maxX.toPx() * dragStartXFraction + dragAmount.x).coerceIn(
|
.coerceIn(0f, maxX.toPx())
|
||||||
0f,
|
val nextY = (maxY.toPx() * dragStartYFraction + dragAmount.y)
|
||||||
maxX.toPx()
|
.coerceIn(0f, maxY.toPx())
|
||||||
)
|
val nextXFraction =
|
||||||
val nextY =
|
if (maxX > 0.dp) nextX / maxX.toPx()
|
||||||
(maxY.toPx() * dragStartYFraction + dragAmount.y).coerceIn(
|
else 0f
|
||||||
0f,
|
val nextYFraction =
|
||||||
maxY.toPx()
|
if (maxY > 0.dp) nextY / maxY.toPx()
|
||||||
)
|
else 0f
|
||||||
val nextXFraction = if (maxX > 0.dp) nextX / maxX.toPx() else 0f
|
|
||||||
val nextYFraction = if (maxY > 0.dp) nextY / maxY.toPx() else 0f
|
|
||||||
dragStartXFraction = nextXFraction
|
dragStartXFraction = nextXFraction
|
||||||
dragStartYFraction = nextYFraction
|
dragStartYFraction = nextYFraction
|
||||||
offsetXFraction = nextXFraction
|
offsetXFraction = nextXFraction
|
||||||
@@ -419,14 +496,30 @@ class VirtualButtonBar(
|
|||||||
actions = actions,
|
actions = actions,
|
||||||
onDismiss = { showActions = false },
|
onDismiss = { showActions = false },
|
||||||
onAction = {
|
onAction = {
|
||||||
scope.launch {
|
if (it == VirtualButtonAction.PASSWORD_INPUT &&
|
||||||
onAction(it)
|
passwordPopupContent != null
|
||||||
}
|
) showPasswordPopup = true
|
||||||
|
else scope.launch { onAction(it) }
|
||||||
|
|
||||||
showActions = false
|
showActions = false
|
||||||
},
|
},
|
||||||
|
passwordPopupContent = passwordPopupContent,
|
||||||
renderInRootScaffold = true,
|
renderInRootScaffold = true,
|
||||||
popupAlignment = popupAlignment,
|
popupAlignment = popupAlignment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (passwordPopupContent != null) {
|
||||||
|
OverlayListPopup(
|
||||||
|
show = showPasswordPopup,
|
||||||
|
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||||
|
alignment = popupAlignment,
|
||||||
|
onDismissRequest = { showPasswordPopup = false },
|
||||||
|
renderInRootScaffold = true,
|
||||||
|
enableWindowDim = false,
|
||||||
|
) {
|
||||||
|
passwordPopupContent { showPasswordPopup = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +530,7 @@ class VirtualButtonBar(
|
|||||||
actions: List<VirtualButtonAction>,
|
actions: List<VirtualButtonAction>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAction: suspend (VirtualButtonAction) -> Unit,
|
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||||
|
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
|
||||||
renderInRootScaffold: Boolean,
|
renderInRootScaffold: Boolean,
|
||||||
popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd,
|
popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd,
|
||||||
) {
|
) {
|
||||||
@@ -458,6 +552,60 @@ class VirtualButtonBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavOverlayListPopup(
|
||||||
|
show = show,
|
||||||
|
startDestination = ActionPopupDestination.Actions,
|
||||||
|
popupAlignment = popupAlignment,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
renderInRootScaffold = renderInRootScaffold,
|
||||||
|
) { destination, navigateTo, dismiss ->
|
||||||
|
ListPopupColumn {
|
||||||
|
if (destination == ActionPopupDestination.Actions)
|
||||||
|
spinnerItems.forEachIndexed { index, entry ->
|
||||||
|
SpinnerItemImpl(
|
||||||
|
entry = entry,
|
||||||
|
entryCount = spinnerItems.size,
|
||||||
|
isSelected = false,
|
||||||
|
index = index,
|
||||||
|
spinnerColors = SpinnerDefaults.spinnerColors(),
|
||||||
|
dialogMode = false,
|
||||||
|
onSelectedIndexChange = { selectedIdx ->
|
||||||
|
haptics.confirm()
|
||||||
|
val selectedAction = actions[selectedIdx]
|
||||||
|
if (
|
||||||
|
selectedAction == VirtualButtonAction.PASSWORD_INPUT &&
|
||||||
|
passwordPopupContent != null
|
||||||
|
) {
|
||||||
|
navigateTo(ActionPopupDestination.Passwords)
|
||||||
|
} else {
|
||||||
|
scope.launch { onAction(selectedAction) }
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (passwordPopupContent != null)
|
||||||
|
passwordPopupContent { dismiss() }
|
||||||
|
else
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <Destination> NavOverlayListPopup(
|
||||||
|
show: Boolean,
|
||||||
|
startDestination: Destination,
|
||||||
|
popupAlignment: PopupPositionProvider.Align,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
renderInRootScaffold: Boolean,
|
||||||
|
content: @Composable (
|
||||||
|
destination: Destination,
|
||||||
|
navigateTo: (Destination) -> Unit,
|
||||||
|
dismiss: () -> Unit,
|
||||||
|
) -> Unit,
|
||||||
|
) {
|
||||||
|
var destination by remember(show, startDestination) { mutableStateOf(startDestination) }
|
||||||
OverlayListPopup(
|
OverlayListPopup(
|
||||||
show = show,
|
show = show,
|
||||||
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||||
@@ -466,24 +614,7 @@ class VirtualButtonBar(
|
|||||||
renderInRootScaffold = renderInRootScaffold,
|
renderInRootScaffold = renderInRootScaffold,
|
||||||
enableWindowDim = false,
|
enableWindowDim = false,
|
||||||
) {
|
) {
|
||||||
ListPopupColumn {
|
content(destination, { destination = it }, onDismiss)
|
||||||
spinnerItems.forEachIndexed { index, entry ->
|
|
||||||
SpinnerItemImpl(
|
|
||||||
entry = entry,
|
|
||||||
entryCount = spinnerItems.size,
|
|
||||||
isSelected = false,
|
|
||||||
index = index,
|
|
||||||
spinnerColors = SpinnerDefaults.spinnerColors(),
|
|
||||||
dialogMode = false,
|
|
||||||
onSelectedIndexChange = { selectedIdx ->
|
|
||||||
haptics.confirm()
|
|
||||||
scope.launch {
|
|
||||||
onAction(actions[selectedIdx])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
174
doc/scheme/锁屏密码自动填充实现设计方案初案.md
Normal file
174
doc/scheme/锁屏密码自动填充实现设计方案初案.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 锁屏密码自动填充实现设计方案初案
|
||||||
|
|
||||||
|
## 用户输入
|
||||||
|
|
||||||
|
- Activity `onCreate` 设置 `FLAG_SECURE`
|
||||||
|
- TextField 设置
|
||||||
|
- `visualTransformation = PasswordVisualTransformation()`
|
||||||
|
- `keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, autoCorrect = false)`
|
||||||
|
- `onPause` / `onStop` 时主动清空 TextField
|
||||||
|
- 密码允许字符字母数字混合输入,明确白名单/黑名单,禁止 `\n` 类似的控制字符注入
|
||||||
|
- 不考虑连线式密码,直接无视,不需要写任何提示
|
||||||
|
|
||||||
|
## 密码存取
|
||||||
|
|
||||||
|
- 调用前使用 `BiometricManager` 确认硬件状态。若设备未设置任何锁屏保护,每次进入设置密码的 activity 前都提示风险,且按下“同意”后才允许继续
|
||||||
|
- 使用 `BiometricPrompt` 实现验证 ,开关允许跳过验证直接使用,当用户在设置页面将开关从 ON 切换到 OFF 时,除非此时不存在任何密码,否则立即拉起一次 `BiometricPrompt`
|
||||||
|
- `BiometricPrompt` 的验证器类型包括 `BIOMETRIC_STRONG` 和 `DEVICE_CREDENTIAL`,即同时允许本机密码验证
|
||||||
|
- 验证过程受 Activity 生命周期约束,`onStop` 时自动取消挂起的验证请求
|
||||||
|
- 使用 `EncryptedSharedPreferences` 储存密码
|
||||||
|
- 读取时若捕获到 `KeyStoreException` / `GeneralSecurityException`,静默清除后提示密钥损坏并引导重新设置密码,注意静默清除后同步验证是否可用的状态
|
||||||
|
- 明文密码使用 `CharArray` 类型,密码仅在 BiometricPrompt 回调成功的瞬间提取
|
||||||
|
- 在传递给 Shell 执行层后,必须在 `finally` 块中执行 `fill(0)`,确保即使后续代码异常,内存也能被清理
|
||||||
|
- 严禁将明文密码赋值给长生命周期的成员变量
|
||||||
|
- 严禁任何日志泄露
|
||||||
|
- 每次调用密码自动输入时都判断按需触发验证,不做长期信任
|
||||||
|
- 密码附带一个字段,判断密码的创建环境和使用环境是否匹配,不允许密码在认证的情况下被添加,但是在无认证的时候被使用,因为我允许无验证的情况下使用这个功能
|
||||||
|
- `val createdWithAuth: Boolean`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val globalRequiresAuth: Boolean // 全局开关
|
||||||
|
val canAuthNow: Boolean // 当前设备是否可认证
|
||||||
|
|
||||||
|
if (globalRequiresAuth) {
|
||||||
|
// 只要开关开着,就必须认证
|
||||||
|
BiometricPrompt()
|
||||||
|
} else {
|
||||||
|
// 开关关闭 → 允许不认证,但要防止安全降级
|
||||||
|
if (pw.createdWithAuth && !canAuthNow) {
|
||||||
|
// 降级禁止
|
||||||
|
showError("设备安全状态已变更,请重新设置密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可以直接使用
|
||||||
|
usePassword()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 在应用启动时就获取设备安全状态,并遍历一遍密码,如果存在安全添加的密码,但是设备变得不安全,就静默清除那个密码
|
||||||
|
- 只清除密文,但保留这个 item,让用户可以在密码管理界面看到哪个密码变得不可用 (endAction 多一个 `Block` 图标)
|
||||||
|
- 显示在 OverlayListPopup 里时也要是不可用的状态,不可点击
|
||||||
|
|
||||||
|
## 交互设计
|
||||||
|
|
||||||
|
新增入口 `ArrowPreference` `锁屏密码自动输入` 在设置页 `投屏` section 中的最后一个
|
||||||
|
|
||||||
|
点击后跳转上述中带 `FLAG_SECURE` 的新 activity,接下来所有的交互都在这个安全的 activity 内
|
||||||
|
|
||||||
|
首屏页参考目前的 `虚拟按钮排序` 页,使用项目内包装好的 `LazyColumn`
|
||||||
|
|
||||||
|
第一个选项为跳过验证的总开关 `使用密码时需要验证`,在设备无认证条件的时候禁用且(enabled = false),正常默认启用,关闭时提示安全性风险且需要同意,有密码时拉起认证,通过认证成功关闭后遍历一遍当前所有密码,将 `requiresAuth` 都设置为 false
|
||||||
|
|
||||||
|
接下来是密码列表,参考 `虚拟按钮排序` 页,允许排序和命名,命名的交互逻辑参考 `管理配置` 的 BottomSheet,在 endAction 中放一个 `Icons.Rounded.Edit` 图标按钮,右上角三点菜单放 `新增密码` 的入口
|
||||||
|
|
||||||
|
点击新增密码后根据开关判断是否拉起生物验证,通过/跳过后拉起一个 `OverlayBottomSheet`,里面放一个命名 TextField 和一个密码输入 TextField,左上角关闭按钮,右上角确认按钮,参考 `B:\Git\miuix\example\shared\src\commonMain\kotlin\component\BottomSheetSection.kt` 里的 `private fun SuperBottomSheetDemo`,确认前判断密码框输入长度,禁止空密码,确认后主动清空两个 TextField
|
||||||
|
|
||||||
|
使用入口 `填充锁屏密码` 新增在目前虚拟按钮列表 `class VirtualButtonBar` 中,图标用 `Password2`,点击呼出另一个 `OverlayListPopup` (参考 `VirtualButtonBar.ActionPopup`),里面放按用户排序的密码,点击任意一个就判断是否要验证来选择呼出生物验证还是不需要验证直接注入密码到 `Scrcpy.injectText`
|
||||||
|
|
||||||
|
## 代码设计
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 单例
|
||||||
|
// 内部使用 EncryptedSharedPreferences
|
||||||
|
// 不处理认证逻辑
|
||||||
|
object PasswordRepository {
|
||||||
|
fun getAll(): List<PasswordEntry>
|
||||||
|
fun add(entry: PasswordEntry)
|
||||||
|
fun update(entry: PasswordEntry)
|
||||||
|
fun delete(id: String)
|
||||||
|
|
||||||
|
fun markInvalid(id: String) // 清除密文但保留 entry
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 明文
|
||||||
|
data class PasswordEntry(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val cipherText: CharArray?, // null = 已失效
|
||||||
|
val createdWithAuth: Boolean
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 封装:
|
||||||
|
// BiometricManager
|
||||||
|
// BiometricPrompt
|
||||||
|
// 内部要处理:
|
||||||
|
// lifecycle
|
||||||
|
// callback 防重复(sessionId)
|
||||||
|
object BiometricGate {
|
||||||
|
fun canAuthenticate(): Boolean
|
||||||
|
suspend fun authenticate(activity: FragmentActivity): Boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class PasswordUseCase {
|
||||||
|
|
||||||
|
suspend fun preparePassword(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
entry: PasswordEntry,
|
||||||
|
globalRequiresAuth: Boolean
|
||||||
|
): Result<CharArray> {
|
||||||
|
|
||||||
|
val canAuthNow = BiometricGate.canAuthenticate()
|
||||||
|
|
||||||
|
// 1. 需要认证
|
||||||
|
if (globalRequiresAuth) {
|
||||||
|
val ok = BiometricGate.authenticate(activity)
|
||||||
|
if (!ok) {
|
||||||
|
return Result.failure(Exception("认证失败"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2. 不认证,但防降级
|
||||||
|
if (entry.createdWithAuth && !canAuthNow) {
|
||||||
|
return Result.failure(Exception("设备安全状态已变更"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 取密码
|
||||||
|
val pw = entry.cipherText ?: return Result.failure(Exception("密码已失效"))
|
||||||
|
|
||||||
|
return Result.success(pw.copyOf()) // 一定要 copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用法
|
||||||
|
val result = passwordUseCase.preparePassword(
|
||||||
|
activity,
|
||||||
|
entry,
|
||||||
|
globalRequiresAuth
|
||||||
|
)
|
||||||
|
|
||||||
|
result.onSuccess { pw ->
|
||||||
|
InjectionController.inject(pw)
|
||||||
|
}.onFailure {
|
||||||
|
showError(it.message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object InjectionController {
|
||||||
|
fun inject(password: CharArray) {
|
||||||
|
try {
|
||||||
|
Scrcpy.injectText(String(password))
|
||||||
|
} finally {
|
||||||
|
password.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 应用启动
|
||||||
|
val canAuthNow = BiometricGate.canAuthenticate()
|
||||||
|
|
||||||
|
PasswordRepository.getAll().forEach {
|
||||||
|
if (it.createdWithAuth && !canAuthNow) {
|
||||||
|
PasswordRepository.markInvalid(it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -13,6 +13,8 @@ miuix = "0.9.0"
|
|||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
runtime = "1.10.5"
|
runtime = "1.10.5"
|
||||||
androidxCorePip = "1.0.0-alpha02"
|
androidxCorePip = "1.0.0-alpha02"
|
||||||
|
androidxBiometric = "1.4.0-alpha04"
|
||||||
|
androidxSecurityCrypto = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -38,6 +40,8 @@ miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.r
|
|||||||
miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" }
|
miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" }
|
||||||
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
|
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
|
||||||
androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "androidxCorePip" }
|
androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "androidxCorePip" }
|
||||||
|
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometric" }
|
||||||
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "androidxSecurityCrypto" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user