Files
OwnDroid/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt
BinTianqi 70dca6c1c6 Add a description to Logout (#98)
Add build guide in READMEs
Update github workflow file, submit dependency graph only on master branch
2025-02-09 21:02:20 +08:00

614 lines
26 KiB
Kotlin

package com.bintianqi.owndroid.dpm
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Binder
import android.os.Build.VERSION
import android.os.Process
import android.os.UserHandle
import android.os.UserManager
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
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.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavHostController
import com.bintianqi.owndroid.R
import com.bintianqi.owndroid.parseTimestamp
import com.bintianqi.owndroid.showOperationResultToast
import com.bintianqi.owndroid.ui.CardItem
import com.bintianqi.owndroid.ui.CheckBoxItem
import com.bintianqi.owndroid.ui.FunctionItem
import com.bintianqi.owndroid.ui.InfoCard
import com.bintianqi.owndroid.ui.ListItem
import com.bintianqi.owndroid.ui.MyScaffold
import com.bintianqi.owndroid.ui.SwitchItem
import com.bintianqi.owndroid.uriToStream
import com.bintianqi.owndroid.yesOrNo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun Users(navCtrl: NavHostController) {
val context = LocalContext.current
val dpm = context.getDPM()
val receiver = context.getReceiver()
val deviceOwner = context.isDeviceOwner
val profileOwner = context.isProfileOwner
var dialog by remember { mutableIntStateOf(0) }
MyScaffold(R.string.users, 0.dp, navCtrl) {
if(VERSION.SDK_INT >= 28 && profileOwner && dpm.isAffiliatedUser) {
FunctionItem(R.string.logout, icon = R.drawable.logout_fill0) { dialog = 2 }
}
FunctionItem(R.string.user_info, icon = R.drawable.person_fill0) { navCtrl.navigate("UserInfo") }
if(deviceOwner && VERSION.SDK_INT >= 28) {
FunctionItem(R.string.secondary_users, icon = R.drawable.list_fill0) { dialog = 1 }
FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { navCtrl.navigate("UserOptions") }
}
if(deviceOwner) {
FunctionItem(R.string.user_operation, icon = R.drawable.sync_alt_fill0) { navCtrl.navigate("UserOperation") }
}
if(VERSION.SDK_INT >= 24 && deviceOwner) {
FunctionItem(R.string.create_user, icon = R.drawable.person_add_fill0) { navCtrl.navigate("CreateUser") }
}
if(deviceOwner || profileOwner) {
FunctionItem(R.string.change_username, icon = R.drawable.edit_fill0) { navCtrl.navigate("ChangeUsername") }
}
if(VERSION.SDK_INT >= 23 && (deviceOwner || profileOwner)) {
FunctionItem(R.string.change_user_icon, icon = R.drawable.account_circle_fill0) { navCtrl.navigate("ChangeUserIcon") }
}
if(VERSION.SDK_INT >= 28 && deviceOwner) {
FunctionItem(R.string.user_session_msg, icon = R.drawable.notifications_fill0) { navCtrl.navigate("UserSessionMessage") }
}
if(VERSION.SDK_INT >= 26 && (deviceOwner || profileOwner)) {
FunctionItem(R.string.affiliation_id, icon = R.drawable.id_card_fill0) { navCtrl.navigate("AffiliationID") }
}
}
if(dialog != 0 && VERSION.SDK_INT >= 28) AlertDialog(
title = { Text(stringResource(if(dialog == 1) R.string.secondary_users else R.string.logout)) },
text = {
if(dialog == 1) {
val um = context.getSystemService(Context.USER_SERVICE) as UserManager
val list = dpm.getSecondaryUsers(receiver)
if(list.isEmpty()) {
Text(stringResource(R.string.no_secondary_users))
} else {
Text("(" + stringResource(R.string.serial_number) + ")\n" + list.joinToString("\n") { um.getSerialNumberForUser(it).toString() })
}
} else {
Text(stringResource(R.string.info_logout))
}
},
confirmButton = {
TextButton(
onClick = {
if(dialog == 2) {
val result = dpm.logoutUser(receiver)
Toast.makeText(context, userOperationResultCode(result), Toast.LENGTH_SHORT).show()
}
dialog = 0
}
) {
Text(stringResource(R.string.confirm))
}
},
dismissButton = {
if(dialog != 1) TextButton(onClick = { dialog = 0 }) {
Text(stringResource(R.string.cancel))
}
},
onDismissRequest = { dialog = 0 }
)
}
@Composable
fun UserOptions(navCtrl: NavHostController) {
val context = LocalContext.current
val dpm = context.getDPM()
val receiver = context.getReceiver()
MyScaffold(R.string.options, 0.dp, navCtrl) {
if(VERSION.SDK_INT >= 28) {
SwitchItem(R.string.enable_logout, getState = { dpm.isLogoutEnabled }, onCheckedChange = { dpm.setLogoutEnabled(receiver, it) })
}
}
}
@Composable
fun CurrentUserInfo(navCtrl: NavHostController) {
val context = LocalContext.current
val dpm = context.getDPM()
val receiver = context.getReceiver()
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
val user = Process.myUserHandle()
var infoDialog by remember { mutableIntStateOf(0) }
MyScaffold(R.string.user_info, 8.dp, navCtrl) {
if(VERSION.SDK_INT >= 24) CardItem(R.string.support_multiuser, UserManager.supportsMultipleUsers().yesOrNo)
if(VERSION.SDK_INT >= 31) CardItem(R.string.headless_system_user_mode, UserManager.isHeadlessSystemUserMode().yesOrNo) { infoDialog = 1 }
Spacer(Modifier.padding(vertical = 8.dp))
if(VERSION.SDK_INT >= 23) CardItem(R.string.system_user, userManager.isSystemUser.yesOrNo)
if(VERSION.SDK_INT >= 34) CardItem(R.string.admin_user, userManager.isAdminUser.yesOrNo)
if(VERSION.SDK_INT >= 25) CardItem(R.string.demo_user, userManager.isDemoUser.yesOrNo)
if(VERSION.SDK_INT >= 26) CardItem(R.string.creation_time, parseTimestamp(userManager.getUserCreationTime(user)))
if (VERSION.SDK_INT >= 28) {
CardItem(R.string.logout_enabled, dpm.isLogoutEnabled.yesOrNo)
if(context.isDeviceOwner || context.isProfileOwner) {
CardItem(R.string.ephemeral_user, dpm.isEphemeralUser(receiver).yesOrNo)
}
CardItem(R.string.affiliated_user, dpm.isAffiliatedUser.yesOrNo)
}
CardItem(R.string.user_id, (Binder.getCallingUid() / 100000).toString())
CardItem(R.string.user_serial_number, userManager.getSerialNumberForUser(Process.myUserHandle()).toString())
}
if(infoDialog != 0) AlertDialog(
text = { Text(stringResource(R.string.info_headless_system_user_mode)) },
confirmButton = {
TextButton(onClick = { infoDialog = 0 }) {
Text(stringResource(R.string.confirm))
}
},
onDismissRequest = { infoDialog = 0 }
)
}
@Composable
fun UserOperation(navCtrl: NavHostController) {
val context = LocalContext.current
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
val dpm = context.getDPM()
val receiver = context.getReceiver()
var input by remember { mutableStateOf("") }
val focusMgr = LocalFocusManager.current
var useUserId by remember { mutableStateOf(false) }
fun withUserHandle(operation: (UserHandle) -> Unit) {
val userHandle = if(useUserId && VERSION.SDK_INT >= 24) {
UserHandle.getUserHandleForUid(input.toInt() * 100000)
} else {
userManager.getUserForSerialNumber(input.toLong())
}
if(userHandle == null) {
Toast.makeText(context, R.string.user_not_exist, Toast.LENGTH_SHORT).show()
} else {
operation(userHandle)
}
}
val legalInput = input.toIntOrNull() != null
MyScaffold(R.string.user_operation, 8.dp, navCtrl) {
if(VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(!useUserId, { useUserId = false }, SegmentedButtonDefaults.itemShape(0, 2)) {
Text(stringResource(R.string.serial_number))
}
SegmentedButton(useUserId, { useUserId = true }, SegmentedButtonDefaults.itemShape(1, 2)) {
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()
withUserHandle {
val result = dpm.startUserInBackground(receiver, it)
Toast.makeText(context, userOperationResultCode(result), Toast.LENGTH_SHORT).show()
}
},
enabled = legalInput,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.PlayArrow, null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.start_in_background))
}
}
Button(
onClick = {
focusMgr.clearFocus()
withUserHandle { context.showOperationResultToast(dpm.switchUser(receiver, it)) }
},
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) {
Button(
onClick = {
focusMgr.clearFocus()
withUserHandle {
val result = dpm.stopUser(receiver, it)
Toast.makeText(context, userOperationResultCode(result), Toast.LENGTH_SHORT).show()
}
},
enabled = legalInput,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.stop))
}
}
Button(
onClick = {
focusMgr.clearFocus()
withUserHandle {
if(dpm.removeUser(receiver, it)) {
context.showOperationResultToast(true)
input = ""
} else {
Toast.makeText(context, R.string.failed, Toast.LENGTH_SHORT).show()
}
}
},
enabled = legalInput,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Delete, null, Modifier.padding(end = 4.dp))
Text(stringResource(R.string.delete))
}
}
}
@RequiresApi(24)
@Composable
fun CreateUser(navCtrl: NavHostController) {
val context = LocalContext.current
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
val dpm = context.getDPM()
val receiver = context.getReceiver()
val focusMgr = LocalFocusManager.current
var userName by remember { mutableStateOf("") }
var creating by remember { mutableStateOf(false) }
var createdUserSerialNumber by remember { mutableLongStateOf(-1) }
var flag by remember { mutableIntStateOf(0) }
val coroutine = rememberCoroutineScope()
MyScaffold(R.string.create_user, 8.dp, navCtrl) {
OutlinedTextField(
value = userName,
onValueChange = { userName= it },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() })
)
Spacer(Modifier.padding(vertical = 5.dp))
CheckBoxItem(
R.string.create_user_skip_wizard,
flag and DevicePolicyManager.SKIP_SETUP_WIZARD != 0
) { flag = flag xor DevicePolicyManager.SKIP_SETUP_WIZARD }
if(VERSION.SDK_INT >= 28) {
CheckBoxItem(
R.string.create_user_ephemeral_user,
flag and DevicePolicyManager.MAKE_USER_EPHEMERAL != 0
) { flag = flag xor DevicePolicyManager.MAKE_USER_EPHEMERAL }
CheckBoxItem(
R.string.create_user_enable_all_system_app,
flag and DevicePolicyManager.LEAVE_ALL_SYSTEM_APPS_ENABLED != 0
) { flag = flag xor DevicePolicyManager.LEAVE_ALL_SYSTEM_APPS_ENABLED }
}
Spacer(Modifier.padding(vertical = 5.dp))
Button(
onClick = {
focusMgr.clearFocus()
creating = true
coroutine.launch(Dispatchers.IO) {
try {
val uh = dpm.createAndManageUser(receiver, userName, receiver, null, flag)
withContext(Dispatchers.Main) {
createdUserSerialNumber = userManager.getSerialNumberForUser(uh)
}
} catch(_: Exception) {
context.showOperationResultToast(false)
}
withContext(Dispatchers.Main) { creating = false }
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.create))
}
if(createdUserSerialNumber != -1L) AlertDialog(
title = { Text(stringResource(R.string.success)) },
text = { Text(stringResource(R.string.serial_number_of_new_user_is, createdUserSerialNumber)) },
confirmButton = {
TextButton({ createdUserSerialNumber = -1 }) { Text(stringResource(R.string.confirm)) }
},
onDismissRequest = { createdUserSerialNumber = -1 }
)
if(creating) Dialog({}, DialogProperties(false, false)) {
CircularProgressIndicator()
}
}
}
@RequiresApi(26)
@Composable
fun AffiliationID(navCtrl: NavHostController) {
val context = LocalContext.current
val dpm = context.getDPM()
val receiver = context.getReceiver()
val focusMgr = LocalFocusManager.current
var input by remember { mutableStateOf("") }
val list = remember { mutableStateListOf<String>() }
val refreshIds = {
list.clear()
list.addAll(dpm.getAffiliationIds(receiver))
}
LaunchedEffect(Unit) { refreshIds() }
MyScaffold(R.string.affiliation_id, 8.dp, navCtrl) {
Column(modifier = Modifier.animateContentSize()) {
if(list.isEmpty()) Text(stringResource(R.string.none))
for(i in list) {
ListItem(i) { list -= i }
}
}
Spacer(Modifier.padding(vertical = 5.dp))
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("ID") },
trailingIcon = {
IconButton(
onClick = {
list += input
input = ""
}
) {
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add))
}
},
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() })
)
Spacer(Modifier.padding(vertical = 5.dp))
Button(
onClick = {
list.removeAll(listOf(""))
dpm.setAffiliationIds(receiver, list.toSet())
context.showOperationResultToast(true)
refreshIds()
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.apply))
}
InfoCard(R.string.info_affiliated_id)
}
}
@Composable
fun ChangeUsername(navCtrl: NavHostController) {
val context = LocalContext.current
val dpm = context.getDPM()
val receiver = context.getReceiver()
val focusMgr = LocalFocusManager.current
var inputUsername by remember { mutableStateOf("") }
MyScaffold(R.string.change_username, 8.dp, navCtrl) {
OutlinedTextField(
value = inputUsername,
onValueChange = { inputUsername= it },
label = { Text(stringResource(R.string.username)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.padding(vertical = 5.dp))
Button(
onClick = {
dpm.setProfileName(receiver, inputUsername)
context.showOperationResultToast(true)
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.apply))
}
Button(
onClick = { dpm.setProfileName(receiver,null) },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.reset))
}
}
}
@RequiresApi(28)
@Composable
fun UserSessionMessage(navCtrl: NavHostController) {
val context = LocalContext.current
val dpm = context.getDPM()
val receiver = context.getReceiver()
val focusMgr = LocalFocusManager.current
var start by remember { mutableStateOf("") }
var end by remember { mutableStateOf("") }
val refreshMsg = {
start = dpm.getStartUserSessionMessage(receiver)?.toString() ?: ""
end = dpm.getEndUserSessionMessage(receiver)?.toString() ?: ""
}
LaunchedEffect(Unit) { refreshMsg() }
MyScaffold(R.string.user_session_msg, 8.dp, navCtrl) {
OutlinedTextField(
value = start,
onValueChange = { start= it },
label = { Text(stringResource(R.string.start_user_session_msg)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }),
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp)
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(
onClick = {
dpm.setStartUserSessionMessage(receiver,start)
refreshMsg()
},
modifier = Modifier.fillMaxWidth(0.49F)
) {
Text(stringResource(R.string.apply))
}
Button(
onClick = {
dpm.setStartUserSessionMessage(receiver,null)
refreshMsg()
context.showOperationResultToast(true)
},
modifier = Modifier.fillMaxWidth(0.96F)
) {
Text(stringResource(R.string.reset))
}
}
Spacer(Modifier.padding(vertical = 8.dp))
OutlinedTextField(
value = end,
onValueChange = { end= it },
label = { Text(stringResource(R.string.end_user_session_msg)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }),
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp)
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(
onClick = {
dpm.setEndUserSessionMessage(receiver,end)
refreshMsg()
context.showOperationResultToast(true)
},
modifier = Modifier.fillMaxWidth(0.49F)
) {
Text(stringResource(R.string.apply))
}
Button(
onClick = {
dpm.setEndUserSessionMessage(receiver,null)
refreshMsg()
context.showOperationResultToast(true)
},
modifier = Modifier.fillMaxWidth(0.96F)
) {
Text(stringResource(R.string.reset))
}
}
}
}
@RequiresApi(23)
@Composable
fun ChangeUserIcon(navCtrl: NavHostController) {
val context = LocalContext.current
val dpm = context.getDPM()
val receiver = context.getReceiver()
var getContent by remember { mutableStateOf(false) }
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
it.data?.data?.let {
uriToStream(context, it) { stream ->
bitmap = BitmapFactory.decodeStream(stream)
}
}
}
MyScaffold(R.string.change_user_icon, 8.dp, navCtrl) {
CheckBoxItem(R.string.file_picker_instead_gallery, getContent) { getContent = it }
Spacer(Modifier.padding(vertical = 5.dp))
Button(
onClick = {
val intent = Intent(if(getContent) Intent.ACTION_GET_CONTENT else Intent.ACTION_PICK)
if(getContent) intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
getFileLauncher.launch(intent)
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.select_picture))
}
AnimatedVisibility(visible = bitmap != null, modifier = Modifier.align(Alignment.CenterHorizontally)) {
Card(modifier = Modifier.padding(top = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(10.dp)) {
Image(
bitmap = bitmap!!.asImageBitmap(), contentDescription = "User icon",
modifier = Modifier.padding(end = 12.dp).size(80.dp).clip(RoundedCornerShape(50))
)
Button(
onClick = {
dpm.setUserIcon(receiver, bitmap)
context.showOperationResultToast(true)
}
) {
Text(stringResource(R.string.apply))
}
}
}
}
}
}
@StringRes
private fun userOperationResultCode(result:Int): Int =
when(result) {
UserManager.USER_OPERATION_SUCCESS -> R.string.success
UserManager.USER_OPERATION_ERROR_UNKNOWN -> R.string.unknown_error
UserManager.USER_OPERATION_ERROR_MANAGED_PROFILE-> R.string.fail_managed_profile
UserManager.USER_OPERATION_ERROR_CURRENT_USER-> R.string.fail_current_user
else -> R.string.unknown
}