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

@@ -601,7 +601,8 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) {
UsersOptionsScreen(vm::getLogoutEnabled, vm::setLogoutEnabled, ::navigateUp)
}
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<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.SystemOptionsStatus
import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo
import com.bintianqi.owndroid.dpm.UserIdentifier
import com.bintianqi.owndroid.dpm.UserInformation
import com.bintianqi.owndroid.dpm.UserOperationType
import com.bintianqi.owndroid.dpm.WifiInfo
import com.bintianqi.owndroid.dpm.WifiSecurity
import com.bintianqi.owndroid.dpm.WifiStatus
import com.bintianqi.owndroid.dpm.activateOrgProfileCommand
import com.bintianqi.owndroid.dpm.delegatedScopesList
import com.bintianqi.owndroid.dpm.doUserOperationWithContext
import com.bintianqi.owndroid.dpm.getPackageInstaller
import com.bintianqi.owndroid.dpm.handlePrivilegeChange
import com.bintianqi.owndroid.dpm.isValidPackageName
@@ -1290,35 +1293,28 @@ class MyViewModel(application: Application): AndroidViewModel(application) {
UM.getSerialNumberForUser(uh)
)
}
@Suppress("PrivateApi")
@RequiresApi(28)
fun startUser(id: Int, isUserId: Boolean): Int {
val uh = getUserHandle(id, isUserId)
if (uh == null) return R.string.user_not_exist
return getUserOperationResultText(DPM.startUserInBackground(DAR, uh))
fun getUserIdentifiers(): List<UserIdentifier> {
return DPM.getSecondaryUsers(DAR)?.mapNotNull {
try {
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
}
} ?: emptyList()
}
fun switchUser(id: Int, isUserId: Boolean): Boolean {
val uh = getUserHandle(id, isUserId)
if (uh == null) return false
DPM.switchUser(DAR, uh)
return true
fun doUserOperation(type: UserOperationType, id: Int, isUserId: Boolean): Boolean {
return doUserOperationWithContext(application, type, id, isUserId)
}
@RequiresApi(28)
fun stopUser(id: Int, isUserId: Boolean): Int {
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 {
return when (code) {
@@ -1369,10 +1365,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) {
DPM.setUserIcon(DAR, bitmap)
}
@RequiresApi(28)
fun getSecondaryUsers(): List<Long> {
return DPM.getSecondaryUsers(DAR).map { UM.getSerialNumberForUser(it) }
}
@RequiresApi(28)
fun getUserSessionMessages(): Pair<String, String> {
return (DPM.getStartUserSessionMessage(DAR)?.toString() ?: "") to
(DPM.getEndUserSessionMessage(DAR)?.toString() ?: "")

View File

@@ -103,6 +103,10 @@ class Receiver : DeviceAdminReceiver() {
override fun onUserRemoved(context: Context, intent: Intent, removedUser: UserHandle) {
super.onUserRemoved(context, intent, removedUser)
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) {

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.bintianqi.owndroid.dpm.UserOperationType
object ShortcutUtils {
fun setAllShortcuts(context: Context, enabled: Boolean) {
@@ -19,7 +20,7 @@ object ShortcutUtils {
)
ShortcutManagerCompat.setDynamicShortcuts(context, list)
} else {
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
ShortcutManagerCompat.removeDynamicShortcuts(context, MyShortcut.entries.map { it.id })
}
}
fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) {
@@ -79,6 +80,46 @@ object ShortcutUtils {
val shortcut = createUserRestrictionShortcut(context, id, state)
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() {
if (SP.shortcutKey.isNullOrEmpty()) {
SP.shortcutKey = generateBase64Key(10)

View File

@@ -3,6 +3,8 @@ package com.bintianqi.owndroid
import android.app.Activity
import android.os.Bundle
import android.util.Log
import com.bintianqi.owndroid.dpm.UserOperationType
import com.bintianqi.owndroid.dpm.doUserOperationWithContext
class ShortcutsReceiverActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,7 +13,7 @@ class ShortcutsReceiverActivity : Activity() {
val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.")
val key = SP.shortcutKey
val requestKey = intent?.getStringExtra("key")
if (action != null && SP.shortcuts && key != null && requestKey == key) {
if (action != null && key != null && requestKey == key) {
when (action) {
"LOCK" -> Privilege.DPM.lockNow()
"DISABLE_CAMERA" -> {
@@ -35,12 +37,21 @@ class ShortcutsReceiverActivity : Activity() {
}
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")
showOperationResultToast(true)
} else {
showOperationResultToast(false)
}
} catch(e: Exception) {
e.printStackTrace()
} finally {
finish()
}

View File

@@ -12,12 +12,16 @@ import android.content.Intent
import android.content.pm.IPackageInstaller
import android.content.pm.PackageInstaller
import android.os.Build.VERSION
import android.os.UserHandle
import android.os.UserManager
import android.util.Log
import androidx.annotation.RequiresApi
import com.bintianqi.owndroid.MyApplication
import com.bintianqi.owndroid.NotificationType
import com.bintianqi.owndroid.NotificationUtils
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.SP
import com.bintianqi.owndroid.ShortcutUtils
@@ -142,7 +146,7 @@ class NetworkLog(
@RequiresApi(26)
fun retrieveNetworkLogs(app: MyApplication, token: Long) {
CoroutineScope(Dispatchers.IO).launch {
val logs = Privilege.DPM.retrieveNetworkLogs(Privilege.DAR, token)?.mapNotNull {
val logs = DPM.retrieveNetworkLogs(DAR, token)?.mapNotNull {
when (it) {
is DnsEvent -> NetworkLog(
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)
fun retrieveSecurityLogs(app: MyApplication) {
CoroutineScope(Dispatchers.IO).launch {
val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR)
val logs = DPM.retrieveSecurityLogs(DAR)
if (logs.isNullOrEmpty()) return@launch
app.myRepo.writeSecurityLogs(logs)
NotificationUtils.sendBasicNotification(
@@ -500,7 +504,7 @@ fun setDefaultAffiliationID() {
if (VERSION.SDK_INT < 26) return
if(!SP.isDefaultAffiliationIdSet) {
try {
Privilege.DPM.setAffiliationIds(Privilege.DAR, setOf("OwnDroid_default_affiliation_id"))
DPM.setAffiliationIds(DAR, setOf("OwnDroid_default_affiliation_id"))
SP.isDefaultAffiliationIdSet = true
Log.d("DPM", "Default affiliation id set")
} catch (e: Exception) {
@@ -559,3 +563,29 @@ fun handlePrivilegeChange(context: Context) {
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.material3.AlertDialog
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.IconButton
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -40,6 +46,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -81,15 +88,14 @@ import kotlinx.serialization.Serializable
fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) {
val context = LocalContext.current
val privilege by Privilege.status.collectAsStateWithLifecycle()
/** 1: secondary users, 2: logout*/
/** 1: logout */
var dialog by rememberSaveable { mutableIntStateOf(0) }
MyScaffold(R.string.users, onNavigateUp, 0.dp) {
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) }
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) }
}
if(privilege.device) {
@@ -126,24 +132,6 @@ fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) ->
}
}
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)) },
text = {
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserOperationScreen(
startUser: (Int, Boolean) -> Int, switchUser: (Int, Boolean) -> Boolean,
stopUser: (Int, Boolean) -> Int, deleteUser: (Int, Boolean) -> Boolean, onNavigateUp: () -> Unit
getUsers: () -> List<UserIdentifier>, doOperation: (UserOperationType, Int, Boolean) -> Boolean,
createShortcut: (UserOperationType, Int, Boolean) -> Boolean, onNavigateUp: () -> Unit
) {
val context = LocalContext.current
var input by rememberSaveable { mutableStateOf("") }
val focusMgr = LocalFocusManager.current
var useUserId by rememberSaveable { mutableStateOf(false) }
var dialog by rememberSaveable { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) }
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) {
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)) {
Text(stringResource(R.string.serial_number))
}
@@ -249,50 +259,84 @@ fun UserOperationScreen(
Text(stringResource(R.string.user_id))
}
}
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number)) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() })
)
if(VERSION.SDK_INT >= 28) {
Button(
onClick = {
focusMgr.clearFocus()
context.popToast(startUser(input.toInt(), useUserId))
ExposedDropdownMenuBox(menu, { menu = it }) {
OutlinedTextField(
input, { input = it },
Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryEditable)
.padding(top = 4.dp, bottom = 8.dp),
label = {
Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number))
},
enabled = legalInput,
modifier = Modifier.fillMaxWidth()
trailingIcon = {
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))
Text(stringResource(R.string.start_in_background))
}
CreateShortcutIcon(UserOperationType.Start)
}
Button(
onClick = {
focusMgr.clearFocus()
if (switchUser(input.toInt(), useUserId)) context.popToast(R.string.user_not_exist)
},
enabled = legalInput,
modifier = Modifier.fillMaxWidth()
) {
Icon(painterResource(R.drawable.sync_alt_fill0), null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.user_operation_switch))
}
if(VERSION.SDK_INT >= 28) {
Row {
Button(
onClick = {
{
focusMgr.clearFocus()
context.popToast(stopUser(input.toInt(), useUserId))
val result = doOperation(UserOperationType.Switch, input.toInt(), useUserId)
context.showOperationResultToast(result)
},
enabled = legalInput,
modifier = Modifier.fillMaxWidth()
Modifier.weight(1F),
legalInput
) {
Icon(painterResource(R.drawable.sync_alt_fill0), null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.user_operation_switch))
}
CreateShortcutIcon(UserOperationType.Switch)
}
if (VERSION.SDK_INT >= 28) Row {
Button(
{
focusMgr.clearFocus()
val result = doOperation(UserOperationType.Stop, input.toInt(), useUserId)
context.showOperationResultToast(result)
},
Modifier.weight(1F),
legalInput
) {
Icon(Icons.Default.Close, null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.stop))
}
CreateShortcutIcon(UserOperationType.Stop)
}
Button(
onClick = {
@@ -312,7 +356,8 @@ fun UserOperationScreen(
},
confirmButton = {
TextButton({
context.showOperationResultToast(deleteUser(input.toInt(), useUserId))
val result = doOperation(UserOperationType.Delete, input.toInt(), useUserId)
context.showOperationResultToast(result)
dialog = false
}) {
Text(stringResource(R.string.confirm))