User operation shortcuts

Update dependencies
This commit is contained in:
BinTianqi
2025-10-20 17:35:58 +08:00
parent fde191adc5
commit a57b3b3a8e
12 changed files with 232 additions and 100 deletions

View File

@@ -91,6 +91,7 @@ dependencies {
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.material.icons.core)
implementation(libs.shizuku.provider) implementation(libs.shizuku.provider)
implementation(libs.shizuku.api) implementation(libs.shizuku.api)
implementation(libs.dhizuku.api) implementation(libs.dhizuku.api)

View File

@@ -12,7 +12,7 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/> <uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-sdk tools:overrideLibrary="rikka.shizuku.provider,rikka.shizuku.api,rikka.shizuku.shared,rikka.shizuku.aidl"/> <uses-sdk tools:overrideLibrary="rikka.shizuku.provider,rikka.shizuku.api,rikka.shizuku.shared,rikka.shizuku.aidl,com.rosan.dhizuku.server_api,com.rosan.dhizuku.api"/>
<uses-feature android:name="android.software.device_admin"/> <uses-feature android:name="android.software.device_admin"/>
<application <application
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"

View File

@@ -601,7 +601,8 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) {
UsersOptionsScreen(vm::getLogoutEnabled, vm::setLogoutEnabled, ::navigateUp) UsersOptionsScreen(vm::getLogoutEnabled, vm::setLogoutEnabled, ::navigateUp)
} }
composable<UserOperation> { composable<UserOperation> {
UserOperationScreen(vm::startUser, vm::switchUser, vm::stopUser, vm::deleteUser, ::navigateUp) UserOperationScreen(vm::getUserIdentifiers, vm::doUserOperation,
vm::createUserOperationShortcut, ::navigateUp)
} }
composable<CreateUser> { CreateUserScreen(vm::createUser, ::navigateUp) } composable<CreateUser> { CreateUserScreen(vm::createUser, ::navigateUp) }
composable<ChangeUsername> { ChangeUsernameScreen(vm::setProfileName, ::navigateUp) } composable<ChangeUsername> { ChangeUsernameScreen(vm::setProfileName, ::navigateUp) }

View File

@@ -87,12 +87,15 @@ import com.bintianqi.owndroid.dpm.SsidPolicy
import com.bintianqi.owndroid.dpm.SsidPolicyType import com.bintianqi.owndroid.dpm.SsidPolicyType
import com.bintianqi.owndroid.dpm.SystemOptionsStatus import com.bintianqi.owndroid.dpm.SystemOptionsStatus
import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo
import com.bintianqi.owndroid.dpm.UserIdentifier
import com.bintianqi.owndroid.dpm.UserInformation import com.bintianqi.owndroid.dpm.UserInformation
import com.bintianqi.owndroid.dpm.UserOperationType
import com.bintianqi.owndroid.dpm.WifiInfo import com.bintianqi.owndroid.dpm.WifiInfo
import com.bintianqi.owndroid.dpm.WifiSecurity import com.bintianqi.owndroid.dpm.WifiSecurity
import com.bintianqi.owndroid.dpm.WifiStatus import com.bintianqi.owndroid.dpm.WifiStatus
import com.bintianqi.owndroid.dpm.activateOrgProfileCommand import com.bintianqi.owndroid.dpm.activateOrgProfileCommand
import com.bintianqi.owndroid.dpm.delegatedScopesList import com.bintianqi.owndroid.dpm.delegatedScopesList
import com.bintianqi.owndroid.dpm.doUserOperationWithContext
import com.bintianqi.owndroid.dpm.getPackageInstaller import com.bintianqi.owndroid.dpm.getPackageInstaller
import com.bintianqi.owndroid.dpm.handlePrivilegeChange import com.bintianqi.owndroid.dpm.handlePrivilegeChange
import com.bintianqi.owndroid.dpm.isValidPackageName import com.bintianqi.owndroid.dpm.isValidPackageName
@@ -1290,35 +1293,28 @@ class MyViewModel(application: Application): AndroidViewModel(application) {
UM.getSerialNumberForUser(uh) UM.getSerialNumberForUser(uh)
) )
} }
@Suppress("PrivateApi")
@RequiresApi(28) @RequiresApi(28)
fun startUser(id: Int, isUserId: Boolean): Int { fun getUserIdentifiers(): List<UserIdentifier> {
val uh = getUserHandle(id, isUserId) return DPM.getSecondaryUsers(DAR)?.mapNotNull {
if (uh == null) return R.string.user_not_exist try {
return getUserOperationResultText(DPM.startUserInBackground(DAR, uh)) val field = UserHandle::class.java.getDeclaredField("mHandle")
field.isAccessible = true
UserIdentifier(field.get(it) as Int, UM.getSerialNumberForUser(it))
} catch (e: Exception) {
e.printStackTrace()
null
} }
fun switchUser(id: Int, isUserId: Boolean): Boolean { } ?: emptyList()
val uh = getUserHandle(id, isUserId)
if (uh == null) return false
DPM.switchUser(DAR, uh)
return true
} }
@RequiresApi(28) fun doUserOperation(type: UserOperationType, id: Int, isUserId: Boolean): Boolean {
fun stopUser(id: Int, isUserId: Boolean): Int { return doUserOperationWithContext(application, type, id, isUserId)
val uh = getUserHandle(id, isUserId)
if (uh == null) return R.string.user_not_exist
return getUserOperationResultText(DPM.stopUser(DAR, uh))
}
fun deleteUser(id: Int, isUserId: Boolean): Boolean {
val uh = getUserHandle(id, isUserId)
if (uh == null) return false
return DPM.removeUser(DAR, uh)
}
fun getUserHandle(id: Int, isUserId: Boolean): UserHandle? {
return if (isUserId && VERSION.SDK_INT >= 24) {
UserHandle.getUserHandleForUid(id * 100000)
} else {
UM.getUserForSerialNumber(id.toLong())
} }
fun createUserOperationShortcut(type: UserOperationType, id: Int, isUserId: Boolean): Boolean {
val serial = if (isUserId && VERSION.SDK_INT >= 24) {
UM.getSerialNumberForUser(UserHandle.getUserHandleForUid(id * 100000))
} else id
return ShortcutUtils.setUserOperationShortcut(application, type, serial.toInt())
} }
fun getUserOperationResultText(code: Int): Int { fun getUserOperationResultText(code: Int): Int {
return when (code) { return when (code) {
@@ -1369,10 +1365,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) {
DPM.setUserIcon(DAR, bitmap) DPM.setUserIcon(DAR, bitmap)
} }
@RequiresApi(28) @RequiresApi(28)
fun getSecondaryUsers(): List<Long> {
return DPM.getSecondaryUsers(DAR).map { UM.getSerialNumberForUser(it) }
}
@RequiresApi(28)
fun getUserSessionMessages(): Pair<String, String> { fun getUserSessionMessages(): Pair<String, String> {
return (DPM.getStartUserSessionMessage(DAR)?.toString() ?: "") to return (DPM.getStartUserSessionMessage(DAR)?.toString() ?: "") to
(DPM.getEndUserSessionMessage(DAR)?.toString() ?: "") (DPM.getEndUserSessionMessage(DAR)?.toString() ?: "")

View File

@@ -103,6 +103,10 @@ class Receiver : DeviceAdminReceiver() {
override fun onUserRemoved(context: Context, intent: Intent, removedUser: UserHandle) { override fun onUserRemoved(context: Context, intent: Intent, removedUser: UserHandle) {
super.onUserRemoved(context, intent, removedUser) super.onUserRemoved(context, intent, removedUser)
sendUserRelatedNotification(context, removedUser, NotificationType.UserRemoved) sendUserRelatedNotification(context, removedUser, NotificationType.UserRemoved)
val um = context.getSystemService(Context.USER_SERVICE) as UserManager
ShortcutUtils.deleteUserOperationShortcut(
context, um.getSerialNumberForUser(removedUser).toInt()
)
} }
override fun onBugreportShared(context: Context, intent: Intent, hash: String) { override fun onBugreportShared(context: Context, intent: Intent, hash: String) {

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import com.bintianqi.owndroid.dpm.UserOperationType
object ShortcutUtils { object ShortcutUtils {
fun setAllShortcuts(context: Context, enabled: Boolean) { fun setAllShortcuts(context: Context, enabled: Boolean) {
@@ -19,7 +20,7 @@ object ShortcutUtils {
) )
ShortcutManagerCompat.setDynamicShortcuts(context, list) ShortcutManagerCompat.setDynamicShortcuts(context, list)
} else { } else {
ShortcutManagerCompat.removeAllDynamicShortcuts(context) ShortcutManagerCompat.removeDynamicShortcuts(context, MyShortcut.entries.map { it.id })
} }
} }
fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) { fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) {
@@ -79,6 +80,46 @@ object ShortcutUtils {
val shortcut = createUserRestrictionShortcut(context, id, state) val shortcut = createUserRestrictionShortcut(context, id, state)
ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut)) ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut))
} }
fun buildUserOperationShortcut(
context: Context, type: UserOperationType, serial: Int
): ShortcutInfoCompat {
setShortcutKey()
val icon = when (type) {
UserOperationType.Start, UserOperationType.Switch -> R.drawable.person_fill0
UserOperationType.Stop -> R.drawable.person_off
else -> R.drawable.person_fill0
}
val text = when (type) {
UserOperationType.Start -> R.string.start_user_n
UserOperationType.Switch -> R.string.switch_to_user_n
UserOperationType.Stop -> R.string.stop_user_n
else -> R.string.place_holder
}
return ShortcutInfoCompat.Builder(context, "USER_OPERATION-${type.name}-$serial")
.setIcon(IconCompat.createWithResource(context, icon))
.setShortLabel(context.getString(text, serial))
.setIntent(
Intent(context, ShortcutsReceiverActivity::class.java)
.setAction("com.bintianqi.owndroid.action.USER_OPERATION")
.putExtra("operation", type.name)
.putExtra("serial", serial)
.putExtra("key", SP.shortcutKey)
)
.build()
}
fun setUserOperationShortcut(context: Context, type: UserOperationType, serial: Int): Boolean {
val shortcut = buildUserOperationShortcut(context, type, serial)
return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
}
fun deleteUserOperationShortcut(context: Context, serial: Int) {
val shortcuts = ShortcutManagerCompat.getShortcuts(
context, ShortcutManagerCompat.FLAG_MATCH_PINNED
)
val matchedShortcuts = shortcuts.filter {
it.id.startsWith("USER_OPERATION-") && it.id.endsWith("-$serial")
}.map { it.id }
ShortcutManagerCompat.removeLongLivedShortcuts(context, matchedShortcuts)
}
fun setShortcutKey() { fun setShortcutKey() {
if (SP.shortcutKey.isNullOrEmpty()) { if (SP.shortcutKey.isNullOrEmpty()) {
SP.shortcutKey = generateBase64Key(10) SP.shortcutKey = generateBase64Key(10)

View File

@@ -3,6 +3,8 @@ package com.bintianqi.owndroid
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import com.bintianqi.owndroid.dpm.UserOperationType
import com.bintianqi.owndroid.dpm.doUserOperationWithContext
class ShortcutsReceiverActivity : Activity() { class ShortcutsReceiverActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,7 +13,7 @@ class ShortcutsReceiverActivity : Activity() {
val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.") val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.")
val key = SP.shortcutKey val key = SP.shortcutKey
val requestKey = intent?.getStringExtra("key") val requestKey = intent?.getStringExtra("key")
if (action != null && SP.shortcuts && key != null && requestKey == key) { if (action != null && key != null && requestKey == key) {
when (action) { when (action) {
"LOCK" -> Privilege.DPM.lockNow() "LOCK" -> Privilege.DPM.lockNow()
"DISABLE_CAMERA" -> { "DISABLE_CAMERA" -> {
@@ -35,12 +37,21 @@ class ShortcutsReceiverActivity : Activity() {
} }
ShortcutUtils.updateUserRestrictionShortcut(this, id, !state, false) ShortcutUtils.updateUserRestrictionShortcut(this, id, !state, false)
} }
"USER_OPERATION" -> {
val typeName = intent.getStringExtra("operation") ?: return
val type = UserOperationType.valueOf(typeName)
val serial = intent.getIntExtra("serial", -1)
if (serial == -1) return
doUserOperationWithContext(this, type, serial, false)
}
} }
Log.d(TAG, "Received intent: $action") Log.d(TAG, "Received intent: $action")
showOperationResultToast(true) showOperationResultToast(true)
} else { } else {
showOperationResultToast(false) showOperationResultToast(false)
} }
} catch(e: Exception) {
e.printStackTrace()
} finally { } finally {
finish() finish()
} }

View File

@@ -12,12 +12,16 @@ import android.content.Intent
import android.content.pm.IPackageInstaller import android.content.pm.IPackageInstaller
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.os.Build.VERSION import android.os.Build.VERSION
import android.os.UserHandle
import android.os.UserManager
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.bintianqi.owndroid.MyApplication import com.bintianqi.owndroid.MyApplication
import com.bintianqi.owndroid.NotificationType import com.bintianqi.owndroid.NotificationType
import com.bintianqi.owndroid.NotificationUtils import com.bintianqi.owndroid.NotificationUtils
import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.Privilege
import com.bintianqi.owndroid.Privilege.DAR
import com.bintianqi.owndroid.Privilege.DPM
import com.bintianqi.owndroid.R import com.bintianqi.owndroid.R
import com.bintianqi.owndroid.SP import com.bintianqi.owndroid.SP
import com.bintianqi.owndroid.ShortcutUtils import com.bintianqi.owndroid.ShortcutUtils
@@ -142,7 +146,7 @@ class NetworkLog(
@RequiresApi(26) @RequiresApi(26)
fun retrieveNetworkLogs(app: MyApplication, token: Long) { fun retrieveNetworkLogs(app: MyApplication, token: Long) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val logs = Privilege.DPM.retrieveNetworkLogs(Privilege.DAR, token)?.mapNotNull { val logs = DPM.retrieveNetworkLogs(DAR, token)?.mapNotNull {
when (it) { when (it) {
is DnsEvent -> NetworkLog( is DnsEvent -> NetworkLog(
if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, "dns", if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, "dns",
@@ -486,7 +490,7 @@ fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? {
@RequiresApi(24) @RequiresApi(24)
fun retrieveSecurityLogs(app: MyApplication) { fun retrieveSecurityLogs(app: MyApplication) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR) val logs = DPM.retrieveSecurityLogs(DAR)
if (logs.isNullOrEmpty()) return@launch if (logs.isNullOrEmpty()) return@launch
app.myRepo.writeSecurityLogs(logs) app.myRepo.writeSecurityLogs(logs)
NotificationUtils.sendBasicNotification( NotificationUtils.sendBasicNotification(
@@ -500,7 +504,7 @@ fun setDefaultAffiliationID() {
if (VERSION.SDK_INT < 26) return if (VERSION.SDK_INT < 26) return
if(!SP.isDefaultAffiliationIdSet) { if(!SP.isDefaultAffiliationIdSet) {
try { try {
Privilege.DPM.setAffiliationIds(Privilege.DAR, setOf("OwnDroid_default_affiliation_id")) DPM.setAffiliationIds(DAR, setOf("OwnDroid_default_affiliation_id"))
SP.isDefaultAffiliationIdSet = true SP.isDefaultAffiliationIdSet = true
Log.d("DPM", "Default affiliation id set") Log.d("DPM", "Default affiliation id set")
} catch (e: Exception) { } catch (e: Exception) {
@@ -559,3 +563,29 @@ fun handlePrivilegeChange(context: Context) {
SP.apiKeyHash = "" SP.apiKeyHash = ""
} }
} }
fun doUserOperationWithContext(
context: Context, type: UserOperationType, id: Int, isUserId: Boolean
): Boolean {
val um = context.getSystemService(Context.USER_SERVICE) as UserManager
val handle = if (isUserId && VERSION.SDK_INT >= 24) {
UserHandle.getUserHandleForUid(id * 100000)
} else {
um.getUserForSerialNumber(id.toLong())
}
if (handle == null) return false
return when (type) {
UserOperationType.Start -> {
if (VERSION.SDK_INT >= 28)
DPM.startUserInBackground(DAR, handle) == UserManager.USER_OPERATION_SUCCESS
else false
}
UserOperationType.Switch -> DPM.switchUser(DAR, handle)
UserOperationType.Stop -> {
if (VERSION.SDK_INT >= 28)
DPM.stopUser(DAR, handle) == UserManager.USER_OPERATION_SUCCESS
else false
}
UserOperationType.Delete -> DPM.removeUser(DAR, handle)
}
}

View File

@@ -28,8 +28,14 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
@@ -40,6 +46,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -81,15 +88,14 @@ import kotlinx.serialization.Serializable
fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val privilege by Privilege.status.collectAsStateWithLifecycle() val privilege by Privilege.status.collectAsStateWithLifecycle()
/** 1: secondary users, 2: logout*/ /** 1: logout */
var dialog by rememberSaveable { mutableIntStateOf(0) } var dialog by rememberSaveable { mutableIntStateOf(0) }
MyScaffold(R.string.users, onNavigateUp, 0.dp) { MyScaffold(R.string.users, onNavigateUp, 0.dp) {
if(VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated) { if(VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated) {
FunctionItem(R.string.logout, icon = R.drawable.logout_fill0) { dialog = 2 } FunctionItem(R.string.logout, icon = R.drawable.logout_fill0) { dialog = 1 }
} }
FunctionItem(R.string.user_info, icon = R.drawable.person_fill0) { onNavigate(UserInfo) } FunctionItem(R.string.user_info, icon = R.drawable.person_fill0) { onNavigate(UserInfo) }
if(VERSION.SDK_INT >= 28 && privilege.device) { if(VERSION.SDK_INT >= 28 && privilege.device) {
FunctionItem(R.string.secondary_users, icon = R.drawable.list_fill0) { dialog = 1 }
FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(UsersOptions) } FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(UsersOptions) }
} }
if(privilege.device) { if(privilege.device) {
@@ -126,24 +132,6 @@ fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) ->
} }
} }
if (VERSION.SDK_INT >= 28 && dialog == 1) AlertDialog( if (VERSION.SDK_INT >= 28 && dialog == 1) AlertDialog(
title = { Text(stringResource(R.string.secondary_users)) },
text = {
val list = vm.getSecondaryUsers()
val text = if (list.isEmpty()) {
stringResource(R.string.no_secondary_users)
} else {
"(" + stringResource(R.string.serial_number) + ")\n" + list.joinToString("\n")
}
Text(text)
},
confirmButton = {
TextButton({ dialog = 0 }) {
Text(stringResource(R.string.confirm))
}
},
onDismissRequest = { dialog = 0 }
)
if (VERSION.SDK_INT >= 28 && dialog == 2) AlertDialog(
title = { Text(stringResource(R.string.logout)) }, title = { Text(stringResource(R.string.logout)) },
text = { text = {
Text(stringResource(R.string.info_logout)) Text(stringResource(R.string.info_logout))
@@ -227,21 +215,43 @@ fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) {
) )
} }
class UserIdentifier(val id: Int, val serial: Long)
enum class UserOperationType {
Start, Switch, Stop, Delete
}
@Serializable object UserOperation @Serializable object UserOperation
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun UserOperationScreen( fun UserOperationScreen(
startUser: (Int, Boolean) -> Int, switchUser: (Int, Boolean) -> Boolean, getUsers: () -> List<UserIdentifier>, doOperation: (UserOperationType, Int, Boolean) -> Boolean,
stopUser: (Int, Boolean) -> Int, deleteUser: (Int, Boolean) -> Boolean, onNavigateUp: () -> Unit createShortcut: (UserOperationType, Int, Boolean) -> Boolean, onNavigateUp: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var input by rememberSaveable { mutableStateOf("") } var input by rememberSaveable { mutableStateOf("") }
val focusMgr = LocalFocusManager.current val focusMgr = LocalFocusManager.current
var useUserId by rememberSaveable { mutableStateOf(false) } var useUserId by rememberSaveable { mutableStateOf(false) }
var dialog by rememberSaveable { mutableStateOf(false) } var dialog by rememberSaveable { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) }
val legalInput = input.toIntOrNull() != null val legalInput = input.toIntOrNull() != null
val identifiers = remember { mutableStateListOf<UserIdentifier>() }
@Composable
fun CreateShortcutIcon(type: UserOperationType) {
FilledTonalIconButton({
if (!createShortcut(type, input.toInt(), useUserId))
context.showOperationResultToast(false)
}, enabled = legalInput) {
Icon(painterResource(R.drawable.open_in_new), null)
}
}
LaunchedEffect(Unit) {
identifiers.addAll(getUsers())
}
MyScaffold(R.string.user_operation, onNavigateUp) { MyScaffold(R.string.user_operation, onNavigateUp) {
if(VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { if (VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!useUserId, { useUserId = false }, SegmentedButtonDefaults.itemShape(0, 2)) { SegmentedButton(!useUserId, { useUserId = false }, SegmentedButtonDefaults.itemShape(0, 2)) {
Text(stringResource(R.string.serial_number)) Text(stringResource(R.string.serial_number))
} }
@@ -249,50 +259,84 @@ fun UserOperationScreen(
Text(stringResource(R.string.user_id)) Text(stringResource(R.string.user_id))
} }
} }
ExposedDropdownMenuBox(menu, { menu = it }) {
OutlinedTextField( OutlinedTextField(
value = input, input, { input = it },
onValueChange = { input = it }, Modifier
label = { Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number)) }, .fillMaxWidth()
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp), .menuAnchor(MenuAnchorType.PrimaryEditable)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), .padding(top = 4.dp, bottom = 8.dp),
keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) label = {
) Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number))
if(VERSION.SDK_INT >= 28) {
Button(
onClick = {
focusMgr.clearFocus()
context.popToast(startUser(input.toInt(), useUserId))
}, },
enabled = legalInput, trailingIcon = {
modifier = Modifier.fillMaxWidth() ExposedDropdownMenuDefaults.TrailingIcon(menu)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done)
)
ExposedDropdownMenu(menu, { menu = false }) {
if (identifiers.isEmpty()) {
DropdownMenuItem(
{ Text(stringResource(R.string.no_secondary_users)) }, {}
)
} else {
identifiers.forEach {
val text = (if (useUserId) it.id else it.serial).toString()
DropdownMenuItem(
{ Text(text) },
{
input = text
menu = false
}
)
}
}
}
}
if (VERSION.SDK_INT >= 28) Row {
Button(
{
focusMgr.clearFocus()
val result = doOperation(UserOperationType.Start, input.toInt(), useUserId)
context.showOperationResultToast(result)
},
Modifier.weight(1F),
legalInput
) { ) {
Icon(Icons.Default.PlayArrow, null, Modifier.padding(end = 4.dp)) Icon(Icons.Default.PlayArrow, null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.start_in_background)) Text(stringResource(R.string.start_in_background))
} }
CreateShortcutIcon(UserOperationType.Start)
} }
Row {
Button( Button(
onClick = { {
focusMgr.clearFocus() focusMgr.clearFocus()
if (switchUser(input.toInt(), useUserId)) context.popToast(R.string.user_not_exist) val result = doOperation(UserOperationType.Switch, input.toInt(), useUserId)
context.showOperationResultToast(result)
}, },
enabled = legalInput, Modifier.weight(1F),
modifier = Modifier.fillMaxWidth() legalInput
) { ) {
Icon(painterResource(R.drawable.sync_alt_fill0), null, Modifier.padding(end = 4.dp)) Icon(painterResource(R.drawable.sync_alt_fill0), null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.user_operation_switch)) Text(stringResource(R.string.user_operation_switch))
} }
if(VERSION.SDK_INT >= 28) { CreateShortcutIcon(UserOperationType.Switch)
}
if (VERSION.SDK_INT >= 28) Row {
Button( Button(
onClick = { {
focusMgr.clearFocus() focusMgr.clearFocus()
context.popToast(stopUser(input.toInt(), useUserId)) val result = doOperation(UserOperationType.Stop, input.toInt(), useUserId)
context.showOperationResultToast(result)
}, },
enabled = legalInput, Modifier.weight(1F),
modifier = Modifier.fillMaxWidth() legalInput
) { ) {
Icon(Icons.Default.Close, null, Modifier.padding(end = 4.dp)) Icon(Icons.Default.Close, null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.stop)) Text(stringResource(R.string.stop))
} }
CreateShortcutIcon(UserOperationType.Stop)
} }
Button( Button(
onClick = { onClick = {
@@ -312,7 +356,8 @@ fun UserOperationScreen(
}, },
confirmButton = { confirmButton = {
TextButton({ TextButton({
context.showOperationResultToast(deleteUser(input.toInt(), useUserId)) val result = doOperation(UserOperationType.Delete, input.toInt(), useUserId)
context.showOperationResultToast(result)
dialog = false dialog = false
}) { }) {
Text(stringResource(R.string.confirm)) Text(stringResource(R.string.confirm))

View File

@@ -469,6 +469,9 @@
<string name="secondary_users">次要用户</string> <string name="secondary_users">次要用户</string>
<string name="no_secondary_users">无次要用户</string> <string name="no_secondary_users">无次要用户</string>
<string name="user_operation">用户操作</string> <string name="user_operation">用户操作</string>
<string name="start_user_n">启动用户 %1$d</string>
<string name="switch_to_user_n">切换到用户 %1$d</string>
<string name="stop_user_n">停止用户 %1$d</string>
<string name="user_not_exist">用户不存在</string> <string name="user_not_exist">用户不存在</string>
<string name="serial_number">序列号</string> <string name="serial_number">序列号</string>
<string name="logout">登出</string> <string name="logout">登出</string>

View File

@@ -505,6 +505,9 @@
<string name="secondary_users">Secondary users</string> <string name="secondary_users">Secondary users</string>
<string name="no_secondary_users">No secondary users</string> <string name="no_secondary_users">No secondary users</string>
<string name="user_operation">User operation</string> <string name="user_operation">User operation</string>
<string name="start_user_n">Start user %1$d</string>
<string name="switch_to_user_n">Switch to user %1$d</string>
<string name="stop_user_n">Stop user %1$d</string>
<string name="user_not_exist">User does not exist</string> <string name="user_not_exist">User does not exist</string>
<string name="serial_number">Serial number</string> <string name="serial_number">Serial number</string>
<string name="logout">Logout</string> <string name="logout">Logout</string>

View File

@@ -1,15 +1,15 @@
[versions] [versions]
agp = "8.12.2" agp = "8.13.0"
kotlin = "2.2.10" kotlin = "2.2.20"
navigation-compose = "2.9.3" navigation-compose = "2.9.5"
composeBom = "2025.08.01" composeBom = "2025.10.00"
accompanist-drawablepainter = "0.37.3" accompanist-drawablepainter = "0.37.3"
accompanist-permissions = "0.37.3" accompanist-permissions = "0.37.3"
shizuku = "13.1.5" shizuku = "13.1.5"
fragment = "1.8.9" fragment = "1.8.9"
dhizuku = "2.5.3" dhizuku = "2.5.4"
dhizuku-server = "0.0.5" dhizuku-server = "0.0.6"
hiddenApiBypass = "6.1" hiddenApiBypass = "6.1"
libsu = "6.0.0" libsu = "6.0.0"
serialization = "1.9.0" serialization = "1.9.0"
@@ -22,6 +22,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose" }
androidx-material3 = { module = "androidx.compose.material3:material3" } androidx-material3 = { module = "androidx.compose.material3:material3" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" } androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" }
material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist-drawablepainter" } accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist-drawablepainter" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist-permissions" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist-permissions" }
@@ -39,4 +40,4 @@ serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.10" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.20" }