From 765b1ea7906798ad3d3db23d9639431ef84d33ab Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sat, 30 Aug 2025 18:36:10 +0800 Subject: [PATCH 01/26] Split UI and ViewModel --- .../owndroid/AppInstallerActivity.kt | 378 +----------------- .../owndroid/AppInstallerViewModel.kt | 150 +++++++ .../main/java/com/bintianqi/owndroid/Utils.kt | 13 +- .../com/bintianqi/owndroid/ui/AppInstaller.kt | 258 ++++++++++++ 4 files changed, 424 insertions(+), 375 deletions(-) create mode 100644 app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt create mode 100644 app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt index f37be6c..5d0d8a1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt @@ -1,85 +1,15 @@ package com.bintianqi.owndroid -import android.app.Application -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageInfo -import android.content.pm.PackageInstaller -import android.net.Uri -import android.os.Build import android.os.Bundle -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage -import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem -import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.AppInstaller import com.bintianqi.owndroid.ui.theme.OwnDroidTheme -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.URLDecoder class AppInstallerActivity:FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -91,311 +21,11 @@ class AppInstallerActivity:FragmentActivity() { setContent { val theme by myVm.theme.collectAsStateWithLifecycle() OwnDroidTheme(theme) { - val installing by vm.installing.collectAsStateWithLifecycle() - val options by vm.options.collectAsStateWithLifecycle() - val packages by vm.packages.collectAsStateWithLifecycle() - val writtenPackages by vm.writtenPackages.collectAsStateWithLifecycle() - val writingPackage by vm.writingPackage.collectAsStateWithLifecycle() - val result by vm.result.collectAsStateWithLifecycle() + val uiState by vm.uiState.collectAsState() AppInstaller( - installing, options, { if(!installing) vm.options.value = it }, - packages, { uri -> vm.packages.update { it.minus(uri) } }, - { uris -> vm.packages.update { it.plus(uris) } }, - vm::startInstall, writtenPackages, writingPackage, - result, { vm.result.value = null } + uiState, vm::onPackagesAdd, vm::onPackageRemove, vm::startInstall, vm::closeResultDialog ) } } } } - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun AppInstaller( - installing: Boolean = false, - options: SessionParamsOptions = SessionParamsOptions(), - onOptionsChange: (SessionParamsOptions) -> Unit = {}, - packages: Set = setOf("https://example.com".toUri()), - onPackageRemove: (Uri) -> Unit = {}, - onPackageChoose: (List) -> Unit = {}, - onStartInstall: () -> Unit = {}, - writtenPackages: Set = setOf("https://example.com".toUri()), - writingPackage: Uri? = null, - result: Intent? = null, - onResultDialogClose: () -> Unit = {} -) { - var appLockDialog by rememberSaveable { mutableStateOf(false) } - val coroutine = rememberCoroutineScope() - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.app_installer)) } - ) - }, - floatingActionButton = { - if(packages.isNotEmpty()) ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.start)) }, - icon = { - if(installing) CircularProgressIndicator(modifier = Modifier.size(24.dp)) - else Icon(Icons.Default.PlayArrow, null) - }, - onClick = { - if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall() else appLockDialog = true - }, - expanded = !installing - ) - } - ) { paddingValues -> - var tab by remember { mutableIntStateOf(0) } - val pagerState = rememberPagerState { 2 } - val scrollState = rememberScrollState() - tab = pagerState.targetPage - Column(modifier = Modifier.padding(paddingValues)) { - TabRow(tab) { - Tab( - tab == 0, - onClick = { - coroutine.launch { scrollState.animateScrollTo(0) } - coroutine.launch { pagerState.animateScrollToPage(0) } - }, - text = { Text(stringResource(R.string.packages)) } - ) - Tab( - tab == 1, - onClick = { - coroutine.launch { scrollState.animateScrollTo(0) } - coroutine.launch { pagerState.animateScrollToPage(1) } - }, - text = { Text(stringResource(R.string.options)) } - ) - } - HorizontalPager(pagerState) { page -> - Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(top = 8.dp)) { - if(page == 0) Packages(installing, packages, onPackageRemove, onPackageChoose, writtenPackages, writingPackage) - else Options(options, onOptionsChange) - } - } - ResultDialog(result, onResultDialogClose) - } - } - if(appLockDialog) { - AppLockDialog({ - appLockDialog = false - onStartInstall() - }) { appLockDialog = false } - } -} - - -@Composable -private fun ColumnScope.Packages( - installing: Boolean, - packages: Set, onRemove: (Uri) -> Unit, onChoose: (List) -> Unit, - writtenPackages: Set, writingPackage: Uri? -) { - val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onChoose) - packages.forEach { - PackageItem( - it, installing, - { onRemove(it) }, it in writtenPackages, it == writingPackage - ) - } - AnimatedVisibility(!installing) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { - chooseSplitPackage.launch(APK_MIME) - }.padding(vertical = 12.dp) - ) { - Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp)) - Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium) - } - } -} - - -@Composable -private fun PackageItem(uri: Uri, installing: Boolean, onRemove: () -> Unit, isWritten: Boolean, isWriting: Boolean) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 6.dp, bottom = 6.dp).heightIn(min = 40.dp) - ) { - Text( - URLDecoder.decode(URLDecoder.decode(uri.path ?: uri.toString())), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth(0.85F) - ) - if(!installing) IconButton(onRemove) { - Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove)) - } - if(isWritten) Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary) - if(isWriting) CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp)) - } -} - -data class SessionParamsOptions( - val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL, - val keepOriginalEnabledSetting: Boolean = false, - val noKill: Boolean = false, - val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY, -) - -@Composable -private fun ColumnScope.Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) { - Text( - stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), - style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) - FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) { - onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false)) - } - FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { - onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING)) - } - if(Build.VERSION.SDK_INT >= 34) { - AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { - FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) { - onChange(options.copy(noKill = it)) - } - } - FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) { - onChange(options.copy(keepOriginalEnabledSetting = it)) - } - } - Text( - stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), - style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) - FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO)) - } - FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)) - } - FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL)) - } -} - -@Composable -private fun ResultDialog(result: Intent?, onDialogClose: () -> Unit) { - if(result != null) { - val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - AlertDialog( - title = { - val text = if(status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure - Text(stringResource(text)) - }, - text = { - val context = LocalContext.current - Text(parsePackageInstallerMessage(context, result)) - }, - confirmButton = { - TextButton(onDialogClose) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = onDialogClose - ) - } -} - -class AppInstallerViewModel(application: Application): AndroidViewModel(application) { - fun initialize(intent: Intent) { - intent.data?.let { uri -> packages.update { it + uri } } - intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uri -> packages.update { it + uri } } - intent.getParcelableArrayExtra(Intent.EXTRA_STREAM)?.forEach { uri -> packages.update { it + (uri as Uri) } } - intent.clipData?.let { clipData -> - for(i in 0..clipData.itemCount) { - packages.update { it + clipData.getItemAt(i).uri } - } - } - } - val installing = MutableStateFlow(false) - val result = MutableStateFlow(null) - val packages = MutableStateFlow(setOf()) - - val options = MutableStateFlow(SessionParamsOptions()) - - val writtenPackages = MutableStateFlow(setOf()) - val writingPackage = MutableStateFlow(null) - private fun getSessionParams(): PackageInstaller.SessionParams { - return PackageInstaller.SessionParams(options.value.mode).apply { - if(Build.VERSION.SDK_INT >= 34) { - if(options.value.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent() - setDontKillApp(options.value.noKill) - } - setInstallLocation(options.value.location) - } - } - fun startInstall() { - if(installing.value) return - installing.value = true - viewModelScope.launch(Dispatchers.IO) { - val context = getApplication() - val packageInstaller = context.packageManager.packageInstaller - val sessionId = packageInstaller.createSession(getSessionParams()) - val session = packageInstaller.openSession(sessionId) - try { - packages.value.forEach { splitPackageUri -> - withContext(Dispatchers.Main) { writingPackage.value = splitPackageUri } - session.openWrite(splitPackageUri.hashCode().toString(), 0, -1).use { splitPackageOut -> - context.contentResolver.openInputStream(splitPackageUri)!!.use { splitPackageIn -> - splitPackageIn.copyTo(splitPackageOut) - } - session.fsync(splitPackageOut) - } - withContext(Dispatchers.Main) { writtenPackages.update { it.plus(splitPackageUri) } } - } - withContext(Dispatchers.Main) { writingPackage.value = null } - } catch(e: Exception) { - e.printStackTrace() - session.abandon() - return@launch - } - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { - @SuppressWarnings("UnsafeIntentLaunch") - context.startActivity( - (intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) - ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } else { - result.value = intent - writtenPackages.value = setOf() - if(statusExtra == PackageInstaller.STATUS_SUCCESS) { - packages.value = setOf() - } - installing.value = false - context.unregisterReceiver(this) - } - } - } - ContextCompat.registerReceiver( - context, receiver, IntentFilter(ACTION), null, - null, ContextCompat.RECEIVER_EXPORTED - ) - val pi = if(Build.VERSION.SDK_INT >= 34) { - PendingIntent.getBroadcast( - context, sessionId, Intent(ACTION), - PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE - ).intentSender - } else { - PendingIntent.getBroadcast(context, sessionId, Intent(ACTION), PendingIntent.FLAG_MUTABLE).intentSender - } - session.commit(pi) - } - } - - override fun onCleared() { - super.onCleared() - viewModelScope.cancel() - } - companion object { - const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED" - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt new file mode 100644 index 0000000..db89514 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt @@ -0,0 +1,150 @@ +package com.bintianqi.owndroid + +import android.app.Application +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +class AppInstallerViewModel(application: Application): AndroidViewModel(application) { + val uiState = MutableStateFlow(UiState()) + data class UiState( + val packages: List = emptyList(), + val installing: Boolean = false, + val packageWriting: Int = -1, + val result: Intent? = null + ) + + fun initialize(intent: Intent) { + val list = mutableListOf() + intent.data?.let { list += it } + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { list += it } + intent.getParcelableArrayExtra(Intent.EXTRA_STREAM)?.forEach { list += it as Uri } + intent.clipData?.let { clipData -> + for(i in 0..clipData.itemCount - 1) { + list += clipData.getItemAt(i).uri + } + } + uiState.update { it.copy(it.packages + list.distinct()) } + } + + fun onPackagesAdd(packages: List) { + uiState.update { + it.copy(packages = it.packages.plus(packages).distinct()) + } + } + + fun onPackageRemove(uri: Uri) { + uiState.update { + it.copy(packages = it.packages.minus(uri)) + } + } + + private fun getSessionParams(options: SessionParamsOptions): PackageInstaller.SessionParams { + return PackageInstaller.SessionParams(options.mode).apply { + if(Build.VERSION.SDK_INT >= 34) { + if(options.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent() + setDontKillApp(options.noKill) + } + setInstallLocation(options.location) + } + } + + fun startInstall(options: SessionParamsOptions) { + if (uiState.value.installing) return + viewModelScope.launch(Dispatchers.IO) { + installPackages(options) + } + } + + private fun installPackages(options: SessionParamsOptions) { + val packageInstaller = application.packageManager.packageInstaller + val sessionId = packageInstaller.createSession(getSessionParams(options)) + val session = packageInstaller.openSession(sessionId) + try { + uiState.update { it.copy(packageWriting = 0) } + uiState.value.packages.forEach { uri -> + session.openWrite(uri.hashCode().toString(), 0, -1).use { splitPackageOut -> + application.contentResolver.openInputStream(uri)!!.use { splitPackageIn -> + splitPackageIn.copyTo(splitPackageOut) + } + session.fsync(splitPackageOut) + } + uiState.update { it.copy(packageWriting = it.packageWriting + 1) } + } + } catch(e: Exception) { + e.printStackTrace() + session.abandon() + uiState.update { it.copy(installing = false, packageWriting = -1) } + return + } + ContextCompat.registerReceiver( + application, Receiver(), IntentFilter(ACTION), null, + null, ContextCompat.RECEIVER_EXPORTED + ) + val pi = if(Build.VERSION.SDK_INT >= 34) { + PendingIntent.getBroadcast( + application, sessionId, Intent(ACTION), + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE + ).intentSender + } else { + PendingIntent.getBroadcast(application, sessionId, Intent(ACTION), PendingIntent.FLAG_MUTABLE).intentSender + } + session.commit(pi) + } + + inner class Receiver() : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + if (statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @SuppressWarnings("UnsafeIntentLaunch") + context.startActivity( + (intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } else { + uiState.update { it.copy(result = intent) } + context.unregisterReceiver(this) + } + } + } + + fun closeResultDialog() { + if (uiState.value.result?.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) == PackageInstaller.STATUS_SUCCESS) { + uiState.update { it.copy(emptyList(), packageWriting = -1, result = null) } + } else { + uiState.update { it.copy(packageWriting = -1, result = null) } + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.cancel() + } + companion object { + const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED" + } +} + +@Serializable +data class SessionParamsOptions( + val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL, + val keepOriginalEnabledSetting: Boolean = false, + val noKill: Boolean = false, + val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY, +) diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index fded7d3..a19ee32 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -13,10 +13,12 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.RequiresApi import androidx.annotation.StringRes +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.NavType -import kotlinx.serialization.encodeToString +import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import java.io.FileNotFoundException import java.io.IOException @@ -151,3 +153,12 @@ fun Context.popToast(resId: Int) { fun Context.popToast(str: String) { Toast.makeText(this, str, Toast.LENGTH_SHORT).show() } + +class SerializableSaver(val serializer: KSerializer) : Saver { + override fun restore(value: String): T? { + return Json.decodeFromString(serializer, value) + } + override fun SaverScope.save(value: T): String? { + return Json.encodeToString(serializer, value) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt new file mode 100644 index 0000000..6b68957 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt @@ -0,0 +1,258 @@ +package com.bintianqi.owndroid.ui + +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.APK_MIME +import com.bintianqi.owndroid.AppInstallerViewModel +import com.bintianqi.owndroid.AppLockDialog +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.SP +import com.bintianqi.owndroid.SerializableSaver +import com.bintianqi.owndroid.SessionParamsOptions +import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage +import kotlinx.coroutines.launch +import java.net.URLDecoder + + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun AppInstaller( + uiState: AppInstallerViewModel.UiState = AppInstallerViewModel.UiState(), + onPackagesAdd: (List) -> Unit = {}, + onPackageRemove: (Uri) -> Unit = {}, + onStartInstall: (SessionParamsOptions) -> Unit = {}, + onResultDialogClose: () -> Unit = {} +) { + var appLockDialog by rememberSaveable { mutableStateOf(false) } + var options by rememberSaveable(stateSaver = SerializableSaver(SessionParamsOptions.serializer())) { + mutableStateOf(SessionParamsOptions()) + } + val coroutine = rememberCoroutineScope() + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_installer)) } + ) + }, + floatingActionButton = { + if(uiState.packages.isNotEmpty()) ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.start)) }, + icon = { + if(uiState.installing) CircularProgressIndicator(modifier = Modifier.size(24.dp)) + else Icon(Icons.Default.PlayArrow, null) + }, + onClick = { + if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall(options) else appLockDialog = true + }, + expanded = !uiState.installing + ) + } + ) { paddingValues -> + var tab by remember { mutableIntStateOf(0) } + val pagerState = rememberPagerState { 2 } + val scrollState = rememberScrollState() + tab = pagerState.targetPage + Column(modifier = Modifier.padding(paddingValues)) { + TabRow(tab) { + Tab( + tab == 0, + onClick = { + coroutine.launch { scrollState.animateScrollTo(0) } + coroutine.launch { pagerState.animateScrollToPage(0) } + }, + text = { Text(stringResource(R.string.packages)) } + ) + Tab( + tab == 1, + onClick = { + coroutine.launch { scrollState.animateScrollTo(0) } + coroutine.launch { pagerState.animateScrollToPage(1) } + }, + text = { Text(stringResource(R.string.options)) } + ) + } + HorizontalPager(pagerState, Modifier.fillMaxHeight(), verticalAlignment = Alignment.Top) { page -> + if (page == 0) Packages(uiState, onPackageRemove, onPackagesAdd) + else Options(options) { options = it } + } + } + ResultDialog(uiState.result, onResultDialogClose) + } + if(appLockDialog) { + AppLockDialog({ + appLockDialog = false + onStartInstall(options) + }) { appLockDialog = false } + } +} + + +@Composable +private fun Packages( + uiState: AppInstallerViewModel.UiState, onRemove: (Uri) -> Unit, onAdd: (List) -> Unit +) { + val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onAdd) + LazyColumn(Modifier.padding(top = 8.dp)) { + itemsIndexed(uiState.packages, { _, it -> it }) { i, it -> + val status = when { + uiState.packageWriting < 0 -> 0 + i < uiState.packageWriting -> 3 + i == uiState.packageWriting -> 2 + else -> 1 + } + PackageItem(it, status) { onRemove(it) } + } + if (!uiState.installing) { + item { + Row( + Modifier.fillMaxWidth().animateItem().padding(vertical = 4.dp).clickable { + chooseSplitPackage.launch(APK_MIME) + }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp)) + Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium) + } + } + } + } +} + +/** + * @param status 0: not installing, 1: installing, 2: writing, 3: written + */ +@Composable +private fun LazyItemScope.PackageItem(uri: Uri, status: Int, onRemove: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().animateItem().padding(start = 8.dp, end = 6.dp, bottom = 6.dp).heightIn(min = 40.dp) + ) { + Text( + URLDecoder.decode(URLDecoder.decode(uri.path ?: uri.toString())), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(0.85F) + ) + when (status) { + 0 -> IconButton(onRemove) { + Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove)) + } + 2 -> CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp)) + 3 -> Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary) + } + } +} + +@Composable +private fun Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) = Column { + Text( + stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) { + onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false)) + } + FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { + onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING)) + } + if(Build.VERSION.SDK_INT >= 34) { + AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { + FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) { + onChange(options.copy(noKill = it)) + } + } + FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) { + onChange(options.copy(keepOriginalEnabledSetting = it)) + } + } + Text( + stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO)) + } + FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)) + } + FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL)) + } +} + +@Composable +private fun ResultDialog(result: Intent?, onDialogClose: () -> Unit) { + if(result != null) { + val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + AlertDialog( + title = { + val text = if(status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure + Text(stringResource(text)) + }, + text = { + val context = LocalContext.current + Text(parsePackageInstallerMessage(context, result)) + }, + confirmButton = { + TextButton(onDialogClose) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = onDialogClose + ) + } +} From e7c7a3b3c60d9fc2337bae0c07832258b8af6792 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sun, 7 Sep 2025 13:34:42 +0800 Subject: [PATCH 02/26] Update readme, optimize UI --- Readme-en.md | 8 ++++++++ Readme.md | 8 ++++++++ .../main/java/com/bintianqi/owndroid/dpm/Applications.kt | 3 ++- .../main/java/com/bintianqi/owndroid/dpm/Permissions.kt | 7 +++---- .../java/com/bintianqi/owndroid/dpm/UserRestriction.kt | 2 +- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Readme-en.md b/Readme-en.md index 6265fe8..f4ecd7b 100644 --- a/Readme-en.md +++ b/Readme-en.md @@ -57,6 +57,14 @@ Solutions: > [!NOTE] > Some systems have features such as app cloning and children space, which are usually users. +#### Device owner is already set + +```text +java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.owndroid/.Receiver), but device owner (xxx) is already set. +``` + +Only 1 device owner can exist on a device. Please deactivate the existing device owner first. + ### MIUI & HyperOS ```text diff --git a/Readme.md b/Readme.md index d3309b0..a2eb37d 100644 --- a/Readme.md +++ b/Readme.md @@ -57,6 +57,14 @@ java.lang.IllegalStateException: Not allowed to set the device owner because the > [!NOTE] > 一些系统有应用克隆、儿童空间等功能,它们通常是用户。 +#### Device owner 已存在 + +```text +java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.owndroid/.Receiver), but device owner (xxx) is already set. +``` + +一个设备只能存在一个device owner,请先停用已存在的device owner。 + ### MIUI & HyperOS ```text diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index d898059..f75fe78 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -124,7 +125,7 @@ fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) { Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp).animateItem(), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Row(Modifier.fillMaxWidth(0.87F), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { Image( painter = rememberDrawablePainter(info.icon), contentDescription = null, modifier = Modifier.padding(start = 12.dp, end = 18.dp).size(30.dp) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt index fb4c6cf..30c0f5f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt @@ -116,7 +116,6 @@ import com.topjohnwu.superuser.Shell import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @Serializable data class WorkModes(val canNavigateUp: Boolean) @@ -549,15 +548,15 @@ fun DhizukuServerSettingsScreen(onNavigateUp: () -> Unit) { .padding(8.dp, 8.dp, 0.dp, 8.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { Image( rememberDrawablePainter(info.loadIcon(pm)), null, Modifier .padding(end = 16.dp) - .size(50.dp) + .size(45.dp) ) Column { - Text(info.loadLabel(pm).toString(), style = typography.titleLarge) + Text(info.loadLabel(pm).toString(), style = typography.titleMedium) Text(name, Modifier.alpha(0.7F), style = typography.bodyMedium) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index 8527a26..6ba859f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -161,7 +161,7 @@ fun UserRestrictionOptionsScreen( Modifier.fillMaxWidth().padding(15.dp, 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Row(Modifier.fillMaxWidth(0.8F), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { Icon(painterResource(restriction.icon), null, Modifier.padding(start = 6.dp, end = 16.dp)) Column { Text(stringResource(restriction.name), style = typography.titleMedium) From 80c1ddb36c21baf7e1672c3bc6baf9ccbc87f465 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Wed, 17 Sep 2025 18:29:52 +0800 Subject: [PATCH 03/26] Optimize Applications and PackageChooser --- app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 5 - .../com/bintianqi/owndroid/DhizukuServer.kt | 1 - .../com/bintianqi/owndroid/MainActivity.kt | 144 +++- .../com/bintianqi/owndroid/MyViewModel.kt | 365 ++++++++ .../com/bintianqi/owndroid/PackageChooser.kt | 74 +- .../java/com/bintianqi/owndroid/Settings.kt | 30 +- .../main/java/com/bintianqi/owndroid/Utils.kt | 9 - .../bintianqi/owndroid/dpm/Applications.kt | 796 +++++------------- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 1 - .../com/bintianqi/owndroid/dpm/Network.kt | 63 +- .../com/bintianqi/owndroid/dpm/Permissions.kt | 31 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 68 +- 13 files changed, 787 insertions(+), 802 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b19edd8..48b55e8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,3 +24,5 @@ -dontwarn android.app.ActivityThread -dontwarn android.app.ContextImpl -dontwarn android.app.LoadedApk + +-keep class com.bintianqi.owndroid.MyViewModel { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f71f941..bdbe7d4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,11 +60,6 @@ - Unit) { val lifecycleOwner = LocalLifecycleOwner.current fun navigateUp() { navController.navigateUp() } fun navigate(destination: Any) { navController.navigate(destination) } + fun choosePackage() { + navController.navigate(ApplicationsList(false)) + } LaunchedEffect(Unit) { if(!Privilege.status.value.activated) { navController.navigate(WorkModes(false)) { @@ -306,7 +302,9 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { DhizukuServerSettingsScreen(::navigateUp) } composable { DelegatedAdminsScreen(::navigateUp, ::navigate) } - composable{ AddDelegatedAdminScreen(it.toRoute(), ::navigateUp) } + composable{ + AddDelegatedAdminScreen(vm.chosenPackage, ::choosePackage, it.toRoute(), ::navigateUp) + } composable { DeviceInfoScreen(::navigateUp) } composable { LockScreenInfoScreen(::navigateUp) } composable { SupportMessageScreen(::navigateUp) } @@ -331,7 +329,9 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { PermissionPolicyScreen(::navigateUp) } composable { MtePolicyScreen(::navigateUp) } composable { NearbyStreamingPolicyScreen(::navigateUp) } - composable { LockTaskModeScreen(::navigateUp) } + composable { + LockTaskModeScreen(vm.chosenPackage, ::choosePackage, ::navigateUp) + } composable { CaCertScreen(::navigateUp) } composable { SecurityLoggingScreen(::navigateUp) } composable { DisableAccountManagementScreen(::navigateUp) } @@ -346,12 +346,16 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { AddNetworkScreen(it.arguments!!, ::navigateUp) } composable { WifiSecurityLevelScreen(::navigateUp) } composable { WifiSsidPolicyScreen(::navigateUp) } - composable { NetworkStatsScreen(::navigateUp, ::navigate) } + composable { + NetworkStatsScreen(vm.chosenPackage, ::choosePackage, ::navigateUp, ::navigate) + } composable(mapOf(serializableNavTypePair>())) { NetworkStatsViewerScreen(it.toRoute(), ::navigateUp) } composable { PrivateDnsScreen(::navigateUp) } - composable { AlwaysOnVpnPackageScreen(::navigateUp) } + composable { + AlwaysOnVpnPackageScreen(vm.chosenPackage, ::choosePackage, ::navigateUp) + } composable { RecommendedGlobalProxyScreen(::navigateUp) } composable { NetworkLoggingScreen(::navigateUp) } composable { WifiAuthKeypairScreen(::navigateUp) } @@ -368,14 +372,25 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { DeleteWorkProfileScreen(::navigateUp) } composable { - AppChooserScreen(it.toRoute(), { dest -> - if(dest == null) navigateUp() else navigate(ApplicationDetails(dest)) + val canSwitchView = (it.toRoute() as ApplicationsList).canSwitchView + AppChooserScreen( + canSwitchView, vm.installedPackages, vm.refreshPackagesProgress, { name -> + if (canSwitchView) { + if (name == null) { + navigateUp() + } else { + navigate(ApplicationDetails(name)) + } + } else { + if (name != null) vm.chosenPackage.trySend(name) + navigateUp() + } }, { SP.applicationsListView = false navController.navigate(ApplicationsFeatures) { popUpTo(Home) } - }) + }, vm::refreshPackageList) } composable { ApplicationsFeaturesScreen(::navigateUp, ::navigate) { @@ -385,24 +400,78 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } } } - composable { ApplicationDetailsScreen(it.toRoute(), ::navigateUp, ::navigate) } - composable { SuspendScreen(::navigateUp) } - composable { HideScreen(::navigateUp) } - composable { BlockUninstallScreen(::navigateUp) } - composable { DisableUserControlScreen(::navigateUp) } - composable { PermissionsManagerScreen(::navigateUp, it.toRoute()) } - composable { DisableMeteredDataScreen(::navigateUp) } - composable { ClearAppStorageScreen(::navigateUp) } - composable { UninstallAppScreen(::navigateUp) } - composable { KeepUninstalledPackagesScreen(::navigateUp) } - composable { InstallExistingAppScreen(::navigateUp) } - composable { CrossProfilePackagesScreen(::navigateUp) } - composable { CrossProfileWidgetProvidersScreen(::navigateUp) } - composable { CredentialManagerPolicyScreen(::navigateUp) } - composable { PermittedAccessibilityServicesScreen(::navigateUp) } - composable { PermittedInputMethodsScreen(::navigateUp) } - composable { EnableSystemAppScreen(::navigateUp) } - composable { SetDefaultDialerScreen(::navigateUp) } + composable { + ApplicationDetailsScreen(it.toRoute(), vm, ::navigateUp, ::navigate) + } + composable { + PackageFunctionScreen(R.string.suspend, vm.suspendedPackages, vm::getSuspendedPackaged, + vm::setPackageSuspended, ::navigateUp, vm.chosenPackage, ::choosePackage, + R.string.info_suspend_app) + } + composable { + PackageFunctionScreen(R.string.hide, vm.hiddenPackages, vm::getHiddenPackages, + vm::setPackageHidden, ::navigateUp, vm.chosenPackage, ::choosePackage) + } + composable { + PackageFunctionScreenWithoutResult(R.string.block_uninstall, vm.ubPackages, + vm::getUbPackages, vm::setPackageUb, ::navigateUp, vm.chosenPackage, ::choosePackage) + } + composable { + PackageFunctionScreenWithoutResult(R.string.disable_user_control, vm.ucdPackages, + vm::getUcdPackages, vm::setPackageUcd, ::navigateUp, vm.chosenPackage, + ::choosePackage, R.string.info_disable_user_control) + } + composable { + PermissionsManagerScreen(vm.packagePermissions, vm::getPackagePermissions, + vm::setPackagePermission, ::navigateUp, it.toRoute(), vm.chosenPackage, ::choosePackage) + } + composable { + PackageFunctionScreen(R.string.disable_metered_data, vm.mddPackages, + vm::getMddPackages, vm::setPackageMdd, ::navigateUp, vm.chosenPackage, ::choosePackage) + } + composable { + ClearAppStorageScreen(vm.chosenPackage, ::choosePackage, vm::clearAppData, ::navigateUp) + } + composable { + UninstallAppScreen(vm.chosenPackage, ::choosePackage, vm::uninstallPackage, ::navigateUp) + } + composable { + PackageFunctionScreenWithoutResult(R.string.keep_uninstalled_packages, vm.kuPackages, + vm::getKuPackages, vm::setPackageKu, ::navigateUp, vm.chosenPackage, + ::choosePackage, R.string.info_keep_uninstalled_apps) + } + composable { + InstallExistingAppScreen(vm.chosenPackage, ::choosePackage, + vm::installExistingApp, ::navigateUp) + } + composable { + PackageFunctionScreenWithoutResult(R.string.cross_profile_apps, vm.cpPackages, + vm::getCpPackages, vm::setPackageCp, ::navigateUp, vm.chosenPackage, ::choosePackage) + } + composable { + PackageFunctionScreen(R.string.cross_profile_widget, vm.cpwProviders, + vm::getCpwProviders, vm::setCpwProvider, ::navigateUp, vm.chosenPackage, ::choosePackage) + } + composable { + CredentialManagerPolicyScreen(vm.chosenPackage, ::choosePackage, + vm.cmPackages, vm::getCmPolicy, vm::setCmPackage, vm::setCmPolicy, ::navigateUp) + } + composable { + PermittedAsAndImPackages(R.string.permitted_accessibility_services, + R.string.system_accessibility_always_allowed, vm.chosenPackage, ::choosePackage, + vm.pasPackages, vm::getPasPackages, vm::setPasPackage, vm::setPasPolicy, ::navigateUp) + } + composable { + PermittedAsAndImPackages(R.string.permitted_ime, R.string.system_ime_always_allowed, + vm.chosenPackage, ::choosePackage, vm.pimPackages, vm::getPimPackages, + vm::setPimPackage, vm::setPimPolicy, ::navigateUp) + } + composable { + EnableSystemAppScreen(vm.chosenPackage, ::choosePackage, vm::enableSystemApp, ::navigateUp) + } + composable { + SetDefaultDialerScreen(vm.chosenPackage, ::choosePackage, vm::setDefaultDialer, ::navigateUp) + } composable { UserRestrictionScreen(::navigateUp) { @@ -497,7 +566,10 @@ private fun HomeScreen(onNavigate: (Any) -> Unit) { }, contentWindowInsets = WindowInsets.ime ) { - Column(Modifier.fillMaxSize().padding(it).verticalScroll(rememberScrollState())) { + Column(Modifier + .fillMaxSize() + .padding(it) + .verticalScroll(rememberScrollState())) { if(privilege.device || privilege.profile) { HomePageItem(R.string.system, R.drawable.android_fill0) { onNavigate(SystemManager) } HomePageItem(R.string.network, R.drawable.wifi_fill0) { onNavigate(Network) } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index db9be42..3ea5e76 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,10 +1,41 @@ package com.bintianqi.owndroid import android.app.Application +import android.app.PendingIntent +import android.app.admin.PackagePolicy +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.Privilege.DAR +import com.bintianqi.owndroid.Privilege.DPM +import com.bintianqi.owndroid.dpm.AppStatus +import com.bintianqi.owndroid.dpm.getPackageInstaller +import com.bintianqi.owndroid.dpm.isValidPackageName +import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage +import com.bintianqi.owndroid.dpm.permissionList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.Executors class MyViewModel(application: Application): AndroidViewModel(application) { + val PM = application.packageManager val theme = MutableStateFlow(ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme)) fun changeTheme(newTheme: ThemeSettings) { theme.value = newTheme @@ -12,6 +43,340 @@ class MyViewModel(application: Application): AndroidViewModel(application) { SP.darkTheme = newTheme.darkTheme SP.blackTheme = newTheme.blackTheme } + + val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST) + + val installedPackages = MutableStateFlow(emptyList()) + val refreshPackagesProgress = MutableStateFlow(0F) + fun refreshPackageList() { + viewModelScope.launch(Dispatchers.IO) { + installedPackages.value = emptyList() + val apps = PM.getInstalledApplications(getInstalledAppsFlags) + apps.forEachIndexed { index, info -> + installedPackages.update { + it + getAppInfo(info) + } + refreshPackagesProgress.value = (index + 1).toFloat() / apps.size + } + } + } + fun getAppInfo(info: ApplicationInfo) = + AppInfo(info.packageName, info.loadLabel(PM).toString(), info.loadIcon(PM), info.flags) + fun getAppInfo(name: String): AppInfo { + return try { + getAppInfo(PM.getApplicationInfo(name, getInstalledAppsFlags)) + } catch (_: PackageManager.NameNotFoundException) { + AppInfo(name, "???", Color.Transparent.toArgb().toDrawable(), 0) + } + } + + val suspendedPackages = MutableStateFlow(emptyList()) + @RequiresApi(24) + fun getSuspendedPackaged() { + val packages = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isPackageSuspended(DAR, it.packageName) + } + suspendedPackages.value = packages.map { getAppInfo(it) } + } + @RequiresApi(24) + fun setPackageSuspended(name: String, status: Boolean): Boolean { + val result = DPM.setPackagesSuspended(DAR, arrayOf(name), status) + getSuspendedPackaged() + return result.isEmpty() + } + + val hiddenPackages = MutableStateFlow(emptyList()) + fun getHiddenPackages() { + viewModelScope.launch { + hiddenPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isApplicationHidden(DAR, it.packageName) + }.map { getAppInfo(it) } + } + } + fun setPackageHidden(name: String, status: Boolean): Boolean { + val result = DPM.setApplicationHidden(DAR, name, status) + getHiddenPackages() + return result + } + + // Uninstall blocked packages + val ubPackages = MutableStateFlow(emptyList()) + fun getUbPackages() { + viewModelScope.launch { + ubPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isUninstallBlocked(DAR, it.packageName) + }.map { getAppInfo(it) } + } + } + fun setPackageUb(name: String, status: Boolean) { + DPM.setUninstallBlocked(DAR, name, status) + getUbPackages() + } + + // User control disabled packages + val ucdPackages = MutableStateFlow(emptyList()) + @RequiresApi(30) + fun getUcdPackages() { + ucdPackages.value = DPM.getUserControlDisabledPackages(DAR).map { + getAppInfo(it) + } + } + @RequiresApi(30) + fun setPackageUcd(name: String, status: Boolean) { + DPM.setUserControlDisabledPackages( + DAR, + ucdPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + ) + getUcdPackages() + } + + val packagePermissions = MutableStateFlow(emptyMap()) + @RequiresApi(23) + fun getPackagePermissions(name: String) { + if (name.isValidPackageName) { + packagePermissions.value = permissionList().associate { + it.permission to DPM.getPermissionGrantState(DAR, name, it.permission) + } + } else { + packagePermissions.value = emptyMap() + } + } + @RequiresApi(23) + fun setPackagePermission(name: String, permission: String, status: Int): Boolean { + val result = DPM.setPermissionGrantState(DAR, name, permission, status) + getPackagePermissions(name) + return result + } + + // Metered data disabled packages + val mddPackages = MutableStateFlow(emptyList()) + @RequiresApi(28) + fun getMddPackages() { + mddPackages.value = DPM.getMeteredDataDisabledPackages(DAR).map { getAppInfo(it) } + } + @RequiresApi(28) + fun setPackageMdd(name: String, status: Boolean): Boolean { + val result = DPM.setMeteredDataDisabledPackages( + DAR, mddPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + ) + getMddPackages() + return result.isEmpty() + } + + // Keep uninstalled packages + val kuPackages = MutableStateFlow(emptyList()) + @RequiresApi(28) + fun getKuPackages() { + kuPackages.value = DPM.getKeepUninstalledPackages(DAR)?.map { getAppInfo(it) } ?: emptyList() + } + @RequiresApi(28) + fun setPackageKu(name: String, status: Boolean) { + DPM.setKeepUninstalledPackages( + DAR, kuPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + ) + getKuPackages() + } + + // Cross profile packages + val cpPackages = MutableStateFlow(emptyList()) + @RequiresApi(30) + fun getCpPackages() { + cpPackages.value = DPM.getCrossProfilePackages(DAR).map { getAppInfo(it) } + } + @RequiresApi(30) + fun setPackageCp(name: String, status: Boolean) { + DPM.setCrossProfilePackages( + DAR, + cpPackages.value.map { it.name }.toSet().run { if (status) plus(name) else minus(name) } + ) + getCpPackages() + } + + // Cross-profile widget providers + val cpwProviders = MutableStateFlow(emptyList()) + fun getCpwProviders() { + cpwProviders.value = DPM.getCrossProfileWidgetProviders(DAR).map { getAppInfo(it) } + } + fun setCpwProvider(name: String, status: Boolean): Boolean { + val result = if (status) { + DPM.addCrossProfileWidgetProvider(DAR, name) + } else { + DPM.removeCrossProfileWidgetProvider(DAR, name) + } + getCpwProviders() + return result + } + + @RequiresApi(28) + fun clearAppData(name: String, callback: (Boolean) -> Unit) { + DPM.clearApplicationUserData(DAR, name, Executors.newSingleThreadExecutor()) { _, result -> + callback(result) + } + } + + fun uninstallPackage(packageName: String, onComplete: (String?) -> Unit) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @SuppressWarnings("UnsafeIntentLaunch") + context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) + } else { + context.unregisterReceiver(this) + if(statusExtra == PackageInstaller.STATUS_SUCCESS) { + onComplete(null) + } else { + onComplete(parsePackageInstallerMessage(context, intent)) + } + } + } + } + ContextCompat.registerReceiver( + application, receiver, IntentFilter(AppInstallerViewModel.ACTION), null, + null, ContextCompat.RECEIVER_EXPORTED + ) + val pi = if(VERSION.SDK_INT >= 34) { + PendingIntent.getBroadcast( + application, 0, Intent(AppInstallerViewModel.ACTION), + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE + ).intentSender + } else { + PendingIntent.getBroadcast(application, 0, Intent(AppInstallerViewModel.ACTION), PendingIntent.FLAG_MUTABLE).intentSender + } + application.getPackageInstaller().uninstall(packageName, pi) + } + + @RequiresApi(28) + fun installExistingApp(name: String): Boolean { + return DPM.installExistingPackage(DAR, name) + } + + // Credential manager policy + val cmPackages = MutableStateFlow(emptyList()) + @RequiresApi(34) + fun getCmPolicy(): Int { + return DPM.credentialManagerPolicy?.let { policy -> + cmPackages.value = policy.packageNames.map { getAppInfo(it) } + policy.policyType + } ?: -1 + } + fun setCmPackage(name: String, status: Boolean) { + cmPackages.update { list -> + if (status) list + getAppInfo(name) else list.dropWhile { it.name == name } + } + } + @RequiresApi(34) + fun setCmPolicy(type: Int) { + DPM.credentialManagerPolicy = if (type != -1 && cmPackages.value.isNotEmpty()) { + PackagePolicy(type, cmPackages.value.map { it.name }.toSet()) + } else null + getCmPolicy() + } + + // Permitted input method + val pimPackages = MutableStateFlow(emptyList()) + fun getPimPackages(): Boolean { + return DPM.getPermittedInputMethods(DAR).let { packages -> + pimPackages.value = packages?.map { getAppInfo(it) } ?: emptyList() + packages == null + } + } + fun setPimPackage(name: String, status: Boolean) { + pimPackages.update { packages -> + if (status) packages + getAppInfo(name) else packages.dropWhile { it.name == name } + } + } + fun setPimPolicy(allowAll: Boolean): Boolean { + val result = DPM.setPermittedInputMethods( + DAR, if (allowAll) null else pimPackages.value.map { it.name }) + getPimPackages() + return result + } + + // Permitted accessibility services + val pasPackages = MutableStateFlow(emptyList()) + fun getPasPackages(): Boolean { + return DPM.getPermittedAccessibilityServices(DAR).let { packages -> + pasPackages.value = packages?.map { getAppInfo(it) } ?: emptyList() + packages == null + } + } + fun setPasPackage(name: String, status: Boolean) { + pasPackages.update { packages -> + if (status) packages + getAppInfo(name) else packages.dropWhile { it.name == name } + } + } + fun setPasPolicy(allowAll: Boolean): Boolean { + val result = DPM.setPermittedAccessibilityServices( + DAR, if (allowAll) null else pasPackages.value.map { it.name }) + getPasPackages() + return result + } + + fun enableSystemApp(name: String) { + DPM.enableSystemApp(DAR, name) + } + + val appStatus = MutableStateFlow(AppStatus(false, false, false, false, false, false)) + fun getAppStatus(name: String) { + appStatus.value = AppStatus( + if (VERSION.SDK_INT >= 24) DPM.isPackageSuspended(DAR, name) else false, + DPM.isApplicationHidden(DAR, name), + DPM.isUninstallBlocked(DAR, name), + if (VERSION.SDK_INT >= 30) name in DPM.getUserControlDisabledPackages(DAR) else false, + if (VERSION.SDK_INT >= 28) name in DPM.getMeteredDataDisabledPackages(DAR) else false, + if (VERSION.SDK_INT >= 28) DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true else false + ) + } + // Application details + @RequiresApi(24) + fun adSetPackageSuspended(name: String, status: Boolean) { + DPM.setPackagesSuspended(DAR, arrayOf(name), status) + appStatus.update { it.copy(suspend = DPM.isPackageSuspended(DAR, name)) } + } + fun adSetPackageHidden(name: String, status: Boolean) { + DPM.setApplicationHidden(DAR, name, status) + appStatus.update { it.copy(hide = DPM.isApplicationHidden(DAR, name)) } + } + fun adSetPackageUb(name: String, status: Boolean) { + DPM.setUninstallBlocked(DAR, name, status) + appStatus.update { it.copy(uninstallBlocked = DPM.isUninstallBlocked(DAR, name)) } + } + @RequiresApi(30) + fun adSetPackageUcd(name: String, status: Boolean) { + DPM.setUserControlDisabledPackages(DAR, + DPM.getUserControlDisabledPackages(DAR).run { if (status) plus(name) else minus(name) }) + appStatus.update { + it.copy(userControlDisabled = name in DPM.getUserControlDisabledPackages(DAR)) + } + } + @RequiresApi(28) + fun adSetPackageMdd(name: String, status: Boolean) { + DPM.setMeteredDataDisabledPackages(DAR, + DPM.getMeteredDataDisabledPackages(DAR).run { if (status) plus(name) else minus(name) }) + appStatus.update { + it.copy(meteredDataDisabled = name in DPM.getMeteredDataDisabledPackages(DAR)) + } + } + @RequiresApi(28) + fun adSetPackageKu(name: String, status: Boolean) { + DPM.setKeepUninstalledPackages(DAR, + DPM.getKeepUninstalledPackages(DAR)?.run { if (status) plus(name) else minus(name) } ?: emptyList()) + appStatus.update { + it.copy(keepUninstalled = DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true ) + } + } + + @RequiresApi(34) + fun setDefaultDialer(name: String): Boolean { + return try { + DPM.setDefaultDialerApplication(name) + true + } catch (e: IllegalArgumentException) { + e.printStackTrace() + false + } + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt index c63cf79..10c95ed 100644 --- a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt +++ b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt @@ -1,16 +1,9 @@ package com.bintianqi.owndroid -import android.content.Context -import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.os.Build -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -44,10 +37,8 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -62,35 +53,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ui.theme.OwnDroidTheme import com.google.accompanist.drawablepainter.rememberDrawablePainter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -class PackageChooserActivity: ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val vm by viewModels() - enableEdgeToEdge() - setContent { - val theme by vm.theme.collectAsStateWithLifecycle() - OwnDroidTheme(theme) { - AppChooserScreen(ApplicationsList(false), { - setResult(0, Intent().putExtra("package", it)) - finish() - }, {}) - } - } - } -} - -val installedApps = MutableStateFlow(emptyList()) - data class AppInfo( val name: String, val label: String, @@ -105,11 +71,14 @@ private fun searchInString(query: String, content: String) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Unit, onSwitchView: () -> Unit) { - val packages by installedApps.collectAsStateWithLifecycle() - val coroutine = rememberCoroutineScope() +fun AppChooserScreen( + canSwitchView: Boolean, packageList: MutableStateFlow>, + refreshProgress: MutableStateFlow, onChoosePackage: (String?) -> Unit, + onSwitchView: () -> Unit, onRefresh: () -> Unit +) { + val packages by packageList.collectAsStateWithLifecycle() val context = LocalContext.current - var progress by remember { mutableFloatStateOf(1F) } + val progress by refreshProgress.collectAsStateWithLifecycle() var system by rememberSaveable { mutableStateOf(false) } var query by rememberSaveable { mutableStateOf("") } var searchMode by rememberSaveable { mutableStateOf(false) } @@ -119,7 +88,7 @@ fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Uni } val focusMgr = LocalFocusManager.current LaunchedEffect(Unit) { - if(packages.size <= 1) getInstalledApps(coroutine, context) { progress = it } + if(packages.size <= 1) onRefresh() } Scaffold( topBar = { @@ -135,20 +104,17 @@ fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Uni }) { Icon(painter = painterResource(R.drawable.filter_alt_fill0), contentDescription = null) } - IconButton( - { getInstalledApps(coroutine, context) { progress = it } }, - enabled = progress == 1F - ) { + IconButton(onRefresh, enabled = progress == 1F) { Icon(painter = painterResource(R.drawable.refresh_fill0), contentDescription = null) } - if(params.canSwitchView) IconButton(onSwitchView) { + if (canSwitchView) IconButton(onSwitchView) { Icon(Icons.AutoMirrored.Default.List, null) } } }, title = { if(searchMode) { - val fr = FocusRequester() + val fr = remember { FocusRequester() } LaunchedEffect(Unit) { fr.requestFocus() } OutlinedTextField( value = query, @@ -213,23 +179,5 @@ fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Uni } } -fun getInstalledApps(scope: CoroutineScope, context: Context, onProgressUpdated: (Float) -> Unit) { - installedApps.value = emptyList() - scope.launch(Dispatchers.IO) { - val pm = context.packageManager - val apps = pm.getInstalledApplications(getInstalledAppsFlags) - for(pkg in apps) { - val label = pkg.loadLabel(pm).toString() - val icon = pkg.loadIcon(pm) - withContext(Dispatchers.Main) { - installedApps.update { - it + AppInfo(pkg.packageName, label, icon, pkg.flags) - } - onProgressUpdated(installedApps.value.size.toFloat() / apps.size) - } - } - } -} - val getInstalledAppsFlags = if(Build.VERSION.SDK_INT >= 24) PackageManager.MATCH_DISABLED_COMPONENTS or PackageManager.MATCH_UNINSTALLED_PACKAGES else 0 diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/Settings.kt index 6eb883b..530bccd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Settings.kt @@ -233,28 +233,38 @@ fun AppLockSettingsScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.app_lo var confirmPassword by remember { mutableStateOf("") } var allowBiometrics by remember { mutableStateOf(SP.biometricsUnlock) } var lockWhenLeaving by remember { mutableStateOf(SP.lockWhenLeaving) } - val fr = FocusRequester() + val fr = remember { FocusRequester() } val alreadySet = !SP.lockPasswordHash.isNullOrEmpty() val isInputLegal = password.length !in 1..3 && (alreadySet || (password.isNotEmpty() && password.isNotBlank())) - Column(Modifier.widthIn(max = 300.dp).align(Alignment.CenterHorizontally)) { + Column(Modifier + .widthIn(max = 300.dp) + .align(Alignment.CenterHorizontally)) { OutlinedTextField( - password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + password, { password = it }, Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), label = { Text(stringResource(R.string.password)) }, supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), keyboardActions = KeyboardActions { fr.requestFocus() } ) OutlinedTextField( - confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth().focusRequester(fr), + confirmPassword, { confirmPassword = it }, Modifier + .fillMaxWidth() + .focusRequester(fr), label = { Text(stringResource(R.string.confirm_password)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), keyboardActions = KeyboardActions { fm.clearFocus() } ) - if(VERSION.SDK_INT >= 28) Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { + if(VERSION.SDK_INT >= 28) Row(Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { Text(stringResource(R.string.allow_biometrics)) Switch(allowBiometrics, { allowBiometrics = it }) } - Row(Modifier.fillMaxWidth().padding(bottom = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Row(Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { Text(stringResource(R.string.lock_when_leaving)) Switch(lockWhenLeaving, { lockWhenLeaving = it }) } @@ -302,7 +312,9 @@ fun ApiSettings(onNavigateUp: () -> Unit) { var key by remember { mutableStateOf("") } OutlinedTextField( value = key, onValueChange = { key = it }, label = { Text(stringResource(R.string.api_key)) }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), readOnly = true, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), readOnly = true, trailingIcon = { IconButton( onClick = { @@ -316,7 +328,9 @@ fun ApiSettings(onNavigateUp: () -> Unit) { } ) Button( - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), onClick = { SP.apiKey = key context.showOperationResultToast(true) diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index a19ee32..14fdfff 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -4,13 +4,11 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.ComponentName import android.content.Context -import android.content.Intent import android.content.pm.PackageInfo import android.net.Uri import android.os.Build import android.os.Bundle import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.compose.runtime.saveable.Saver @@ -107,13 +105,6 @@ inline fun serializableNavTypePair() = Json.encodeToString(value) } -class ChoosePackageContract: ActivityResultContract() { - override fun createIntent(context: Context, input: Nothing?): Intent = - Intent(context, PackageChooserActivity::class.java) - override fun parseResult(resultCode: Int, intent: Intent?): String? = - intent?.getStringExtra("package") -} - fun exportLogs(context: Context, uri: Uri) { context.contentResolver.openOutputStream(uri)?.use { output -> val proc = Runtime.getRuntime().exec("logcat -d") diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index f75fe78..2878e55 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -1,20 +1,12 @@ package com.bintianqi.owndroid.dpm -import android.app.PendingIntent import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED import android.app.admin.PackagePolicy -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInstaller -import android.content.pm.PackageManager import android.os.Build.VERSION import android.os.Looper -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -26,10 +18,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -60,8 +52,6 @@ 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.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -70,7 +60,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -79,20 +68,15 @@ 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.core.content.ContextCompat -import androidx.core.graphics.drawable.toDrawable +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.AppInfo import com.bintianqi.owndroid.AppInstallerActivity -import com.bintianqi.owndroid.AppInstallerViewModel -import com.bintianqi.owndroid.ChoosePackageContract import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.getInstalledAppsFlags -import com.bintianqi.owndroid.installedApps import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.ErrorDialog import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyLazyScaffold @@ -102,19 +86,9 @@ import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.Serializable -import java.util.concurrent.Executors - -fun PackageManager.retrieveAppInfo(packageName: String): AppInfo { - return try { - getApplicationInfo(packageName, getInstalledAppsFlags).retrieveAppInfo(this) - } catch (_: PackageManager.NameNotFoundException) { - AppInfo(packageName, "???", Color.Transparent.toArgb().toDrawable(), 0) - } -} - -fun ApplicationInfo.retrieveAppInfo(pm: PackageManager) = - installedApps.value.find { it.name == packageName } ?: AppInfo(packageName, loadLabel(pm).toString(), loadIcon(pm), flags) val String.isValidPackageName get() = Regex("""^(?:[a-zA-Z]\w*\.)+[a-zA-Z]\w*$""").matches(this) @@ -142,18 +116,16 @@ fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) { } @Composable -fun PackageNameTextField(value: String, modifier: Modifier = Modifier, onValueChange: (String) -> Unit) { - val launcher = rememberLauncherForActivityResult(ChoosePackageContract()) { - if(it != null) onValueChange(it) - } +fun PackageNameTextField( + value: String, onChoosePackage: () -> Unit, + modifier: Modifier = Modifier, onValueChange: (String) -> Unit +) { val fm = LocalFocusManager.current OutlinedTextField( value, onValueChange, Modifier.fillMaxWidth().then(modifier), label = { Text(stringResource(R.string.package_name)) }, trailingIcon = { - IconButton({ - launcher.launch(null) - }) { + IconButton(onChoosePackage) { Icon(Icons.AutoMirrored.Default.List, null) } }, @@ -243,258 +215,102 @@ fun ApplicationsFeaturesScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Un @Serializable data class ApplicationDetails(val packageName: String) +data class AppStatus( + val suspend: Boolean, + val hide: Boolean, + val uninstallBlocked: Boolean, + val userControlDisabled: Boolean, + val meteredDataDisabled: Boolean, + val keepUninstalled: Boolean +) + @Composable -fun ApplicationDetailsScreen(param: ApplicationDetails, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun ApplicationDetailsScreen( + param: ApplicationDetails, vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit +) { val packageName = param.packageName - val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - val pm = context.packageManager var dialog by remember { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall - val info = pm.getApplicationInfo(packageName, getInstalledAppsFlags) + val info = vm.getAppInfo(packageName) + val status by vm.appStatus.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.getAppStatus(packageName) } MySmallTitleScaffold(R.string.place_holder, onNavigateUp, 0.dp) { Column(Modifier.align(Alignment.CenterHorizontally).padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Image(rememberDrawablePainter(info.loadIcon(pm)), null, Modifier.size(50.dp)) - Text(info.loadLabel(pm).toString(), Modifier.padding(top = 4.dp)) - Text(info.packageName, Modifier.alpha(0.7F).padding(bottom = 8.dp), style = typography.bodyMedium) + Image(rememberDrawablePainter(info.icon), null, Modifier.size(50.dp)) + Text(info.label, Modifier.padding(top = 4.dp)) + Text(info.name, Modifier.alpha(0.7F).padding(bottom = 8.dp), style = typography.bodyMedium) } FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) { onNavigate(PermissionsManager(packageName)) } if(VERSION.SDK_INT >= 24) SwitchItem( - R.string.suspend, icon = R.drawable.block_fill0, - getState = { Privilege.DPM.isPackageSuspended(Privilege.DAR, packageName) }, - onCheckedChange = { Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(packageName), it) } + R.string.suspend, icon = R.drawable.block_fill0, state = status.suspend, + onCheckedChange = { vm.adSetPackageSuspended(packageName, it) } ) SwitchItem( R.string.hide, icon = R.drawable.visibility_off_fill0, - getState = { Privilege.DPM.isApplicationHidden(Privilege.DAR, packageName) }, - onCheckedChange = { Privilege.DPM.setApplicationHidden(Privilege.DAR, packageName, it) } + state = status.hide, + onCheckedChange = { vm.adSetPackageHidden(packageName, it) } ) SwitchItem( R.string.block_uninstall, icon = R.drawable.delete_forever_fill0, - getState = { Privilege.DPM.isUninstallBlocked(Privilege.DAR, packageName) }, - onCheckedChange = { Privilege.DPM.setUninstallBlocked(Privilege.DAR, packageName, it) } + state = status.uninstallBlocked, + onCheckedChange = { vm.adSetPackageUb(packageName, it) } ) if(VERSION.SDK_INT >= 30) SwitchItem( R.string.disable_user_control, icon = R.drawable.do_not_touch_fill0, - getState = { packageName in Privilege.DPM.getUserControlDisabledPackages(Privilege.DAR) }, - onCheckedChange = { state -> - Privilege.DPM.setUserControlDisabledPackages(Privilege.DAR, - Privilege.DPM.getUserControlDisabledPackages(Privilege.DAR).let { if(state) it.plus(packageName) else it.minus(packageName) } - ) - } + state = status.userControlDisabled, + onCheckedChange = { vm.adSetPackageUcd(packageName, it) } ) if(VERSION.SDK_INT >= 28) SwitchItem( R.string.disable_metered_data, icon = R.drawable.money_off_fill0, - getState = { packageName in Privilege.DPM.getMeteredDataDisabledPackages(Privilege.DAR) }, - onCheckedChange = { state -> - Privilege.DPM.setMeteredDataDisabledPackages(Privilege.DAR, - Privilege.DPM.getMeteredDataDisabledPackages(Privilege.DAR).let { if(state) it.plus(packageName) else it.minus(packageName) } - ) - } + state = status.meteredDataDisabled, + onCheckedChange = { vm.adSetPackageMdd(packageName, it) } ) if(privilege.device && VERSION.SDK_INT >= 28) SwitchItem( R.string.keep_after_uninstall, icon = R.drawable.delete_fill0, - getState = { Privilege.DPM.getKeepUninstalledPackages(Privilege.DAR)?.contains(packageName) == true }, - onCheckedChange = { state -> - Privilege.DPM.setKeepUninstalledPackages(Privilege.DAR, - Privilege.DPM.getKeepUninstalledPackages(Privilege.DAR)?.let { if(state) it.plus(packageName) else it.minus(packageName) } ?: listOf(packageName) - ) - } + state = status.keepUninstalled, + onCheckedChange = { vm.adSetPackageKu(packageName, it) } ) if(VERSION.SDK_INT >= 28) FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { dialog = 1 } FunctionItem(R.string.uninstall, icon = R.drawable.delete_fill0) { dialog = 2 } } - if(dialog == 1 && VERSION.SDK_INT >= 28) ClearAppStorageDialog(packageName) { dialog = 0 } - if(dialog == 2) UninstallAppDialog(packageName) { dialog = 0 } + if(dialog == 1 && VERSION.SDK_INT >= 28) + ClearAppStorageDialog(packageName, vm::clearAppData) { dialog = 0 } + if(dialog == 2) UninstallAppDialog(packageName, vm::uninstallPackage) { dialog = 0 } } @Serializable object Suspend -@RequiresApi(24) -@Composable -fun SuspendScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - pm.getInstalledApplications(getInstalledAppsFlags).filter { - Privilege.DPM.isPackageSuspended(Privilege.DAR, it.packageName) - }.forEach { - packages += it.retrieveAppInfo(pm) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.suspend, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(it.name), false) - refresh() - } - } - item { - Column(Modifier.padding(horizontal = HorizontalPadding)) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - if(Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(packageName), true).isEmpty()) packageName = "" - else context.showOperationResultToast(false) - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.suspend)) - } - Notes(R.string.info_suspend_app) - } - } - } -} - @Serializable object Hide -@Composable -fun HideScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - pm.getInstalledApplications(getInstalledAppsFlags).filter { Privilege.DPM.isApplicationHidden(Privilege.DAR, it.packageName) }.forEach { - packages += it.retrieveAppInfo(pm) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.hide, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.setApplicationHidden(Privilege.DAR, it.name, false) - refresh() - } - } - item { - Column(Modifier.padding(horizontal = HorizontalPadding)) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - if(Privilege.DPM.setApplicationHidden(Privilege.DAR, packageName, true)) packageName = "" - else context.showOperationResultToast(false) - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.hide)) - } - } - } - } -} - @Serializable object BlockUninstall -@Composable -fun BlockUninstallScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - pm.getInstalledApplications(getInstalledAppsFlags).filter { Privilege.DPM.isUninstallBlocked(Privilege.DAR, it.packageName) }.forEach { - packages += it.retrieveAppInfo(pm) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.block_uninstall, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.setUninstallBlocked(Privilege.DAR, it.name, false) - refresh() - } - } - item { - Column(Modifier.padding(horizontal = HorizontalPadding)) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - Privilege.DPM.setUninstallBlocked(Privilege.DAR, packageName, true) - packageName = "" - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.block_uninstall)) - } - } - } - } -} - @Serializable object DisableUserControl -@RequiresApi(30) -@Composable -fun DisableUserControlScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getUserControlDisabledPackages(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.disable_user_control, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setUserControlDisabledPackages(Privilege.DAR, packages.minus(info).map { it.name }) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - Privilege.DPM.setUserControlDisabledPackages(Privilege.DAR, packages.map { it.name } + packageName) - refresh() - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 8.dp), - ) { - Text(stringResource(R.string.add)) - } - Notes(R.string.info_disable_user_control, HorizontalPadding) - } - } -} - @Serializable data class PermissionsManager(val packageName: String? = null) @RequiresApi(23) @Composable -fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager) { +fun PermissionsManagerScreen( + packagePermissions: MutableStateFlow>, getPackagePermissions: (String) -> Unit, + setPackagePermission: (String, String, Int) -> Boolean, onNavigateUp: () -> Unit, + param: PermissionsManager, chosenPackage: Channel, onChoosePackage: () -> Unit +) { val packageNameParam = param.packageName - val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() var packageName by remember { mutableStateOf(packageNameParam ?: "") } var selectedPermission by remember { mutableStateOf(null) } - val statusMap = remember { mutableStateMapOf() } + val permissions by packagePermissions.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } LaunchedEffect(packageName) { - if(packageName.isValidPackageName) { - permissionList().forEach { statusMap[it.permission] = Privilege.DPM.getPermissionGrantState(Privilege.DAR, packageName, it.permission) } - } else { - statusMap.clear() - } + getPackagePermissions(packageName) } MyLazyScaffold(R.string.permissions, onNavigateUp) { item { if(packageNameParam == null) { - PackageNameTextField(packageName, Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } Spacer(Modifier.padding(vertical = 4.dp)) } } @@ -510,7 +326,7 @@ fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager ) { Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp)) Column { - val state = when(statusMap[it.permission]) { + val state = when(permissions[it.permission]) { PERMISSION_GRANT_STATE_DEFAULT -> R.string.default_stringres PERMISSION_GRANT_STATE_GRANTED -> R.string.granted PERMISSION_GRANT_STATE_DENIED -> R.string.denied @@ -527,14 +343,12 @@ fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager } if(selectedPermission != null) { fun changeState(state: Int) { - val result = Privilege.DPM.setPermissionGrantState(Privilege.DAR, packageName, selectedPermission!!.permission, state) - if (!result) context.showOperationResultToast(false) - statusMap[selectedPermission!!.permission] = Privilege.DPM.getPermissionGrantState(Privilege.DAR, packageName, selectedPermission!!.permission) - selectedPermission = null + val result = setPackagePermission(packageName, selectedPermission!!.permission, state) + if (result) selectedPermission = null } @Composable fun GrantPermissionItem(label: Int, status: Int) { - val selected = statusMap[selectedPermission!!.permission] == status + val selected = permissions[selectedPermission!!.permission] == status Row( Modifier .fillMaxWidth() @@ -569,56 +383,22 @@ fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager @Serializable object DisableMeteredData -@RequiresApi(28) -@Composable -fun DisableMeteredDataScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getMeteredDataDisabledPackages(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.disable_metered_data, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setMeteredDataDisabledPackages(Privilege.DAR, packages.minus(info).map { it.name }) - refresh() - } - } - item { - PackageNameTextField(packageName, Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } - Button( - { - if(Privilege.DPM.setMeteredDataDisabledPackages(Privilege.DAR, packages.map { it.name } + packageName).isEmpty()) { - packageName = "" - } else { - context.showOperationResultToast(false) - } - refresh() - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - } - } -} - @Serializable object ClearAppStorage @RequiresApi(28) @Composable -fun ClearAppStorageScreen(onNavigateUp: () -> Unit) { +fun ClearAppStorageScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onClear: (String, (Boolean) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { var dialog by remember { mutableStateOf(false) } var packageName by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.clear_app_storage, onNavigateUp) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { dialog = true }, Modifier.fillMaxWidth(), @@ -627,12 +407,14 @@ fun ClearAppStorageScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.clear)) } } - if(dialog) ClearAppStorageDialog(packageName) { dialog = false } + if(dialog) ClearAppStorageDialog(packageName, onClear) { dialog = false } } @RequiresApi(28) @Composable -private fun ClearAppStorageDialog(packageName: String, onClose: () -> Unit) { +private fun ClearAppStorageDialog( + packageName: String, onClear: (String, (Boolean) -> Unit) -> Unit, onClose: () -> Unit +) { val context = LocalContext.current var clearing by remember { mutableStateOf(false) } AlertDialog( @@ -644,9 +426,7 @@ private fun ClearAppStorageDialog(packageName: String, onClose: () -> Unit) { TextButton( { clearing = true - Privilege.DPM.clearApplicationUserData( - Privilege.DAR, packageName, Executors.newSingleThreadExecutor() - ) { _, it -> + onClear(packageName) { Looper.prepare() context.showOperationResultToast(it) onClose() @@ -661,18 +441,28 @@ private fun ClearAppStorageDialog(packageName: String, onClose: () -> Unit) { dismissButton = { TextButton(onClose, enabled = !clearing) { Text(stringResource(R.string.cancel)) } }, - onDismissRequest = onClose + onDismissRequest = { + if (!clearing) onClose() + }, + properties = DialogProperties(false, false) ) } @Serializable object UninstallApp @Composable -fun UninstallAppScreen(onNavigateUp: () -> Unit) { +fun UninstallAppScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onUninstall: (String, (String?) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { var dialog by remember { mutableStateOf(false) } var packageName by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.uninstall_app, onNavigateUp) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { dialog = true }, Modifier.fillMaxWidth(), @@ -681,12 +471,16 @@ fun UninstallAppScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.uninstall)) } } - if(dialog) UninstallAppDialog(packageName) { dialog = false } + if(dialog) UninstallAppDialog(packageName, onUninstall) { + packageName = "" + dialog = false + } } @Composable -private fun UninstallAppDialog(packageName: String, onClose: () -> Unit) { - val context = LocalContext.current +private fun UninstallAppDialog( + packageName: String, onUninstall: (String, (String?) -> Unit) -> Unit, onClose: () -> Unit +) { var uninstalling by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } AlertDialog( @@ -699,7 +493,7 @@ private fun UninstallAppDialog(packageName: String, onClose: () -> Unit) { TextButton( { uninstalling = true - uninstallPackage(context, packageName) { + onUninstall(packageName) { uninstalling = false if(it == null) onClose() else errorMessage = it } @@ -713,64 +507,32 @@ private fun UninstallAppDialog(packageName: String, onClose: () -> Unit) { dismissButton = { TextButton(onClose, enabled = !uninstalling) { Text(stringResource(R.string.cancel)) } }, - onDismissRequest = onClose + onDismissRequest = onClose, + properties = DialogProperties(false, false) ) } @Serializable object KeepUninstalledPackages -@RequiresApi(28) -@Composable -fun KeepUninstalledPackagesScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getKeepUninstalledPackages(Privilege.DAR)?.forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.keep_uninstalled_packages, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setKeepUninstalledPackages(Privilege.DAR, packages.minus(info).map { it.name }) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } - Button( - { - Privilege.DPM.setKeepUninstalledPackages(Privilege.DAR, packages.map { it.name } + packageName) - packageName = "" - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 8.dp), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - Notes(R.string.info_keep_uninstalled_apps, HorizontalPadding) - } - } -} - @Serializable object InstallExistingApp @RequiresApi(28) @Composable -fun InstallExistingAppScreen(onNavigateUp: () -> Unit) { +fun InstallExistingAppScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onInstall: (String) -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var packageName by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.install_existing_app, onNavigateUp) { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { - context.showOperationResultToast( - Privilege.DPM.installExistingPackage(Privilege.DAR, packageName) - ) + context.showOperationResultToast(onInstall(packageName)) }, Modifier.fillMaxWidth(), packageName.isValidPackageName @@ -783,101 +545,23 @@ fun InstallExistingAppScreen(onNavigateUp: () -> Unit) { @Serializable object CrossProfilePackages -@RequiresApi(30) -@Composable -fun CrossProfilePackagesScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getCrossProfilePackages(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.cross_profile_apps, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setCrossProfilePackages(Privilege.DAR, packages.minus(info).map { it.name }.toSet()) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - Privilege.DPM.setCrossProfilePackages(Privilege.DAR, packages.map { it.name }.toSet() + packageName) - packageName = "" - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - } - } -} - @Serializable object CrossProfileWidgetProviders -@Composable -fun CrossProfileWidgetProvidersScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getCrossProfileWidgetProviders(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.cross_profile_widget, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.removeCrossProfileWidgetProvider(Privilege.DAR, it.name) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - Privilege.DPM.addCrossProfileWidgetProvider(Privilege.DAR, packageName) - packageName = "" - refresh() - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - } - } -} - @Serializable object CredentialManagerPolicy @RequiresApi(34) @Composable -fun CredentialManagerPolicyScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val pm = context.packageManager - var policyType by remember{ mutableIntStateOf(-1) } - val packages = remember { mutableStateListOf() } - fun refresh() { - val policy = Privilege.DPM.credentialManagerPolicy - policyType = policy?.policyType ?: -1 - packages.clear() - policy?.packageNames?.forEach { - packages += pm.retrieveAppInfo(it) - } +fun CredentialManagerPolicyScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + cmPackages: MutableStateFlow>, getCmPolicy: () -> Int, + setCmPackage: (String, Boolean) -> Unit, setCmPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { + var policy by remember { mutableIntStateOf(getCmPolicy()) } + val packages by cmPackages.collectAsStateWithLifecycle() + var packageName by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() } - LaunchedEffect(Unit) { refresh() } MyLazyScaffold(R.string.credential_manager_policy, onNavigateUp) { item { mapOf( @@ -886,20 +570,21 @@ fun CredentialManagerPolicyScreen(onNavigateUp: () -> Unit) { PackagePolicy.PACKAGE_POLICY_ALLOWLIST to R.string.whitelist, PackagePolicy.PACKAGE_POLICY_ALLOWLIST_AND_SYSTEM to R.string.whitelist_and_system_app ).forEach { (key, value) -> - FullWidthRadioButtonItem(value, policyType == key) { policyType = key } + FullWidthRadioButtonItem(value, policy == key) { policy = key } } Spacer(Modifier.padding(vertical = 4.dp)) } items(packages, { it.name }) { - ApplicationItem(it) { packages -= it } + ApplicationItem(it) { setCmPackage(it.name, false) } } item { Column(Modifier.padding(horizontal = HorizontalPadding)) { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { - packages += pm.retrieveAppInfo(packageName) + setCmPackage(packageName, true) + packageName = "" }, Modifier.fillMaxWidth(), enabled = packageName.isValidPackageName @@ -908,18 +593,7 @@ fun CredentialManagerPolicyScreen(onNavigateUp: () -> Unit) { } Button( { - try { - if(policyType != -1 && packages.isNotEmpty()) { - Privilege.DPM.credentialManagerPolicy = PackagePolicy(policyType, packages.map { it.name }.toSet()) - } else { - Privilege.DPM.credentialManagerPolicy = null - } - context.showOperationResultToast(true) - } catch(_: IllegalArgumentException) { - context.showOperationResultToast(false) - } finally { - refresh() - } + setCmPolicy(policy) }, Modifier.fillMaxWidth() ) { @@ -932,104 +606,54 @@ fun CredentialManagerPolicyScreen(onNavigateUp: () -> Unit) { @Serializable object PermittedAccessibilityServices -@Composable -fun PermittedAccessibilityServicesScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val pm = context.packageManager - val packages = remember { mutableStateListOf() } - var allowAll by remember { mutableStateOf(true) } - fun refresh() { - packages.clear() - val list = Privilege.DPM.getPermittedAccessibilityServices(Privilege.DAR) - allowAll = list == null - list?.forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.permitted_accessibility_services, onNavigateUp) { - item { - SwitchItem(R.string.allow_all, state = allowAll, onCheckedChange = { allowAll = it }) - } - items(packages, { it.name }) { - ApplicationItem(it) { packages -= it } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - packages += pm.retrieveAppInfo(packageName) - packageName = "" - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - Button( - { - val result = Privilege.DPM.setPermittedAccessibilityServices(Privilege.DAR, if(allowAll) null else packages.map { it.name }) - context.showOperationResultToast(result) - refresh() - }, - Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.system_accessibility_always_allowed, HorizontalPadding) - } - } -} - @Serializable object PermittedInputMethods @Composable -fun PermittedInputMethodsScreen(onNavigateUp: () -> Unit) { +fun PermittedAsAndImPackages( + title: Int, note: Int, chosenPackage: Channel, onChoosePackage: () -> Unit, + packagesState: MutableStateFlow>, getPackages: () -> Boolean, + setPackage: (String, Boolean) -> Unit, setPolicy: (Boolean) -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val pm = context.packageManager - val packages = remember { mutableStateListOf() } - var allowAll by remember { mutableStateOf(true) } - fun refresh() { - packages.clear() - val list = Privilege.DPM.getPermittedInputMethods(Privilege.DAR) - allowAll = list == null - list?.forEach { - packages += pm.retrieveAppInfo(it) - } + val packages by packagesState.collectAsStateWithLifecycle() + var packageName by remember { mutableStateOf("") } + var allowAll by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + allowAll = getPackages() + packageName = chosenPackage.receive() } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.permitted_ime, onNavigateUp) { + MyLazyScaffold(title, onNavigateUp) { item { SwitchItem(R.string.allow_all, state = allowAll, onCheckedChange = { allowAll = it }) } - items(packages, { it.name }) { - ApplicationItem(it) { packages -= it } + if (allowAll) items(packages, { it.name }) { + ApplicationItem(it) { setPackage(it.name, false) } } item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - packages += pm.retrieveAppInfo(packageName) - packageName = "" - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) + if (allowAll) { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + Button( + { + setPackage(packageName, true) + packageName = "" + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } } Button( { - val result = Privilege.DPM.setPermittedInputMethods(Privilege.DAR, if(allowAll) null else packages.map { it.name }) - context.showOperationResultToast(result) - refresh() + context.showOperationResultToast(setPolicy(allowAll)) }, Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.apply)) } - Notes(R.string.system_ime_always_allowed, HorizontalPadding) + Spacer(Modifier.height(10.dp)) + Notes(note, HorizontalPadding) } } } @@ -1037,15 +661,22 @@ fun PermittedInputMethodsScreen(onNavigateUp: () -> Unit) { @Serializable object EnableSystemApp @Composable -fun EnableSystemAppScreen(onNavigateUp: () -> Unit) { +fun EnableSystemAppScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onEnable: (String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var packageName by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.enable_system_app, onNavigateUp) { - var packageName by remember { mutableStateOf("") } Spacer(Modifier.padding(vertical = 4.dp)) - PackageNameTextField(packageName, Modifier.padding(bottom = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(bottom = 8.dp)) { packageName = it } Button( { - Privilege.DPM.enableSystemApp(Privilege.DAR, packageName) + onEnable(packageName) packageName = "" context.showOperationResultToast(true) }, @@ -1062,21 +693,21 @@ fun EnableSystemAppScreen(onNavigateUp: () -> Unit) { @RequiresApi(34) @Composable -fun SetDefaultDialerScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var errorMessage by remember { mutableStateOf(null) } +fun SetDefaultDialerScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onSet: (String) -> Unit, onNavigateUp: () -> Unit +) { + var packageName by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.set_default_dialer, onNavigateUp) { - var packageName by remember { mutableStateOf("") } Spacer(Modifier.padding(vertical = 4.dp)) - PackageNameTextField(packageName, Modifier.padding(bottom = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(bottom = 8.dp)) { packageName = it } Button( { - try { - Privilege.DPM.setDefaultDialerApplication(packageName) - context.showOperationResultToast(true) - } catch(e: Exception) { - errorMessage = e.message - } + onSet(packageName) }, Modifier.fillMaxWidth(), packageName.isValidPackageName @@ -1084,37 +715,54 @@ fun SetDefaultDialerScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.set)) } } - ErrorDialog(errorMessage) { errorMessage = null } } -private fun uninstallPackage(context: Context, packageName: String, onComplete: (String?) -> Unit) { - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { - @SuppressWarnings("UnsafeIntentLaunch") - context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) - } else { - context.unregisterReceiver(this) - if(statusExtra == PackageInstaller.STATUS_SUCCESS) { - onComplete(null) - } else { - onComplete(parsePackageInstallerMessage(context, intent)) - } +@Composable +fun PackageFunctionScreenWithoutResult( + title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, + onSet: (String, Boolean) -> Unit, onNavigateUp: () -> Unit, + chosenPackage: Channel, onChoosePackage: () -> Unit, notes: Int? = null +) { + PackageFunctionScreen( + title, packagesState, onGet, { name, status -> onSet(name, status); null }, + onNavigateUp, chosenPackage, onChoosePackage, notes + ) +} + +@Composable +fun PackageFunctionScreen( + title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, + onSet: (String, Boolean) -> Boolean?, onNavigateUp: () -> Unit, + chosenPackage: Channel, onChoosePackage: () -> Unit, notes: Int? = null +) { + val packages by packagesState.collectAsStateWithLifecycle() + var packageName by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + onGet() + packageName = chosenPackage.receive() + } + MyLazyScaffold(title, onNavigateUp) { + items(packages, { it.name }) { + ApplicationItem(it) { + onSet(it.name, false) } } + item { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + Button( + { + if (onSet(packageName, true) != false) { + println("reset") + packageName = "" + } + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } + if (notes != null) Notes(notes, HorizontalPadding) + } } - ContextCompat.registerReceiver( - context, receiver, IntentFilter(AppInstallerViewModel.ACTION), null, - null, ContextCompat.RECEIVER_EXPORTED - ) - val pi = if(VERSION.SDK_INT >= 34) { - PendingIntent.getBroadcast( - context, 0, Intent(AppInstallerViewModel.ACTION), - PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE - ).intentSender - } else { - PendingIntent.getBroadcast(context, 0, Intent(AppInstallerViewModel.ACTION), PendingIntent.FLAG_MUTABLE).intentSender - } - context.getPackageInstaller().uninstall(packageName, pi) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index a934e9f..8d01fb9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -24,7 +24,6 @@ import com.bintianqi.owndroid.createShortcuts import com.rosan.dhizuku.api.Dhizuku import com.rosan.dhizuku.api.DhizukuBinderWrapper import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 965ffa1..65da686 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -127,7 +127,6 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ChoosePackageContract import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R @@ -153,6 +152,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -886,7 +886,10 @@ fun NetworkStats.toBucketList(): List { @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(23) @Composable -fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkStatsViewer) -> Unit) { +fun NetworkStatsScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkStatsViewer) -> Unit +) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() val fm = LocalFocusManager.current @@ -1053,16 +1056,14 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta ) { var uidText by rememberSaveable { mutableStateOf(context.getString(NetworkStatsUID.All.strRes)) } var readOnly by rememberSaveable { mutableStateOf(true) } - if(!readOnly && uidText.toIntOrNull() != null) uid = uidText.toInt() - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { - it ?: return@rememberLauncherForActivityResult - if(VERSION.SDK_INT >= 24 && readOnly) { - try { - uid = context.packageManager.getPackageUid(it, 0) - uidText = "$it ($uid)" - } catch(_: NameNotFoundException) { - context.showOperationResultToast(false) - } + if (!readOnly && uidText.toIntOrNull() != null) uid = uidText.toInt() + if (VERSION.SDK_INT >= 24) LaunchedEffect(Unit) { + val pkg = chosenPackage.receive() + try { + uid = context.packageManager.getPackageUid(pkg, 0) + uidText = "$uid ($pkg)" + } catch(_: NameNotFoundException) { + context.showOperationResultToast(false) } } OutlinedTextField( @@ -1093,7 +1094,7 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta onClick = { readOnly = true activeTextField = NetworkStatsActiveTextField.None - choosePackage.launch(null) + onChoosePackage() } ) DropdownMenuItem( @@ -1457,15 +1458,18 @@ fun PrivateDnsScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable -fun AlwaysOnVpnPackageScreen(onNavigateUp: () -> Unit) { +fun AlwaysOnVpnPackageScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current var lockdown by rememberSaveable { mutableStateOf(false) } var pkgName by rememberSaveable { mutableStateOf("") } - val focusMgr = LocalFocusManager.current - val refresh = { pkgName = Privilege.DPM.getAlwaysOnVpnPackage(Privilege.DAR) ?: "" } - LaunchedEffect(Unit) { refresh() } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { pkgName = it } + fun refresh() { + pkgName = Privilege.DPM.getAlwaysOnVpnPackage(Privilege.DAR) ?: "" + } + LaunchedEffect(Unit) { + refresh() + pkgName = chosenPackage.receive() } val setAlwaysOnVpn: (String?, Boolean)->Boolean = { vpnPkg: String?, lockdownEnabled: Boolean -> try { @@ -1483,21 +1487,8 @@ fun AlwaysOnVpnPackageScreen(onNavigateUp: () -> Unit) { } } MyScaffold(R.string.always_on_vpn, onNavigateUp) { - OutlinedTextField( - value = pkgName, - onValueChange = { pkgName = it }, - label = { Text(stringResource(R.string.package_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - trailingIcon = { - Icon(painter = painterResource(R.drawable.list_fill0), contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { choosePackage.launch(null) } - .padding(3.dp)) - }, - modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) - ) + PackageNameTextField(pkgName, onChoosePackage, + Modifier.padding(vertical = 4.dp)) { pkgName = it } SwitchItem(R.string.enable_lockdown, state = lockdown, onCheckedChange = { lockdown = it }, padding = false) Spacer(Modifier.padding(vertical = 5.dp)) Button( @@ -2067,7 +2058,7 @@ fun AddApnSettingScreen(origin: ApnSetting?, onNavigateUp: () -> Unit) { keyboardActions = KeyboardActions { fm.clearFocus() } ) if(VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween) { - val fr = FocusRequester() + val fr = remember { FocusRequester() } OutlinedTextField( mtuV4, { mtuV4 = it }, Modifier.fillMaxWidth(0.49F), label = { Text("MTU (IPv4)") }, @@ -2206,7 +2197,7 @@ fun AddApnSettingScreen(origin: ApnSetting?, onNavigateUp: () -> Unit) { if(dialog != 0) { var address by remember { mutableStateOf((if(dialog == 1) proxyAddress else mmsProxyAddress)) } var port by remember { mutableStateOf((if(dialog == 1) proxyPort else mmsProxyPort)) } - val fr = FocusRequester() + val fr = remember { FocusRequester() } AlertDialog( title = { Text(if(dialog == 1) "Proxy" else "MMS proxy") }, text = { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt index 30c0f5f..eb53845 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build.VERSION import android.os.PersistableBundle -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.annotation.Keep import androidx.annotation.RequiresApi import androidx.annotation.StringRes @@ -84,10 +83,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ChoosePackageContract import com.bintianqi.owndroid.DHIZUKU_CLIENTS_FILE import com.bintianqi.owndroid.DhizukuClientInfo import com.bintianqi.owndroid.DhizukuPermissions @@ -113,6 +110,7 @@ import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.rosan.dhizuku.api.Dhizuku import com.rosan.dhizuku.api.DhizukuRequestPermissionListener import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -731,30 +729,19 @@ fun DelegatedAdminsScreen(onNavigateUp: () -> Unit, onNavigate: (AddDelegatedAdm @RequiresApi(26) @Composable -fun AddDelegatedAdminScreen(data: AddDelegatedAdmin, onNavigateUp: () -> Unit) { +fun AddDelegatedAdminScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + data: AddDelegatedAdmin, onNavigateUp: () -> Unit +) { val updateMode = data.pkg.isNotEmpty() - val fm = LocalFocusManager.current var input by remember { mutableStateOf(data.pkg) } val scopes = remember { mutableStateListOf(*data.scopes.toTypedArray()) } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { input = it } + LaunchedEffect(Unit) { + input = chosenPackage.receive() } MySmallTitleScaffold(if(updateMode) R.string.place_holder else R.string.add_delegated_admin, onNavigateUp, 0.dp) { - OutlinedTextField( - value = input, onValueChange = { input = it }, - label = { Text(stringResource(R.string.package_name)) }, - trailingIcon = { - if(!updateMode) IconButton({ choosePackage.launch(null) }) { - Icon(painterResource(R.drawable.list_fill0), null) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() }, - readOnly = updateMode, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = HorizontalPadding) - ) + PackageNameTextField(input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { input = it } DelegatedScope.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { scope -> val checked = scope in scopes Row( diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 635957b..86ad241 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -112,13 +112,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ChoosePackageContract import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.NotificationUtils import com.bintianqi.owndroid.Privilege @@ -143,6 +141,7 @@ import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem import com.bintianqi.owndroid.uriToStream import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -1134,7 +1133,9 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(28) @Composable -fun LockTaskModeScreen(onNavigateUp: () -> Unit) { +fun LockTaskModeScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit +) { val coroutine = rememberCoroutineScope() val pagerState = rememberPagerState { 3 } var tabIndex by remember { mutableIntStateOf(0) } @@ -1177,8 +1178,8 @@ fun LockTaskModeScreen(onNavigateUp: () -> Unit) { .padding(horizontal = HorizontalPadding) .padding(bottom = 80.dp) ) { - if(page == 0) StartLockTaskMode() - else LockTaskPackages() + if(page == 0) StartLockTaskMode(chosenPackage, onChoosePackage) + else LockTaskPackages(chosenPackage, onChoosePackage) } } else { Column( @@ -1197,33 +1198,20 @@ fun LockTaskModeScreen(onNavigateUp: () -> Unit) { @RequiresApi(28) @Composable -private fun ColumnScope.StartLockTaskMode() { +private fun ColumnScope.StartLockTaskMode( + chosenPackage: Channel, onChoosePackage: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current var startLockTaskApp by rememberSaveable { mutableStateOf("") } var startLockTaskActivity by rememberSaveable { mutableStateOf("") } var specifyActivity by rememberSaveable { mutableStateOf(false) } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { startLockTaskApp = it } + LaunchedEffect(Unit) { + startLockTaskApp = chosenPackage.receive() } Spacer(Modifier.padding(vertical = 5.dp)) - OutlinedTextField( - value = startLockTaskApp, - onValueChange = { startLockTaskApp = it }, - label = { Text(stringResource(R.string.package_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - trailingIcon = { - Icon(painter = painterResource(R.drawable.list_fill0), contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { choosePackage.launch(null) } - .padding(3.dp)) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp) - ) + PackageNameTextField(startLockTaskApp, onChoosePackage, + Modifier.padding(vertical = 3.dp), { startLockTaskApp = it }) CheckBoxItem(R.string.specify_activity, specifyActivity) { specifyActivity = it } AnimatedVisibility(specifyActivity) { OutlinedTextField( @@ -1264,37 +1252,23 @@ private fun ColumnScope.StartLockTaskMode() { @RequiresApi(26) @Composable -private fun ColumnScope.LockTaskPackages() { +private fun ColumnScope.LockTaskPackages( + chosenPackage: Channel, onChoosePackage: () -> Unit +) { val context = LocalContext.current - val focusMgr = LocalFocusManager.current val lockTaskPackages = remember { mutableStateListOf() } var input by rememberSaveable { mutableStateOf("") } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { input = it } + LaunchedEffect(Unit) { + lockTaskPackages.addAll(Privilege.DPM.getLockTaskPackages(Privilege.DAR)) + input = chosenPackage.receive() } - LaunchedEffect(Unit) { lockTaskPackages.addAll(Privilege.DPM.getLockTaskPackages(Privilege.DAR)) } Spacer(Modifier.padding(vertical = 5.dp)) if(lockTaskPackages.isEmpty()) Text(text = stringResource(R.string.none)) for(i in lockTaskPackages) { ListItem(i) { lockTaskPackages -= i } } - OutlinedTextField( - value = input, - onValueChange = { input = it }, - label = { Text(stringResource(R.string.package_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - trailingIcon = { - Icon(painter = painterResource(R.drawable.list_fill0), contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { choosePackage.launch(null) } - .padding(3.dp)) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp) - ) + PackageNameTextField(input, onChoosePackage, + Modifier.padding(vertical = 3.dp), { input = it }) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { From 26c956a2cf93c4afe0f82747cae1fd3789568f02 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Mon, 22 Sep 2025 22:46:37 +0800 Subject: [PATCH 04/26] ViewModel refactoring: System part fix #165 --- .../com/bintianqi/owndroid/MainActivity.kt | 77 +- .../com/bintianqi/owndroid/MyViewModel.kt | 410 ++++++ .../bintianqi/owndroid/dpm/Applications.kt | 34 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 1289 ++++++++--------- .../com/bintianqi/owndroid/ui/Components.kt | 16 + app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 9 files changed, 1128 insertions(+), 706 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 9384f24..d4d58bd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -316,29 +316,64 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } } - composable { SystemManagerScreen(::navigateUp, ::navigate) } - composable { SystemOptionsScreen(::navigateUp) } - composable { KeyguardScreen(::navigateUp) } - composable { HardwareMonitorScreen(::navigateUp) } - composable { ChangeTimeScreen(::navigateUp) } - composable { ChangeTimeZoneScreen(::navigateUp) } - composable { AutoTimePolicyScreen(::navigateUp) } - composable { AutoTimeZonePolicyScreen(::navigateUp) } - //composable<> { KeyPairs(::navigateUp) } - composable { ContentProtectionPolicyScreen(::navigateUp) } - composable { PermissionPolicyScreen(::navigateUp) } - composable { MtePolicyScreen(::navigateUp) } - composable { NearbyStreamingPolicyScreen(::navigateUp) } - composable { - LockTaskModeScreen(vm.chosenPackage, ::choosePackage, ::navigateUp) + composable { SystemManagerScreen(vm, ::navigateUp, ::navigate) } + composable { SystemOptionsScreen(vm, ::navigateUp) } + composable { + KeyguardScreen(vm::setKeyguardDisabled, vm::lockScreen, ::navigateUp) + } + composable { + HardwareMonitorScreen(vm.hardwareProperties, vm::getHardwareProperties, + vm::setHpRefreshInterval, ::navigateUp) + } + composable { ChangeTimeScreen(vm::setTime, ::navigateUp) } + composable { ChangeTimeZoneScreen(vm::setTimeZone, ::navigateUp) } + composable { + AutoTimePolicyScreen(vm::getAutoTimePolicy, vm::setAutoTimePolicy, ::navigateUp) + } + composable { + AutoTimeZonePolicyScreen(vm::getAutoTimeZonePolicy, vm::setAutoTimeZonePolicy, + ::navigateUp) + } + //composable<> { KeyPairs(::navigateUp) } + composable { + ContentProtectionPolicyScreen(vm::getContentProtectionPolicy, + vm::setContentProtectionPolicy, ::navigateUp) + } + composable { + PermissionPolicyScreen(vm::getPermissionPolicy, vm::setPermissionPolicy, ::navigateUp) + } + composable { + MtePolicyScreen(vm::getMtePolicy, vm::setMtePolicy, ::navigateUp) + } + composable { + NearbyStreamingPolicyScreen(vm::getNsAppPolicy, vm::setNsAppPolicy, + vm::getNsNotificationPolicy, vm::setNsNotificationPolicy, ::navigateUp) + } + composable { + LockTaskModeScreen(vm.chosenPackage, ::choosePackage, vm.lockTaskPackages, + vm::getLockTaskPackages, vm::setLockTaskPackage, vm::startLockTaskMode, + vm:: getLockTaskFeatures, vm::setLockTaskFeatures, ::navigateUp) + } + composable { + CaCertScreen(vm.installedCaCerts, vm::getCaCerts, vm::installCaCert, vm::parseCaCert, + vm::exportCaCert, vm::uninstallCaCert, vm::uninstallAllCaCerts, ::navigateUp) } - composable { CaCertScreen(::navigateUp) } composable { SecurityLoggingScreen(::navigateUp) } - composable { DisableAccountManagementScreen(::navigateUp) } - composable { SystemUpdatePolicyScreen(::navigateUp) } - composable { InstallSystemUpdateScreen(::navigateUp) } - composable { FrpPolicyScreen(::navigateUp) } - composable { WipeDataScreen(::navigateUp) } + composable { + DisableAccountManagementScreen(vm.mdAccountTypes, vm::getMdAccountTypes, + vm::setMdAccountType, ::navigateUp) + } + composable { + SystemUpdatePolicyScreen(vm::getSystemUpdatePolicy, vm::setSystemUpdatePolicy, + vm::getPendingSystemUpdate, ::navigateUp) + } + composable { + InstallSystemUpdateScreen(vm::installSystemUpdate, ::navigateUp) + } + composable { + FrpPolicyScreen(vm::getFrpPolicy, vm::setFrpPolicy, ::navigateUp) + } + composable { WipeDataScreen(vm::wipeData, ::navigateUp) } composable { NetworkScreen(::navigateUp, ::navigate) } composable { WifiScreen(::navigateUp, ::navigate) { navController.navigate(AddNetwork, it)} } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 3ea5e76..3e1f743 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,16 +1,25 @@ package com.bintianqi.owndroid +import android.app.ActivityOptions import android.app.Application import android.app.PendingIntent +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback +import android.app.admin.FactoryResetProtectionPolicy import android.app.admin.PackagePolicy +import android.app.admin.SystemUpdateInfo +import android.app.admin.SystemUpdatePolicy import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.ApplicationInfo import android.content.pm.PackageInstaller import android.content.pm.PackageManager +import android.net.Uri import android.os.Build.VERSION +import android.os.HardwarePropertiesManager import androidx.annotation.RequiresApi import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -22,16 +31,28 @@ import androidx.lifecycle.viewModelScope import com.bintianqi.owndroid.Privilege.DAR import com.bintianqi.owndroid.Privilege.DPM import com.bintianqi.owndroid.dpm.AppStatus +import com.bintianqi.owndroid.dpm.CaCertInfo +import com.bintianqi.owndroid.dpm.FrpPolicyInfo +import com.bintianqi.owndroid.dpm.HardwareProperties +import com.bintianqi.owndroid.dpm.PendingSystemUpdateInfo +import com.bintianqi.owndroid.dpm.SystemOptionsStatus +import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo import com.bintianqi.owndroid.dpm.getPackageInstaller import com.bintianqi.owndroid.dpm.isValidPackageName import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage import com.bintianqi.owndroid.dpm.permissionList +import com.bintianqi.owndroid.dpm.temperatureTypes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.util.concurrent.Executors class MyViewModel(application: Application): AndroidViewModel(application) { @@ -377,6 +398,395 @@ class MyViewModel(application: Application): AndroidViewModel(application) { false } } + + @RequiresApi(24) + fun reboot() { + DPM.reboot(DAR) + } + @RequiresApi(24) + fun requestBugReport(): Boolean { + return DPM.requestBugreport(DAR) + } + @RequiresApi(24) + fun getOrgName(): String { + return DPM.getOrganizationName(DAR).toString() + } + @RequiresApi(24) + fun setOrgName(name: String) { + DPM.setOrganizationName(DAR, name) + } + @RequiresApi(31) + fun setOrgId(id: String) { + DPM.setOrganizationId(id) + } + @RequiresApi(31) + fun getEnrollmentSpecificId(): String { + return DPM.enrollmentSpecificId + } + val systemOptionsStatus = MutableStateFlow(SystemOptionsStatus()) + fun getSystemOptionsStatus() { + val privilege = Privilege.status.value + systemOptionsStatus.value = SystemOptionsStatus( + cameraDisabled = DPM.getCameraDisabled(null), + screenCaptureDisabled = DPM.getScreenCaptureDisabled(null), + statusBarDisabled = if (VERSION.SDK_INT >= 34 && + privilege.run { device || (profile && affiliated) }) + DPM.isStatusBarDisabled else false, + autoTimeEnabled = if (VERSION.SDK_INT >= 30 && privilege.run { device || org }) + DPM.getAutoTimeEnabled(DAR) else false, + autoTimeZoneEnabled = if (VERSION.SDK_INT >= 30 && privilege.run { device || org }) + DPM.getAutoTimeZoneEnabled(DAR) else false, + autoTimeRequired = if (VERSION.SDK_INT < 30) DPM.autoTimeRequired else false, + masterVolumeMuted = DPM.isMasterVolumeMuted(DAR), + backupServiceEnabled = if (VERSION.SDK_INT >= 26) DPM.isBackupServiceEnabled(DAR) else false, + btContactSharingDisabled = if (VERSION.SDK_INT >= 23 && privilege.work) + DPM.getBluetoothContactSharingDisabled(DAR) else false, + commonCriteriaMode = if (VERSION.SDK_INT >= 30) DPM.isCommonCriteriaModeEnabled(DAR) else false, + usbSignalEnabled = if (VERSION.SDK_INT >= 31) DPM.isUsbDataSignalingEnabled else false, + canDisableUsbSignal = if (VERSION.SDK_INT >= 31) DPM.canUsbDataSignalingBeDisabled() else false + ) + } + fun setCameraDisabled(disabled: Boolean) { + DPM.setCameraDisabled(DAR, disabled) + createShortcuts(application) + systemOptionsStatus.update { it.copy(cameraDisabled = DPM.getCameraDisabled(null)) } + } + fun setScreenCaptureDisabled(disabled: Boolean) { + DPM.setScreenCaptureDisabled(DAR, disabled) + systemOptionsStatus.update { + it.copy(screenCaptureDisabled = DPM.getScreenCaptureDisabled(null)) + } + } + @RequiresApi(23) + fun setStatusBarDisabled(disabled: Boolean) { + val result = DPM.setStatusBarDisabled(DAR, disabled) + if (result) systemOptionsStatus.update { it.copy(statusBarDisabled = disabled) } + } + @RequiresApi(30) + fun setAutoTimeEnabled(enabled: Boolean) { + DPM.setAutoTimeEnabled(DAR, enabled) + systemOptionsStatus.update { it.copy(autoTimeEnabled = DPM.getAutoTimeEnabled(DAR)) } + } + @RequiresApi(30) + fun setAutoTimeZoneEnabled(enabled: Boolean) { + DPM.setAutoTimeZoneEnabled(DAR, enabled) + systemOptionsStatus.update { + it.copy(autoTimeZoneEnabled = DPM.getAutoTimeZoneEnabled(DAR)) + } + } + @Suppress("DEPRECATION") + fun setAutoTimeRequired(required: Boolean) { + DPM.setAutoTimeRequired(DAR, required) + systemOptionsStatus.update { it.copy(autoTimeRequired = DPM.autoTimeRequired) } + } + fun setMasterVolumeMuted(muted: Boolean) { + DPM.setMasterVolumeMuted(DAR, muted) + createShortcuts(application) + systemOptionsStatus.update { it.copy(masterVolumeMuted = DPM.isMasterVolumeMuted(DAR)) } + } + @RequiresApi(26) + fun setBackupServiceEnabled(enabled: Boolean) { + DPM.setBackupServiceEnabled(DAR, enabled) + systemOptionsStatus.update { + it.copy(backupServiceEnabled = DPM.isBackupServiceEnabled(DAR)) + } + } + @RequiresApi(23) + fun setBtContactSharingDisabled(disabled: Boolean) { + DPM.setBluetoothContactSharingDisabled(DAR, disabled) + systemOptionsStatus.update { + it.copy(btContactSharingDisabled = DPM.getBluetoothContactSharingDisabled(DAR)) + } + } + @RequiresApi(30) + fun setCommonCriteriaModeEnabled(enabled: Boolean) { + DPM.setCommonCriteriaModeEnabled(DAR, enabled) + systemOptionsStatus.update { + it.copy(commonCriteriaMode = DPM.isCommonCriteriaModeEnabled(DAR)) + } + } + @RequiresApi(31) + fun setUsbSignalEnabled(enabled: Boolean) { + DPM.isUsbDataSignalingEnabled = enabled + systemOptionsStatus.update { it.copy(usbSignalEnabled = DPM.isUsbDataSignalingEnabled) } + } + @RequiresApi(23) + fun setKeyguardDisabled(disabled: Boolean): Boolean { + return DPM.setKeyguardDisabled(DAR, disabled) + } + fun lockScreen(evictKey: Boolean) { + if (VERSION.SDK_INT >= 26 && Privilege.status.value.work) { + DPM.lockNow(if (evictKey) DevicePolicyManager.FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY else 0) + } else { + DPM.lockNow() + } + } + val hardwareProperties = MutableStateFlow(HardwareProperties()) + var hpRefreshInterval = 1000L + fun setHpRefreshInterval(interval: Float) { + hpRefreshInterval = (interval * 1000).toLong() + } + @RequiresApi(24) + suspend fun getHardwareProperties() { + val hpm = application.getSystemService(HardwarePropertiesManager::class.java) + while (true) { + val properties = HardwareProperties( + temperatureTypes.map { (type, _) -> + type to hpm.getDeviceTemperatures(type, HardwarePropertiesManager.TEMPERATURE_CURRENT).toList() + }.toMap(), + hpm.cpuUsages.map { it.active to it.total }, + hpm.fanSpeeds.toList() + ) + if (properties.cpuUsages.isEmpty() && properties.fanSpeeds.isEmpty() && + properties.temperatures.isEmpty()) { + break + } + delay(hpRefreshInterval) + } + } + @RequiresApi(28) + fun setTime(time: Long): Boolean { + return DPM.setTime(DAR, time) + } + @RequiresApi(28) + fun setTimeZone(tz: String): Boolean { + return DPM.setTimeZone(DAR, tz) + } + @RequiresApi(36) + fun getAutoTimePolicy(): Int { + return DPM.autoTimePolicy + } + @RequiresApi(36) + fun setAutoTimePolicy(policy: Int) { + DPM.autoTimePolicy = policy + } + @RequiresApi(36) + fun getAutoTimeZonePolicy(): Int { + return DPM.autoTimeZonePolicy + } + @RequiresApi(36) + fun setAutoTimeZonePolicy(policy: Int) { + DPM.autoTimeZonePolicy = policy + } + @RequiresApi(35) + fun getContentProtectionPolicy(): Int { + return DPM.getContentProtectionPolicy(DAR) + } + @RequiresApi(35) + fun setContentProtectionPolicy(policy: Int) { + DPM.setContentProtectionPolicy(DAR, policy) + } + @RequiresApi(23) + fun getPermissionPolicy(): Int { + return DPM.getPermissionPolicy(DAR) + } + @RequiresApi(23) + fun setPermissionPolicy(policy: Int) { + DPM.setPermissionPolicy(DAR, policy) + } + @RequiresApi(34) + fun getMtePolicy(): Int { + return DPM.mtePolicy + } + @RequiresApi(34) + fun setMtePolicy(policy: Int): Boolean { + return try { + DPM.mtePolicy = policy + true + } catch (_: UnsupportedOperationException) { + false + } + } + @RequiresApi(31) + fun getNsAppPolicy(): Int { + return DPM.nearbyAppStreamingPolicy + } + @RequiresApi(31) + fun setNsAppPolicy(policy: Int) { + DPM.nearbyAppStreamingPolicy = policy + } + @RequiresApi(31) + fun getNsNotificationPolicy(): Int { + return DPM.nearbyNotificationStreamingPolicy + } + @RequiresApi(31) + fun setNsNotificationPolicy(policy: Int) { + DPM.nearbyNotificationStreamingPolicy = policy + } + val lockTaskPackages = MutableStateFlow(emptyList()) + @RequiresApi(26) + fun getLockTaskPackages() { + lockTaskPackages.value = DPM.getLockTaskPackages(DAR).map { getAppInfo(it) } + } + @RequiresApi(26) + fun setLockTaskPackage(name: String, status: Boolean) { + DPM.setLockTaskPackages(DAR, + lockTaskPackages.value.map { it.name } + .run { if (status) plus(name) else minus(name) } + .toTypedArray() + ) + getLockTaskPackages() + } + @RequiresApi(28) + fun startLockTaskMode(packageName: String, activity: String): Int { + if (!NotificationUtils.checkPermission(application)) return 0 + if (!DPM.isLockTaskPermitted(packageName)) return 1 + val options = ActivityOptions.makeBasic().setLockTaskEnabled(true) + val intent = if(activity.isNotEmpty()) { + Intent().setComponent(ComponentName(packageName, activity)) + } else PM.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + application.startActivity(intent, options.toBundle()) + return 0 + } else { + return 2 + } + } + @RequiresApi(28) + fun getLockTaskFeatures(): Int { + return DPM.getLockTaskFeatures(DAR) + } + @RequiresApi(28) + fun setLockTaskFeatures(flags: Int): String? { + try { + DPM.setLockTaskFeatures(DAR, flags) + return null + } catch (e: IllegalArgumentException) { + return e.message + } + } + val installedCaCerts = MutableStateFlow(emptyList()) + fun getCaCerts() { + viewModelScope.launch { + installedCaCerts.value = DPM.getInstalledCaCerts(DAR).mapNotNull { parseCaCert(it) } + } + } + fun parseCaCert(uri: Uri): CaCertInfo? { + return try { + application.contentResolver.openInputStream(uri)?.use { + parseCaCert(it.readBytes()) + } + } catch(e: Exception) { + e.printStackTrace() + null + } + } + fun parseCaCert(bytes: ByteArray): CaCertInfo? { + val hash = MessageDigest.getInstance("SHA-256").digest(bytes).toHexString() + return try { + val factory = CertificateFactory.getInstance("X.509") + val cert = factory.generateCertificate(bytes.inputStream()) as X509Certificate + CaCertInfo( + hash, cert.serialNumber.toString(16), + cert.issuerX500Principal.name, cert.subjectX500Principal.name, + parseDate(cert.notBefore), parseDate(cert.notAfter), bytes + ) + } catch (e: CertificateException) { + e.printStackTrace() + null + } + } + fun installCaCert(cert: CaCertInfo): Boolean { + val result = DPM.installCaCert(DAR, cert.bytes) + if (result) getCaCerts() + return result + } + fun uninstallCaCert(cert: CaCertInfo) { + DPM.uninstallCaCert(DAR, cert.bytes) + getCaCerts() + } + fun uninstallAllCaCerts() { + DPM.uninstallAllUserCaCerts(DAR) + getCaCerts() + } + fun exportCaCert(uri: Uri, cert: CaCertInfo) { + application.contentResolver.openOutputStream(uri)?.use { + it.write(cert.bytes) + } + } + val mdAccountTypes = MutableStateFlow(emptyList()) + fun getMdAccountTypes() { + mdAccountTypes.value = DPM.accountTypesWithManagementDisabled?.toList() ?: emptyList() + } + fun setMdAccountType(type: String, disabled: Boolean) { + DPM.setAccountManagementDisabled(DAR, type, disabled) + getMdAccountTypes() + } + @RequiresApi(30) + fun getFrpPolicy(): FrpPolicyInfo { + return try { + val policy = DPM.getFactoryResetProtectionPolicy(DAR) + FrpPolicyInfo( + true, policy != null, policy?.isFactoryResetProtectionEnabled ?: false, + policy?.factoryResetProtectionAccounts ?: emptyList() + ) + } catch (_: UnsupportedOperationException) { + FrpPolicyInfo(false, false, false, emptyList()) + } + } + @RequiresApi(30) + fun setFrpPolicy(info: FrpPolicyInfo) { + val policy = if (info.usePolicy) { + FactoryResetProtectionPolicy.Builder() + .setFactoryResetProtectionEnabled(info.enabled) + .setFactoryResetProtectionAccounts(info.accounts) + .build() + } else null + DPM.setFactoryResetProtectionPolicy(DAR, policy) + } + fun wipeData(wipeDevice: Boolean, flags: Int, reason: String) { + if (wipeDevice && VERSION.SDK_INT >= 34) { + DPM.wipeDevice(flags) + } else { + if(VERSION.SDK_INT >= 28 && reason.isNotEmpty()) { + DPM.wipeData(flags, reason) + } else { + DPM.wipeData(flags) + } + } + } + @RequiresApi(23) + fun getSystemUpdatePolicy(): SystemUpdatePolicyInfo { + val policy = DPM.systemUpdatePolicy + return SystemUpdatePolicyInfo( + policy?.policyType ?: -1, policy?.installWindowStart ?: 0, policy?.installWindowEnd ?: 0 + ) + } + @RequiresApi(23) + fun setSystemUpdatePolicy(info: SystemUpdatePolicyInfo) { + val policy = when (info.type) { + SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC -> SystemUpdatePolicy.createAutomaticInstallPolicy() + SystemUpdatePolicy.TYPE_INSTALL_WINDOWED -> + SystemUpdatePolicy.createWindowedInstallPolicy(info.start, info.end) + SystemUpdatePolicy.TYPE_POSTPONE -> SystemUpdatePolicy.createPostponeInstallPolicy() + else -> null + } + DPM.setSystemUpdatePolicy(DAR, policy) + } + @RequiresApi(26) + fun getPendingSystemUpdate(): PendingSystemUpdateInfo { + val update = DPM.getPendingSystemUpdate(DAR) + return PendingSystemUpdateInfo(update != null, update?.receivedTime ?: 0, + update?.securityPatchState == SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE) + } + @RequiresApi(29) + fun installSystemUpdate(uri: Uri, callback: (String) -> Unit) { + val callback = object: InstallSystemUpdateCallback() { + override fun onInstallUpdateError(errorCode: Int, errorMessage: String) { + super.onInstallUpdateError(errorCode, errorMessage) + val errDetail = when(errorCode) { + UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low + UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid + UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver + UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist + else -> R.string.unknown_error + } + callback(application.getString(errDetail) + "\n$errorMessage") + } + } + DPM.installSystemUpdate(DAR, uri, application.mainExecutor, callback) + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index 2878e55..f8bab24 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -54,6 +54,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -556,7 +557,8 @@ fun CredentialManagerPolicyScreen( cmPackages: MutableStateFlow>, getCmPolicy: () -> Int, setCmPackage: (String, Boolean) -> Unit, setCmPolicy: (Int) -> Unit, onNavigateUp: () -> Unit ) { - var policy by remember { mutableIntStateOf(getCmPolicy()) } + val context = LocalContext.current + var policy by rememberSaveable { mutableIntStateOf(getCmPolicy()) } val packages by cmPackages.collectAsStateWithLifecycle() var packageName by remember { mutableStateOf("") } LaunchedEffect(Unit) { @@ -574,26 +576,29 @@ fun CredentialManagerPolicyScreen( } Spacer(Modifier.padding(vertical = 4.dp)) } - items(packages, { it.name }) { + if (policy != -1) items(packages, { it.name }) { ApplicationItem(it) { setCmPackage(it.name, false) } } item { Column(Modifier.padding(horizontal = HorizontalPadding)) { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - setCmPackage(packageName, true) - packageName = "" - }, - Modifier.fillMaxWidth(), - enabled = packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) + if (policy != -1) { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } + Button( + { + setCmPackage(packageName, true) + packageName = "" + }, + Modifier.fillMaxWidth(), + enabled = packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } } Button( { setCmPolicy(policy) + context.showOperationResultToast(true) }, Modifier.fillMaxWidth() ) { @@ -617,9 +622,8 @@ fun PermittedAsAndImPackages( val context = LocalContext.current val packages by packagesState.collectAsStateWithLifecycle() var packageName by remember { mutableStateOf("") } - var allowAll by remember { mutableStateOf(false) } + var allowAll by rememberSaveable { mutableStateOf(getPackages()) } LaunchedEffect(Unit) { - allowAll = getPackages() packageName = chosenPackage.receive() } MyLazyScaffold(title, onNavigateUp) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 86ad241..99227f6 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -1,10 +1,7 @@ package com.bintianqi.owndroid.dpm import android.annotation.SuppressLint -import android.app.ActivityOptions import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyManager.FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY -import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback import android.app.admin.DevicePolicyManager.MTE_DISABLED import android.app.admin.DevicePolicyManager.MTE_ENABLED import android.app.admin.DevicePolicyManager.MTE_NOT_CONTROLLED_BY_POLICY @@ -19,15 +16,11 @@ import android.app.admin.DevicePolicyManager.WIPE_EUICC import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE import android.app.admin.DevicePolicyManager.WIPE_RESET_PROTECTION_DATA import android.app.admin.DevicePolicyManager.WIPE_SILENTLY -import android.app.admin.FactoryResetProtectionPolicy import android.app.admin.SystemUpdateInfo -import android.app.admin.SystemUpdatePolicy import android.app.admin.SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC import android.app.admin.SystemUpdatePolicy.TYPE_INSTALL_WINDOWED import android.app.admin.SystemUpdatePolicy.TYPE_POSTPONE -import android.content.ComponentName import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Build.VERSION import android.os.HardwarePropertiesManager @@ -43,12 +36,12 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -68,6 +61,7 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -80,11 +74,7 @@ import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider -import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -101,7 +91,6 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -117,15 +106,15 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.AppInfo import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.NotificationUtils +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP -import com.bintianqi.owndroid.createShortcuts import com.bintianqi.owndroid.formatFileSize import com.bintianqi.owndroid.humanReadableDate -import com.bintianqi.owndroid.parseDate +import com.bintianqi.owndroid.parseTimestamp import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -139,26 +128,23 @@ import com.bintianqi.owndroid.ui.MySmallTitleScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem -import com.bintianqi.owndroid.uriToStream -import kotlinx.coroutines.Dispatchers +import com.bintianqi.owndroid.yesOrNo import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import java.io.ByteArrayOutputStream -import java.security.MessageDigest -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate import java.util.Date import java.util.TimeZone -import java.util.concurrent.Executors import kotlin.math.roundToLong @Serializable object SystemManager @Composable -fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun SystemManagerScreen( + vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit +) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() /** 1: reboot, 2: bug report, 3: org name, 4: org id, 5: enrollment specific id*/ @@ -248,9 +234,9 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { TextButton( onClick = { if(dialog == 1) { - Privilege.DPM.reboot(Privilege.DAR) + vm.reboot() } else { - context.showOperationResultToast(Privilege.DPM.requestBugreport(Privilege.DAR)) + context.showOperationResultToast(vm.requestBugReport()) } dialog = 0 } @@ -266,12 +252,15 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { text = { val focusMgr = LocalFocusManager.current LaunchedEffect(Unit) { - if(dialog == 5 && VERSION.SDK_INT >= 31) input = Privilege.DPM.enrollmentSpecificId + if (dialog == 5 && VERSION.SDK_INT >= 31) input = vm.getEnrollmentSpecificId() + if (dialog == 3 && VERSION.SDK_INT >= 24) input = vm.getOrgName() } Column { OutlinedTextField( input, { input = it }, - Modifier.fillMaxWidth().padding(bottom = if (dialog != 3) 8.dp else 0.dp), + Modifier + .fillMaxWidth() + .padding(bottom = if (dialog != 3) 8.dp else 0.dp), readOnly = dialog == 5, label = { Text(stringResource( @@ -301,16 +290,12 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { confirmButton = { TextButton( onClick = { - try { - if (dialog == 3 && VERSION.SDK_INT >= 24) Privilege.DPM.setOrganizationName(Privilege.DAR, input) - if (dialog == 4 && VERSION.SDK_INT >= 31) { - Privilege.DPM.setOrganizationId(input) - enrollmentSpecificId = Privilege.DPM.enrollmentSpecificId - } - dialog = 0 - } catch(_: IllegalStateException) { - context.showOperationResultToast(false) + if (dialog == 3 && VERSION.SDK_INT >= 24) vm.setOrgName(input) + if (dialog == 4 && VERSION.SDK_INT >= 31) { + vm.setOrgId(input) + enrollmentSpecificId = vm.getEnrollmentSpecificId() } + dialog = 0 }, enabled = dialog != 4 || input.length in 6..64 ) { @@ -321,77 +306,69 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } } +data class SystemOptionsStatus( + val cameraDisabled: Boolean = false, + val screenCaptureDisabled: Boolean = false, + val statusBarDisabled: Boolean = false, + val autoTimeEnabled: Boolean = true, + val autoTimeZoneEnabled: Boolean = true, + val autoTimeRequired: Boolean = true, + val masterVolumeMuted: Boolean = false, + val backupServiceEnabled: Boolean = false, + val btContactSharingDisabled: Boolean = false, + val commonCriteriaMode: Boolean = false, + val usbSignalEnabled: Boolean = true, + val canDisableUsbSignal: Boolean = true +) + @Serializable object SystemOptions @Composable -fun SystemOptionsScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun SystemOptionsScreen(vm: MyViewModel, onNavigateUp: () -> Unit) { val privilege by Privilege.status.collectAsStateWithLifecycle() var dialog by remember { mutableIntStateOf(0) } + val status by vm.systemOptionsStatus.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.getSystemOptionsStatus() } MyScaffold(R.string.options, onNavigateUp, 0.dp) { - SwitchItem(R.string.disable_cam, icon = R.drawable.no_photography_fill0, - getState = { Privilege.DPM.getCameraDisabled(null) }, onCheckedChange = { - Privilege.DPM.setCameraDisabled(Privilege.DAR, it) - createShortcuts(context) - } - ) - SwitchItem(R.string.disable_screen_capture, icon = R.drawable.screenshot_fill0, - getState = { Privilege.DPM.getScreenCaptureDisabled(null) }, - onCheckedChange = { Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, it) } - ) - if(VERSION.SDK_INT >= 34 && (privilege.device || (privilege.profile && privilege.affiliated))) { - SwitchItem(R.string.disable_status_bar, icon = R.drawable.notifications_fill0, - getState = { Privilege.DPM.isStatusBarDisabled}, - onCheckedChange = { Privilege.DPM.setStatusBarDisabled(Privilege.DAR, it) } - ) + SwitchItem(R.string.disable_cam, status.cameraDisabled, vm::setCameraDisabled, + R.drawable.no_photography_fill0) + SwitchItem(R.string.disable_screen_capture, status.screenCaptureDisabled, + vm::setScreenCaptureDisabled, R.drawable.screenshot_fill0) + if (VERSION.SDK_INT >= 34 && privilege.run { device || (profile && affiliated) }) { + SwitchItem(R.string.disable_status_bar, status.statusBarDisabled, + vm::setStatusBarDisabled, R.drawable.notifications_fill0) } - if(privilege.device || privilege.org) { + if (privilege.device || privilege.org) { if(VERSION.SDK_INT >= 30) { - SwitchItem(R.string.auto_time, icon = R.drawable.schedule_fill0, - getState = { Privilege.DPM.getAutoTimeEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setAutoTimeEnabled(Privilege.DAR, it) } - ) - SwitchItem(R.string.auto_timezone, icon = R.drawable.globe_fill0, - getState = { Privilege.DPM.getAutoTimeZoneEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setAutoTimeZoneEnabled(Privilege.DAR, it) } - ) + SwitchItem(R.string.auto_time, status.autoTimeEnabled, vm::setAutoTimeEnabled, + R.drawable.schedule_fill0) + SwitchItem(R.string.auto_timezone, status.autoTimeZoneEnabled, + vm::setAutoTimeZoneEnabled, R.drawable.globe_fill0) } else { - SwitchItem(R.string.require_auto_time, icon = R.drawable.schedule_fill0, - getState = { Privilege.DPM.autoTimeRequired }, - onCheckedChange = { Privilege.DPM.setAutoTimeRequired(Privilege.DAR, it) }, padding = false) + SwitchItem(R.string.require_auto_time, status.autoTimeRequired, + vm::setAutoTimeRequired, R.drawable.schedule_fill0) } } - if (!privilege.work) SwitchItem(R.string.master_mute, icon = R.drawable.volume_off_fill0, - getState = { Privilege.DPM.isMasterVolumeMuted(Privilege.DAR) }, onCheckedChange = { - Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, it) - createShortcuts(context) - } - ) - if(VERSION.SDK_INT >= 26) { + if (!privilege.work) SwitchItem(R.string.master_mute, + status.masterVolumeMuted, vm::setMasterVolumeMuted, R.drawable.volume_off_fill0) + if (VERSION.SDK_INT >= 26) { SwitchItem(R.string.backup_service, icon = R.drawable.backup_fill0, - getState = { Privilege.DPM.isBackupServiceEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setBackupServiceEnabled(Privilege.DAR, it) }, - onClickBlank = { dialog = 1 } - ) + state = status.backupServiceEnabled, onCheckedChange = vm::setBackupServiceEnabled, + onClickBlank = { dialog = 1 }) } - if(VERSION.SDK_INT >= 24 && privilege.work) { - SwitchItem(R.string.disable_bt_contact_share, icon = R.drawable.account_circle_fill0, - getState = { Privilege.DPM.getBluetoothContactSharingDisabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setBluetoothContactSharingDisabled(Privilege.DAR, it) } - ) + if (VERSION.SDK_INT >= 24 && privilege.work) { + SwitchItem(R.string.disable_bt_contact_share, status.btContactSharingDisabled, + vm::setBtContactSharingDisabled, R.drawable.account_circle_fill0) } - if(VERSION.SDK_INT >= 30 && privilege.device) { - SwitchItem(R.string.common_criteria_mode , icon =R.drawable.security_fill0, - getState = { Privilege.DPM.isCommonCriteriaModeEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setCommonCriteriaModeEnabled(Privilege.DAR, it) }, - onClickBlank = { dialog = 2 } - ) + if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { + SwitchItem(R.string.common_criteria_mode, icon = R.drawable.security_fill0, + state = status.commonCriteriaMode, + onCheckedChange = vm::setCommonCriteriaModeEnabled, + onClickBlank = { dialog = 2 }) } - if(VERSION.SDK_INT >= 31 && (privilege.device || privilege.org) && Privilege.DPM.canUsbDataSignalingBeDisabled()) { - SwitchItem( - R.string.disable_usb_signal, icon = R.drawable.usb_fill0, getState = { !Privilege.DPM.isUsbDataSignalingEnabled }, - onCheckedChange = { Privilege.DPM.isUsbDataSignalingEnabled = !it }, - ) + if (VERSION.SDK_INT >= 31 && (privilege.device || privilege.org) && status.canDisableUsbSignal) { + SwitchItem(R.string.enable_usb_signal, status.usbSignalEnabled, + vm::setUsbSignalEnabled, R.drawable.usb_fill0) } } if(dialog != 0) AlertDialog( @@ -414,23 +391,26 @@ fun SystemOptionsScreen(onNavigateUp: () -> Unit) { @Serializable object Keyguard @Composable -fun KeyguardScreen(onNavigateUp: () -> Unit) { +fun KeyguardScreen( + setKeyguardDisabled: (Boolean) -> Boolean, lock: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() MyScaffold(R.string.keyguard, onNavigateUp) { - if(VERSION.SDK_INT >= 23 && (privilege.device || (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated))) { + if (VERSION.SDK_INT >= 23 && (privilege.device || + (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated))) { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { Button( - onClick = { context.showOperationResultToast(Privilege.DPM.setKeyguardDisabled(Privilege.DAR, true)) }, + onClick = { context.showOperationResultToast(setKeyguardDisabled(true)) }, modifier = Modifier.fillMaxWidth(0.49F) ) { Text(stringResource(R.string.disable)) } Button( - onClick = { context.showOperationResultToast(Privilege.DPM.setKeyguardDisabled(Privilege.DAR, false)) }, + onClick = { context.showOperationResultToast(setKeyguardDisabled(false)) }, modifier = Modifier.fillMaxWidth(0.96F) ) { Text(stringResource(R.string.enable)) @@ -441,69 +421,61 @@ fun KeyguardScreen(onNavigateUp: () -> Unit) { } if(VERSION.SDK_INT >= 23) Text(text = stringResource(R.string.lock_now), style = typography.headlineLarge) Spacer(Modifier.padding(vertical = 2.dp)) - var flag by remember { mutableIntStateOf(0) } - if(VERSION.SDK_INT >= 26 && privilege.work) { - CheckBoxItem( - R.string.evict_credential_encryption_key, - flag and FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY != 0 - ) { flag = flag xor FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY } - Spacer(Modifier.padding(vertical = 2.dp)) - } + var evictKey by remember { mutableStateOf(false) } Button( - onClick = { - if(VERSION.SDK_INT >= 26) Privilege.DPM.lockNow(flag) else Privilege.DPM.lockNow() - }, + onClick = { lock(evictKey) }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.lock_now)) } - if(VERSION.SDK_INT >= 26 && privilege.work) { + if (VERSION.SDK_INT >= 26 && privilege.work) { + CheckBoxItem(R.string.evict_credential_encryption_key, evictKey) { evictKey = true } + Spacer(Modifier.height(5.dp)) Notes(R.string.info_evict_credential_encryption_key) } } } +data class HardwareProperties( + val temperatures: Map> = emptyMap(), + val cpuUsages: List> = emptyList(), + val fanSpeeds: List = emptyList() +) + +@RequiresApi(24) +val temperatureTypes = mapOf( + HardwarePropertiesManager.DEVICE_TEMPERATURE_CPU to R.string.cpu_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_GPU to R.string.gpu_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_BATTERY to R.string.battery_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_SKIN to R.string.skin_temp +) + @Serializable object HardwareMonitor @RequiresApi(24) @Composable -fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val hpm = context.getSystemService(HardwarePropertiesManager::class.java) +fun HardwareMonitorScreen( + hardwareProperties: StateFlow, getHardwareProperties: suspend () -> Unit, + setRefreshInterval: (Float) -> Unit, + onNavigateUp: () -> Unit +) { + val properties by hardwareProperties.collectAsStateWithLifecycle() var refreshInterval by remember { mutableFloatStateOf(1F) } val refreshIntervalMs = (refreshInterval * 1000).roundToLong() - val temperatures = remember { mutableStateMapOf>() } - val tempTypeMap = mapOf( - HardwarePropertiesManager.DEVICE_TEMPERATURE_CPU to R.string.cpu_temp, - HardwarePropertiesManager.DEVICE_TEMPERATURE_GPU to R.string.gpu_temp, - HardwarePropertiesManager.DEVICE_TEMPERATURE_BATTERY to R.string.battery_temp, - HardwarePropertiesManager.DEVICE_TEMPERATURE_SKIN to R.string.skin_temp - ) - val cpuUsages = remember { mutableStateListOf>() } - val fanSpeeds = remember { mutableStateListOf() } - fun refresh() { - cpuUsages.clear() - cpuUsages.addAll(hpm.cpuUsages.map { it.active to it.total }) - temperatures.clear() - tempTypeMap.forEach { - temperatures += it.key to hpm.getDeviceTemperatures(it.key, HardwarePropertiesManager.TEMPERATURE_CURRENT).toList() - } - fanSpeeds.clear() - fanSpeeds.addAll(hpm.fanSpeeds.toList()) - } LaunchedEffect(Unit) { - while(true) { - refresh() - delay(refreshIntervalMs) - } + getHardwareProperties() } MyScaffold(R.string.hardware_monitor, onNavigateUp) { - Text(stringResource(R.string.refresh_interval), style = typography.titleLarge, modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)) - Slider(refreshInterval, { refreshInterval = it }, valueRange = 0.5F..2F, steps = 14) + Text(stringResource(R.string.refresh_interval), Modifier.padding(top = 8.dp, bottom = 4.dp), + style = typography.titleLarge) + Slider(refreshInterval, { + refreshInterval = it + setRefreshInterval(it) + }, valueRange = 0.5F..2F, steps = 14) Text("${refreshIntervalMs}ms") Spacer(Modifier.padding(vertical = 10.dp)) - temperatures.forEach { tempMapItem -> - Text(stringResource(tempTypeMap[tempMapItem.key]!!), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) + properties.temperatures.forEach { tempMapItem -> + Text(stringResource(temperatureTypes[tempMapItem.key]!!), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) if(tempMapItem.value.isEmpty()) { Text(stringResource(R.string.unsupported)) } else { @@ -517,10 +489,10 @@ fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 10.dp)) } Text(stringResource(R.string.cpu_usages), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) - if(cpuUsages.isEmpty()) { + if (properties.cpuUsages.isEmpty()) { Text(stringResource(R.string.unsupported)) } else { - cpuUsages.forEachIndexed { index, usage -> + properties.cpuUsages.forEachIndexed { index, usage -> Row(modifier = Modifier.padding(vertical = 4.dp)) { Text(index.toString(), style = typography.titleMedium, modifier = Modifier.padding(start = 8.dp, end = 12.dp)) Column { @@ -532,10 +504,10 @@ fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { } Spacer(Modifier.padding(vertical = 10.dp)) Text(stringResource(R.string.fan_speeds), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) - if(fanSpeeds.isEmpty()) { + if (properties.fanSpeeds.isEmpty()) { Text(stringResource(R.string.unsupported)) } else { - fanSpeeds.forEachIndexed { index, speed -> + properties.fanSpeeds.forEachIndexed { index, speed -> Row(modifier = Modifier.padding(vertical = 4.dp)) { Text(index.toString(), style = typography.titleMedium, modifier = Modifier.padding(start = 8.dp, end = 12.dp)) Text("$speed RPM") @@ -550,10 +522,13 @@ fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(28) @Composable -fun ChangeTimeScreen(onNavigateUp: () -> Unit) { +fun ChangeTimeScreen(setTime: (Long) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current + var tab by remember { mutableIntStateOf(0) } val pagerState = rememberPagerState { 2 } + tab = pagerState.currentPage + val coroutine = rememberCoroutineScope() var picker by remember { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker val datePickerState = rememberDatePickerState() val timePickerState = rememberTimePickerState() @@ -561,87 +536,90 @@ fun ChangeTimeScreen(onNavigateUp: () -> Unit) { val timeInteractionSource = remember { MutableInteractionSource() } if(dateInteractionSource.collectIsPressedAsState().value) picker = 1 if(timeInteractionSource.collectIsPressedAsState().value) picker = 2 - MyScaffold(R.string.change_time, onNavigateUp) { - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.change_time)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) + ) + }, + contentWindowInsets = WindowInsets.ime + ) { paddingValues -> + Column( + Modifier + .fillMaxSize() + .padding(paddingValues) ) { - val coroutine = rememberCoroutineScope() - SegmentedButton( - selected = pagerState.targetPage == 0, shape = SegmentedButtonDefaults.itemShape(0, 2), - onClick = { - coroutine.launch { - pagerState.animateScrollToPage(0) - } - } - ) { - Text(stringResource(R.string.selector)) + TabRow(tab) { + Tab( + tab == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.selector)) } + ) + Tab( + tab == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.manually_input)) } + ) } - SegmentedButton( - selected = pagerState.targetPage == 1, shape = SegmentedButtonDefaults.itemShape(1, 2), - onClick = { - coroutine.launch { - pagerState.animateScrollToPage(1) - } - } - ) { - Text(stringResource(R.string.manually_input)) - } - } - HorizontalPager( - state = pagerState, modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top - ) { page -> - Column(Modifier.padding(top = 4.dp)) { - if(page == 0) { - OutlinedTextField( - value = datePickerState.selectedDateMillis?.humanReadableDate ?: "", - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.date)) }, - interactionSource = dateInteractionSource, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = timePickerState.hour.toString() + ":" + timePickerState.minute.toString(), - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.time)) }, - interactionSource = timeInteractionSource, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) - Button( - onClick = { - val timeMillis = datePickerState.selectedDateMillis!! + timePickerState.hour * 3600000 + timePickerState.minute * 60000 - context.showOperationResultToast(Privilege.DPM.setTime(Privilege.DAR, timeMillis)) - }, - modifier = Modifier.fillMaxWidth(), - enabled = datePickerState.selectedDateMillis != null - ) { - Text(stringResource(R.string.apply)) - } - } else { - var inputTime by remember { mutableStateOf("") } - OutlinedTextField( - value = inputTime, - label = { Text(stringResource(R.string.time_unit_ms)) }, - onValueChange = { inputTime = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() - ) - Button( - onClick = { - val timeMillis = inputTime.toLong() - context.showOperationResultToast(Privilege.DPM.setTime(Privilege.DAR, timeMillis)) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - enabled = inputTime.toLongOrNull() != null - ) { - Text(stringResource(R.string.apply)) + HorizontalPager( + pagerState, Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { page -> + Column( + Modifier + .fillMaxSize() + .padding(top = 8.dp) + .padding(horizontal = HorizontalPadding) + ) { + if(page == 0) { + OutlinedTextField( + value = datePickerState.selectedDateMillis?.humanReadableDate ?: "", + onValueChange = {}, readOnly = true, + label = { Text(stringResource(R.string.date)) }, + interactionSource = dateInteractionSource, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = timePickerState.hour.toString() + ":" + timePickerState.minute.toString(), + onValueChange = {}, readOnly = true, + label = { Text(stringResource(R.string.time)) }, + interactionSource = timeInteractionSource, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) + Button( + onClick = { + val timeMillis = datePickerState.selectedDateMillis!! + + timePickerState.hour * 3600000 + timePickerState.minute * 60000 + context.showOperationResultToast(setTime(timeMillis)) + }, + modifier = Modifier.fillMaxWidth(), + enabled = datePickerState.selectedDateMillis != null + ) { + Text(stringResource(R.string.apply)) + } + } else { + var inputTime by remember { mutableStateOf("") } + OutlinedTextField( + value = inputTime, + label = { Text(stringResource(R.string.time_unit_ms)) }, + onValueChange = { inputTime = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + context.showOperationResultToast(setTime(inputTime.toLong())) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + enabled = inputTime.toLongOrNull() != null + ) { + Text(stringResource(R.string.apply)) + } } } } @@ -672,7 +650,7 @@ fun ChangeTimeScreen(onNavigateUp: () -> Unit) { @RequiresApi(28) @Composable -fun ChangeTimeZoneScreen(onNavigateUp: () -> Unit) { +fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current var inputTimezone by remember { mutableStateOf("") } @@ -694,9 +672,10 @@ fun ChangeTimeZoneScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - context.showOperationResultToast(Privilege.DPM.setTimeZone(Privilege.DAR, inputTimezone)) + context.showOperationResultToast(setTimeZone(inputTimezone)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + enabled = inputTimezone.isNotEmpty() ) { Text(stringResource(R.string.apply)) } @@ -735,8 +714,11 @@ fun ChangeTimeZoneScreen(onNavigateUp: () -> Unit) { @RequiresApi(36) @Composable -fun AutoTimePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_time_policy, onNavigateUp, 0.dp) { - var policy by remember { mutableIntStateOf(Privilege.DPM.autoTimePolicy) } +fun AutoTimePolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) = MyScaffold(R.string.auto_time_policy, onNavigateUp, 0.dp) { + val context = LocalContext.current + var policy by remember { mutableIntStateOf(getPolicy()) } listOf( DevicePolicyManager.AUTO_TIME_ENABLED to R.string.enable, DevicePolicyManager.AUTO_TIME_DISABLED to R.string.disabled, @@ -746,10 +728,15 @@ fun AutoTimePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_ti policy = it.first } } - Button({ - Privilege.DPM.autoTimePolicy = policy - policy = Privilege.DPM.autoTimePolicy - }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { + Button( + { + setPolicy(policy) + context.showOperationResultToast(true) + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + ) { Text(stringResource(R.string.apply)) } } @@ -758,8 +745,11 @@ fun AutoTimePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_ti @RequiresApi(36) @Composable -fun AutoTimeZonePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_timezone_policy, onNavigateUp, 0.dp) { - var policy by remember { mutableIntStateOf(Privilege.DPM.autoTimeZonePolicy) } +fun AutoTimeZonePolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) = MyScaffold(R.string.auto_timezone_policy, onNavigateUp, 0.dp) { + val context = LocalContext.current + var policy by remember { mutableIntStateOf(getPolicy()) } listOf( DevicePolicyManager.AUTO_TIME_ZONE_ENABLED to R.string.enable, DevicePolicyManager.AUTO_TIME_ZONE_DISABLED to R.string.disabled, @@ -770,9 +760,11 @@ fun AutoTimeZonePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.aut } } Button({ - Privilege.DPM.autoTimeZonePolicy = policy - policy = Privilege.DPM.autoTimeZonePolicy - }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { + setPolicy(policy) + context.showOperationResultToast(true) + }, Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding)) { Text(stringResource(R.string.apply)) } } @@ -961,11 +953,11 @@ fun KeyPairs(navCtrl: NavHostController) { @RequiresApi(35) @Composable -fun ContentProtectionPolicyScreen(onNavigateUp: () -> Unit) { +fun ContentProtectionPolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var policy by remember { mutableIntStateOf(DevicePolicyManager.CONTENT_PROTECTION_NOT_CONTROLLED_BY_POLICY) } - fun refresh() { policy = Privilege.DPM.getContentProtectionPolicy(Privilege.DAR) } - LaunchedEffect(Unit) { refresh() } + var policy by remember { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.content_protection_policy, onNavigateUp, 0.dp) { mapOf( DevicePolicyManager.CONTENT_PROTECTION_NOT_CONTROLLED_BY_POLICY to R.string.not_controlled_by_policy, @@ -976,8 +968,7 @@ fun ContentProtectionPolicyScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setContentProtectionPolicy(Privilege.DAR, policy) - refresh() + setPolicy(policy) context.showOperationResultToast(true) }, modifier = Modifier @@ -994,9 +985,11 @@ fun ContentProtectionPolicyScreen(onNavigateUp: () -> Unit) { @RequiresApi(23) @Composable -fun PermissionPolicyScreen(onNavigateUp: () -> Unit) { +fun PermissionPolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var selectedPolicy by remember { mutableIntStateOf(Privilege.DPM.getPermissionPolicy(Privilege.DAR)) } + var selectedPolicy by remember { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.permission_policy, onNavigateUp, 0.dp) { FullWidthRadioButtonItem(R.string.default_stringres, selectedPolicy == PERMISSION_POLICY_PROMPT) { selectedPolicy = PERMISSION_POLICY_PROMPT @@ -1007,15 +1000,14 @@ fun PermissionPolicyScreen(onNavigateUp: () -> Unit) { FullWidthRadioButtonItem(R.string.auto_deny, selectedPolicy == PERMISSION_POLICY_AUTO_DENY) { selectedPolicy = PERMISSION_POLICY_AUTO_DENY } - Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - Privilege.DPM.setPermissionPolicy(Privilege.DAR,selectedPolicy) + setPolicy(selectedPolicy) context.showOperationResultToast(true) }, modifier = Modifier .fillMaxWidth() - .padding(horizontal = HorizontalPadding) + .padding(HorizontalPadding, 5.dp) ) { Text(stringResource(R.string.apply)) } @@ -1027,24 +1019,19 @@ fun PermissionPolicyScreen(onNavigateUp: () -> Unit) { @RequiresApi(34) @Composable -fun MtePolicyScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var selectedMtePolicy by remember { mutableIntStateOf(Privilege.DPM.mtePolicy) } +fun MtePolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Boolean, onNavigateUp: () -> Unit +) { + var policy by remember { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.mte_policy, onNavigateUp, 0.dp) { - FullWidthRadioButtonItem(R.string.decide_by_user, selectedMtePolicy == MTE_NOT_CONTROLLED_BY_POLICY) { - selectedMtePolicy = MTE_NOT_CONTROLLED_BY_POLICY + FullWidthRadioButtonItem(R.string.decide_by_user, policy == MTE_NOT_CONTROLLED_BY_POLICY) { + policy = MTE_NOT_CONTROLLED_BY_POLICY } - FullWidthRadioButtonItem(R.string.enabled, selectedMtePolicy == MTE_ENABLED) { selectedMtePolicy = MTE_ENABLED } - FullWidthRadioButtonItem(R.string.disabled, selectedMtePolicy == MTE_DISABLED) { selectedMtePolicy = MTE_DISABLED } + FullWidthRadioButtonItem(R.string.enabled, policy == MTE_ENABLED) { policy = MTE_ENABLED } + FullWidthRadioButtonItem(R.string.disabled, policy == MTE_DISABLED) { policy = MTE_DISABLED } Button( onClick = { - try { - Privilege.DPM.mtePolicy = selectedMtePolicy - context.showOperationResultToast(true) - } catch(_: java.lang.UnsupportedOperationException) { - context.popToast(R.string.unsupported) - } - selectedMtePolicy = Privilege.DPM.mtePolicy + if (!setPolicy(policy)) policy = getPolicy() }, modifier = Modifier .fillMaxWidth() @@ -1060,9 +1047,12 @@ fun MtePolicyScreen(onNavigateUp: () -> Unit) { @RequiresApi(31) @Composable -fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { +fun NearbyStreamingPolicyScreen( + getAppPolicy: () -> Int, setAppPolicy: (Int) -> Unit, getNotificationPolicy: () -> Int, + setNotificationPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var appPolicy by remember { mutableIntStateOf(Privilege.DPM.nearbyAppStreamingPolicy) } + var appPolicy by remember { mutableIntStateOf(getAppPolicy()) } MySmallTitleScaffold(R.string.nearby_streaming_policy, onNavigateUp, 0.dp) { Text( stringResource(R.string.nearby_app_streaming), @@ -1080,8 +1070,7 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { ) { appPolicy = NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY } Button( onClick = { - Privilege.DPM.nearbyAppStreamingPolicy = appPolicy - appPolicy = Privilege.DPM.nearbyAppStreamingPolicy + setAppPolicy(appPolicy) context.showOperationResultToast(true) }, modifier = Modifier @@ -1091,7 +1080,8 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.apply)) } Notes(R.string.info_nearby_app_streaming_policy, HorizontalPadding) - var notificationPolicy by remember { mutableIntStateOf(Privilege.DPM.nearbyNotificationStreamingPolicy) } + Spacer(Modifier.height(20.dp)) + var notificationPolicy by remember { mutableIntStateOf(getNotificationPolicy()) } Text( stringResource(R.string.nearby_notification_streaming), Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), style = typography.titleLarge @@ -1114,8 +1104,7 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { ) { notificationPolicy = NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY } Button( onClick = { - Privilege.DPM.nearbyNotificationStreamingPolicy = notificationPolicy - notificationPolicy = Privilege.DPM.nearbyNotificationStreamingPolicy + setNotificationPolicy(notificationPolicy) context.showOperationResultToast(true) }, modifier = Modifier @@ -1134,12 +1123,18 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { @RequiresApi(28) @Composable fun LockTaskModeScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit + chosenPackage: Channel, onChoosePackage: () -> Unit, + lockTaskPackages: StateFlow>, getLockTaskPackages: () -> Unit, + setLockTaskPackage: (String, Boolean) -> Unit, startLockTaskMode: (String, String) -> Unit, + getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String?, onNavigateUp: () -> Unit ) { val coroutine = rememberCoroutineScope() val pagerState = rememberPagerState { 3 } var tabIndex by remember { mutableIntStateOf(0) } tabIndex = pagerState.targetPage + LaunchedEffect(Unit) { + getLockTaskPackages() + } Scaffold( topBar = { TopAppBar( @@ -1170,26 +1165,12 @@ fun LockTaskModeScreen( ) } HorizontalPager(pagerState, verticalAlignment = Alignment.Top) { page -> - if(page == 0 || page == 1) { - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = HorizontalPadding) - .padding(bottom = 80.dp) - ) { - if(page == 0) StartLockTaskMode(chosenPackage, onChoosePackage) - else LockTaskPackages(chosenPackage, onChoosePackage) - } + if(page == 0) { + StartLockTaskMode(startLockTaskMode, chosenPackage, onChoosePackage) + } else if (page == 1) { + LockTaskPackages(chosenPackage, onChoosePackage, lockTaskPackages, setLockTaskPackage) } else { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(bottom = 80.dp) - ) { - LockTaskFeatures() - } + LockTaskFeatures(getLockTaskFeatures, setLockTaskFeature) } } } @@ -1198,204 +1179,182 @@ fun LockTaskModeScreen( @RequiresApi(28) @Composable -private fun ColumnScope.StartLockTaskMode( +private fun StartLockTaskMode( + startLockTaskMode: (String, String) -> Unit, chosenPackage: Channel, onChoosePackage: () -> Unit ) { - val context = LocalContext.current val focusMgr = LocalFocusManager.current - var startLockTaskApp by rememberSaveable { mutableStateOf("") } - var startLockTaskActivity by rememberSaveable { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } + var activity by rememberSaveable { mutableStateOf("") } var specifyActivity by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { - startLockTaskApp = chosenPackage.receive() + packageName = chosenPackage.receive() } - Spacer(Modifier.padding(vertical = 5.dp)) - PackageNameTextField(startLockTaskApp, onChoosePackage, - Modifier.padding(vertical = 3.dp), { startLockTaskApp = it }) - CheckBoxItem(R.string.specify_activity, specifyActivity) { specifyActivity = it } - AnimatedVisibility(specifyActivity) { - OutlinedTextField( - value = startLockTaskActivity, - onValueChange = { startLockTaskActivity = it }, - label = { Text("Activity") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .verticalScroll(rememberScrollState()) + ) { + Spacer(Modifier.height(5.dp)) + PackageNameTextField(packageName, onChoosePackage) { packageName = it } + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(specifyActivity, { + specifyActivity = it + activity = "" + }) + OutlinedTextField( + value = activity, + onValueChange = { activity = it }, + label = { Text("Activity") }, + enabled = specifyActivity, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), + modifier = Modifier.fillMaxWidth() + ) + } + Button( modifier = Modifier .fillMaxWidth() - .padding(bottom = 5.dp) - ) + .padding(bottom = 5.dp), + onClick = { + startLockTaskMode(packageName, activity) + }, + enabled = packageName.isNotBlank() && (!specifyActivity || activity.isNotBlank()) + ) { + Text(stringResource(R.string.start)) + } + Notes(R.string.info_start_lock_task_mode) } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - if(!NotificationUtils.checkPermission(context)) return@Button - if(!Privilege.DPM.isLockTaskPermitted(startLockTaskApp)) { - context.popToast(R.string.app_not_allowed) - return@Button - } - val options = ActivityOptions.makeBasic().setLockTaskEnabled(true) - val packageManager = context.packageManager - val launchIntent = if(specifyActivity) Intent().setComponent(ComponentName(startLockTaskApp, startLockTaskActivity)) - else packageManager.getLaunchIntentForPackage(startLockTaskApp) - if (launchIntent != null) { - context.startActivity(launchIntent, options.toBundle()) - } else { - context.showOperationResultToast(false) - } - }, - enabled = startLockTaskApp.isNotBlank() && (!specifyActivity || startLockTaskActivity.isNotBlank()) - ) { - Text(stringResource(R.string.start)) - } - Notes(R.string.info_start_lock_task_mode) } @RequiresApi(26) @Composable -private fun ColumnScope.LockTaskPackages( - chosenPackage: Channel, onChoosePackage: () -> Unit +private fun LockTaskPackages( + chosenPackage: Channel, onChoosePackage: () -> Unit, + lockTaskPackages: StateFlow>, setLockTaskPackage: (String, Boolean) -> Unit ) { - val context = LocalContext.current - val lockTaskPackages = remember { mutableStateListOf() } - var input by rememberSaveable { mutableStateOf("") } + val packages by lockTaskPackages.collectAsStateWithLifecycle() + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { - lockTaskPackages.addAll(Privilege.DPM.getLockTaskPackages(Privilege.DAR)) - input = chosenPackage.receive() + packageName = chosenPackage.receive() } - Spacer(Modifier.padding(vertical = 5.dp)) - if(lockTaskPackages.isEmpty()) Text(text = stringResource(R.string.none)) - for(i in lockTaskPackages) { - ListItem(i) { lockTaskPackages -= i } - } - PackageNameTextField(input, onChoosePackage, - Modifier.padding(vertical = 3.dp), { input = it }) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { - lockTaskPackages.add(input) - input = "" - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.add)) + LazyColumn { + items(packages, { it.name }) { + ApplicationItem(it) { setLockTaskPackage(it.name, false) } } - Button( - onClick = { - lockTaskPackages.remove(input) - input = "" - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.remove)) + item { + Column(Modifier + .padding(horizontal = HorizontalPadding) + .padding(bottom = 40.dp)) { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 3.dp)) { packageName = it } + Button( + onClick = { + setLockTaskPackage(packageName, true) + packageName = "" + }, + modifier = Modifier.fillMaxWidth(), + enabled = packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } + Notes(R.string.info_lock_task_packages) + } } } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - Privilege.DPM.setLockTaskPackages(Privilege.DAR, lockTaskPackages.toTypedArray()) - context.showOperationResultToast(true) - } - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_lock_task_packages) } @RequiresApi(28) @Composable -private fun ColumnScope.LockTaskFeatures() { +private fun LockTaskFeatures( + getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String? +) { val context = LocalContext.current - var flags by remember { mutableIntStateOf(0) } - var custom by rememberSaveable { mutableStateOf(false) } + var flags by remember { mutableIntStateOf(getLockTaskFeatures()) } var errorMessage by remember { mutableStateOf(null) } - fun refresh() { - flags = Privilege.DPM.getLockTaskFeatures(Privilege.DAR) - custom = flags != 0 - } - LaunchedEffect(Unit) { refresh() } - Spacer(Modifier.padding(vertical = 5.dp)) - FullWidthRadioButtonItem(R.string.disable_all, !custom) { custom = false } - FullWidthRadioButtonItem(R.string.custom, custom) { custom = true } - AnimatedVisibility(custom, Modifier.padding(top = 4.dp)) { - Column { - listOf( - DevicePolicyManager.LOCK_TASK_FEATURE_SYSTEM_INFO to R.string.ltf_sys_info, - DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS to R.string.ltf_notifications, - DevicePolicyManager.LOCK_TASK_FEATURE_HOME to R.string.ltf_home, - DevicePolicyManager.LOCK_TASK_FEATURE_OVERVIEW to R.string.ltf_overview, - DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS to R.string.ltf_global_actions, - DevicePolicyManager.LOCK_TASK_FEATURE_KEYGUARD to R.string.ltf_keyguard - ).let { - if(VERSION.SDK_INT >= 30) - it.plus(DevicePolicyManager.LOCK_TASK_FEATURE_BLOCK_ACTIVITY_START_IN_TASK to R.string.ltf_block_activity_start_in_task) - else it - }.forEach { (id, title) -> - FullWidthCheckBoxItem(title, flags and id != 0) { flags = flags xor id } - } - } - } - Button( - modifier = Modifier + Column( + Modifier .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding), - onClick = { - try { - Privilege.DPM.setLockTaskFeatures(Privilege.DAR, flags) - context.showOperationResultToast(true) - } catch (e: IllegalArgumentException) { - errorMessage = e.message - } - refresh() - } + .verticalScroll(rememberScrollState()) ) { - Text(stringResource(R.string.apply)) + Spacer(Modifier.padding(vertical = 5.dp)) + listOf( + DevicePolicyManager.LOCK_TASK_FEATURE_SYSTEM_INFO to R.string.ltf_sys_info, + DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS to R.string.ltf_notifications, + DevicePolicyManager.LOCK_TASK_FEATURE_HOME to R.string.ltf_home, + DevicePolicyManager.LOCK_TASK_FEATURE_OVERVIEW to R.string.ltf_overview, + DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS to R.string.ltf_global_actions, + DevicePolicyManager.LOCK_TASK_FEATURE_KEYGUARD to R.string.ltf_keyguard + ).let { + if(VERSION.SDK_INT >= 30) it.plus( + DevicePolicyManager.LOCK_TASK_FEATURE_BLOCK_ACTIVITY_START_IN_TASK to + R.string.ltf_block_activity_start_in_task) + else it + }.forEach { (id, title) -> + FullWidthCheckBoxItem(title, flags and id != 0) { flags = flags xor id } + } + Button( + onClick = { + val result = setLockTaskFeature(flags) + if (result == null) { + context.showOperationResultToast(true) + } else { + errorMessage = result + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = HorizontalPadding) + ) { + Text(stringResource(R.string.apply)) + } + Spacer(Modifier.height(40.dp)) + ErrorDialog(errorMessage) { errorMessage = null } } - ErrorDialog(errorMessage) { errorMessage = null } } data class CaCertInfo( val hash: String, - val data: ByteArray + val serialNumber: String, + val issuer: String, + val subject: String, + val issuedTime: String, + val expiresTime: String, + val bytes: ByteArray ) @Serializable object CaCert @OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class) @Composable -fun CaCertScreen(onNavigateUp: () -> Unit) { +fun CaCertScreen( + caCertificates: StateFlow>, getCerts: () -> Unit, + installCert: (CaCertInfo) -> Boolean, parseCert: (Uri) -> CaCertInfo?, + exportCert: (Uri, CaCertInfo) -> Unit, uninstallCert: (CaCertInfo) -> Unit, + uninstallAllCerts: () -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current /** 0:none, 1:install, 2:info, 3:uninstall all */ var dialog by remember { mutableIntStateOf(0) } - var caCertByteArray by remember { mutableStateOf(byteArrayOf()) } - val coroutine = rememberCoroutineScope() - val getCertLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {uri -> + val caCerts by caCertificates.collectAsStateWithLifecycle() + var selectedCaCert by remember { mutableStateOf(null) } + val getCertLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument()) { uri -> if(uri != null) { - uriToStream(context, uri) { - caCertByteArray = it.readBytes() - } + selectedCaCert = parseCert(uri) dialog = 1 } } - val exportCertLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument()) { uri -> - if(uri != null) { - context.contentResolver.openOutputStream(uri)?.use { - it.write(caCertByteArray) - } - context.showOperationResultToast(true) - } + val exportCertLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument()) { uri -> + if(uri != null) exportCert(uri, selectedCaCert!!) } - val caCerts = remember { mutableStateListOf() } - fun refresh() { - caCerts.clear() - coroutine.launch(Dispatchers.IO) { - val md = MessageDigest.getInstance("SHA-256") - Privilege.DPM.getInstalledCaCerts(Privilege.DAR).forEach { ba -> - val hash = md.digest(ba).toHexString() - withContext(Dispatchers.Main) { caCerts += CaCertInfo(hash, ba) } - } - } - } - LaunchedEffect(Unit) { refresh() } + LaunchedEffect(Unit) { getCerts() } Scaffold( topBar = { TopAppBar( @@ -1429,8 +1388,7 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { Modifier .fillMaxWidth() .clickable { - caCertByteArray = cert.data - dialog = 2 + selectedCaCert = cert } .animateItem() .padding(vertical = 10.dp, horizontal = 8.dp) @@ -1440,40 +1398,34 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { HorizontalDivider() } item { - if(caCerts.isEmpty()) Text(stringResource(R.string.no_ca_cert), Modifier.padding(top = 8.dp), colorScheme.onSurfaceVariant) - else Spacer(Modifier.padding(vertical = 30.dp)) + Spacer(Modifier.height(40.dp)) } } - if(dialog != 0) AlertDialog( - text = { - if(dialog == 3) Text(stringResource(R.string.uninstall_all_user_ca_cert)) - else { - var text: String - val sha256 = MessageDigest.getInstance("SHA-256").digest(caCertByteArray).toHexString() - try { - val cf = CertificateFactory.getInstance("X.509") - val cert = cf.generateCertificate(caCertByteArray.inputStream()) as X509Certificate - text = "Serial number\n" + cert.serialNumber.toString(16) + "\n\n" + - "Subject\n" + cert.subjectX500Principal.name + "\n\n" + - "Issuer\n" + cert.issuerX500Principal.name + "\n\n" + - "Issued on: " + parseDate(cert.notBefore) + "\n" + - "Expires on: " + parseDate(cert.notAfter) + "\n\n" + - "SHA-256 fingerprint" + "\n$sha256" - } catch(e: Exception) { - e.printStackTrace() - text = stringResource(R.string.parse_cert_failed) - } - Column(Modifier.verticalScroll(rememberScrollState())) { - SelectionContainer { - Text(text) - } - if(dialog == 2) Row(Modifier - .fillMaxWidth() - .padding(top = 4.dp), Arrangement.SpaceBetween) { + if (selectedCaCert != null && (dialog == 1 || dialog == 2)) { + val cert = selectedCaCert!! + AlertDialog( + text = { + Column { + Text("Serial number", style = typography.labelLarge) + SelectionContainer { Text(cert.serialNumber) } + Text("Subject", style = typography.labelLarge) + SelectionContainer { Text(cert.subject) } + Text("Issuer", style = typography.labelLarge) + SelectionContainer { Text(cert.issuer) } + Text("Issued on", style = typography.labelLarge) + SelectionContainer { Text(cert.issuedTime) } + Text("Expires on", style = typography.labelLarge) + SelectionContainer { Text(cert.expiresTime) } + Text("SHA-256 fingerprint", style = typography.labelLarge) + SelectionContainer { Text(cert.hash) } + if (dialog == 2) Row( + Modifier + .fillMaxWidth() + .padding(top = 4.dp), Arrangement.SpaceBetween + ) { TextButton( onClick = { - Privilege.DPM.uninstallCaCert(Privilege.DAR, caCertByteArray) - refresh() + uninstallCert(cert) dialog = 0 }, modifier = Modifier.fillMaxWidth(0.49F), @@ -1483,7 +1435,7 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { } FilledTonalButton( onClick = { - exportCertLauncher.launch(sha256.substring(0..7) + ".0") + exportCertLauncher.launch(cert.hash.substring(0..7) + ".0") }, modifier = Modifier.fillMaxWidth(0.96F) ) { @@ -1491,34 +1443,58 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { } } } - } - }, - confirmButton = { - TextButton({ - try { - if(dialog == 1) { - context.showOperationResultToast(Privilege.DPM.installCaCert(Privilege.DAR, caCertByteArray)) + }, + confirmButton = { + if (dialog == 1) { + TextButton({ + context.showOperationResultToast(installCert(cert)) + dialog = 0 + }) { + Text(stringResource(R.string.install)) } - if(dialog == 3) { - Privilege.DPM.uninstallAllUserCaCerts(Privilege.DAR) + } else { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.confirm)) } - refresh() - dialog = 0 - } catch(e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) } - }) { - Text(stringResource(if(dialog == 1) R.string.install else R.string.confirm)) - } - }, - dismissButton = { - if(dialog != 2) TextButton({ dialog = 0 }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { dialog = 0 } - ) + }, + dismissButton = { + if (dialog == 1) { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.cancel)) + } + } + }, + onDismissRequest = {} + ) + } + if (dialog == 3) { + AlertDialog( + text = { + Text(stringResource(R.string.uninstall_all_user_ca_cert)) + }, + confirmButton = { + TextButton({ + uninstallAllCerts() + dialog = 0 + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = 0 } + ) + } } } @@ -1602,21 +1578,19 @@ fun SecurityLoggingScreen(onNavigateUp: () -> Unit) { @Serializable object DisableAccountManagement @Composable -fun DisableAccountManagementScreen(onNavigateUp: () -> Unit) { +fun DisableAccountManagementScreen( + mdAccounts: StateFlow>, getMdAccounts: () -> Unit, + setMdAccount: (String, Boolean) -> Unit, onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current + val list by mdAccounts.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getMdAccounts() } MyScaffold(R.string.disable_account_management, onNavigateUp) { - val list = remember { mutableStateListOf() } - fun refreshList() { - list.clear() - Privilege.DPM.accountTypesWithManagementDisabled?.forEach { list += it } - } - LaunchedEffect(Unit) { refreshList() } + Column(modifier = Modifier.animateContentSize()) { - if(list.isEmpty()) Text(stringResource(R.string.none)) for(i in list) { ListItem(i) { - Privilege.DPM.setAccountManagementDisabled(Privilege.DAR, i, false) - refreshList() + setMdAccount(i, false) } } } @@ -1628,9 +1602,8 @@ fun DisableAccountManagementScreen(onNavigateUp: () -> Unit) { trailingIcon = { IconButton( onClick = { - Privilege.DPM.setAccountManagementDisabled(Privilege.DAR, inputText, true) + setMdAccount(inputText, true) inputText = "" - refreshList() }, enabled = inputText != "" ) { @@ -1648,58 +1621,53 @@ fun DisableAccountManagementScreen(onNavigateUp: () -> Unit) { } } +data class FrpPolicyInfo( + val supported: Boolean, + val usePolicy: Boolean, + val enabled: Boolean, + val accounts: List +) + @Serializable object FrpPolicy @RequiresApi(30) @Composable -fun FrpPolicyScreen(onNavigateUp: () -> Unit) { +fun FrpPolicyScreen( + getFrpPolicy: () -> FrpPolicyInfo, setFrpPolicy: (FrpPolicyInfo) -> Unit, + onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current var usePolicy by remember { mutableStateOf(false) } var enabled by remember { mutableStateOf(false) } - var unsupported by remember { mutableStateOf(false) } + var supported by remember { mutableStateOf(false) } val accountList = remember { mutableStateListOf() } var inputAccount by remember { mutableStateOf("") } LaunchedEffect(Unit) { - var policy: FactoryResetProtectionPolicy? = null - try { - policy = Privilege.DPM.getFactoryResetProtectionPolicy(Privilege.DAR) - } catch(_: UnsupportedOperationException) { - unsupported = true - policy = null - } finally { - if(policy == null) { - usePolicy = false - } else { - usePolicy = true - enabled = policy.isFactoryResetProtectionEnabled - } + val info = getFrpPolicy() + supported = info.supported + if (info.supported) { + usePolicy = info.usePolicy + enabled = info.enabled + accountList.addAll(info.accounts) } } - MyScaffold(R.string.frp_policy, onNavigateUp) { - if(unsupported) { + MyScaffold(R.string.frp_policy, onNavigateUp, 0.dp) { + if (!supported) { Column( Modifier .fillMaxWidth() - .padding(vertical = 8.dp) + .padding(HorizontalPadding, 8.dp) .clip(RoundedCornerShape(8.dp)) .background(colorScheme.primaryContainer) ) { Text(stringResource(R.string.frp_not_supported), Modifier.padding(8.dp), color = colorScheme.onPrimaryContainer) } } else { - Row( - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 6.dp, vertical = 8.dp) - ) { - Text(stringResource(R.string.use_policy), style = typography.titleLarge) - Switch(checked = usePolicy, onCheckedChange = { usePolicy = it }) - } + SwitchItem(R.string.use_policy, usePolicy, { usePolicy = it }) } - AnimatedVisibility(usePolicy) { - Column { - CheckBoxItem(R.string.enable_frp, enabled) { enabled = it } + if (usePolicy) { + FullWidthCheckBoxItem(R.string.enable_frp, enabled) { enabled = it } + Column(Modifier.padding(horizontal = HorizontalPadding)) { Text(stringResource(R.string.account_list_is)) Column(modifier = Modifier.animateContentSize()) { if(accountList.isEmpty()) Text(stringResource(R.string.none)) @@ -1727,61 +1695,65 @@ fun FrpPolicyScreen(onNavigateUp: () -> Unit) { modifier = Modifier.fillMaxWidth() ) } + Button( + onClick = { + focusMgr.clearFocus() + setFrpPolicy(FrpPolicyInfo(true, usePolicy, enabled, accountList)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.apply)) + } } - if(!unsupported) Button( - onClick = { - focusMgr.clearFocus() - val policy = FactoryResetProtectionPolicy.Builder() - .setFactoryResetProtectionEnabled(enabled) - .setFactoryResetProtectionAccounts(accountList) - .build() - Privilege.DPM.setFactoryResetProtectionPolicy(Privilege.DAR, policy) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_frp_policy) + Notes(R.string.info_frp_policy, HorizontalPadding) } } @Serializable object WipeData @Composable -fun WipeDataScreen(onNavigateUp: () -> Unit) { +fun WipeDataScreen( + wipeData: (Boolean, Int, String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager val privilege by Privilege.status.collectAsStateWithLifecycle() val focusMgr = LocalFocusManager.current - var flag by remember { mutableIntStateOf(0) } - var warning by remember { mutableStateOf(false) } - var wipeDevice by remember { mutableStateOf(false) } - var silent by remember { mutableStateOf(false) } + var flag by remember { mutableIntStateOf(WIPE_SILENTLY) } + var dialog by remember { mutableIntStateOf(0) } // 0: none, 1: wipe data, 2: wipe device var reason by remember { mutableStateOf("") } - MyScaffold(R.string.wipe_data, onNavigateUp) { - CheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { flag = flag xor WIPE_EXTERNAL_STORAGE } - if(VERSION.SDK_INT >= 22 && privilege.device) CheckBoxItem( - R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0) { flag = flag xor WIPE_RESET_PROTECTION_DATA } - if(VERSION.SDK_INT >= 28) CheckBoxItem(R.string.wipe_euicc, flag and WIPE_EUICC != 0) { flag = flag xor WIPE_EUICC } - if(VERSION.SDK_INT >= 29) CheckBoxItem(R.string.wipe_silently, silent) { silent = it } - AnimatedVisibility(!silent && VERSION.SDK_INT >= 28) { - OutlinedTextField( - value = reason, onValueChange = { reason = it }, - label = { Text(stringResource(R.string.reason)) }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp) - ) + MyScaffold(R.string.wipe_data, onNavigateUp, 0.dp) { + FullWidthCheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { + flag = flag xor WIPE_EXTERNAL_STORAGE } - Spacer(Modifier.padding(vertical = 5.dp)) - if(VERSION.SDK_INT < 34 || !userManager.isSystemUser) { + if(VERSION.SDK_INT >= 22 && privilege.device) FullWidthCheckBoxItem( + R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0) { + flag = flag xor WIPE_RESET_PROTECTION_DATA + } + if(VERSION.SDK_INT >= 28) FullWidthCheckBoxItem(R.string.wipe_euicc, + flag and WIPE_EUICC != 0) { + flag = flag xor WIPE_EUICC + } + if (VERSION.SDK_INT < 34 || !userManager.isSystemUser) { + if(VERSION.SDK_INT >= 29) CheckBoxItem(R.string.wipe_silently, flag and WIPE_SILENTLY != 0) { + flag = flag xor WIPE_SILENTLY + reason = "" + } + AnimatedVisibility(flag and WIPE_SILENTLY != 0 && VERSION.SDK_INT >= 28) { + OutlinedTextField( + value = reason, onValueChange = { reason = it }, + label = { Text(stringResource(R.string.reason)) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) + } Button( onClick = { focusMgr.clearFocus() - wipeDevice = false - warning = true + dialog = 1 }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), modifier = Modifier.fillMaxWidth() @@ -1793,8 +1765,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - wipeDevice = true - warning = true + dialog = 2 }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), modifier = Modifier.fillMaxWidth() @@ -1803,8 +1774,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { } } } - if(warning) { - LaunchedEffect(Unit) { silent = reason == "" } + if (dialog != 0) { AlertDialog( title = { Text(text = stringResource(R.string.warning), color = colorScheme.error) @@ -1818,7 +1788,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { color = colorScheme.error ) }, - onDismissRequest = { warning = false }, + onDismissRequest = { dialog = 0 }, confirmButton = { var timer by remember { mutableIntStateOf(6) } LaunchedEffect(Unit) { @@ -1830,16 +1800,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { val timerText = if(timer > 0) "(${timer}s)" else "" TextButton( onClick = { - if(silent && VERSION.SDK_INT >= 29) { flag = flag or WIPE_SILENTLY } - if(wipeDevice && VERSION.SDK_INT >= 34) { - Privilege.DPM.wipeDevice(flag) - } else { - if(VERSION.SDK_INT >= 28 && reason != "") { - Privilege.DPM.wipeData(flag, reason) - } else { - Privilege.DPM.wipeData(flag) - } - } + wipeData(dialog == 2, flag, reason) }, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error), modifier = Modifier.animateContentSize(), @@ -1849,7 +1810,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { } }, dismissButton = { - TextButton(onClick = { warning = false }) { + TextButton(onClick = { dialog = 0 }) { Text(stringResource(R.string.cancel)) } } @@ -1857,35 +1818,53 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { } } +data class SystemUpdatePolicyInfo(val type: Int, val start: Int, val end: Int) +data class PendingSystemUpdateInfo(val exists: Boolean, val time: Long, val securityPatch: Boolean) + @Serializable object SetSystemUpdatePolicy @RequiresApi(23) @Composable -fun SystemUpdatePolicyScreen(onNavigateUp: () -> Unit) { +fun SystemUpdatePolicyScreen( + getPolicy: () -> SystemUpdatePolicyInfo, setPolicy: (SystemUpdatePolicyInfo) -> Unit, + getPendingUpdate: () -> PendingSystemUpdateInfo, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current + var policyType by remember { mutableIntStateOf(-1) } + var windowedPolicyStart by remember { mutableStateOf("") } + var windowedPolicyEnd by remember { mutableStateOf("") } + var pendingUpdate by remember { mutableStateOf(PendingSystemUpdateInfo(false, 0, false)) } + LaunchedEffect(Unit) { + val policy = getPolicy() + policyType = policy.type + if (policy.type == TYPE_INSTALL_WINDOWED) { + windowedPolicyStart = policy.start.toString() + windowedPolicyEnd = policy.end.toString() + } + if (VERSION.SDK_INT >= 26) pendingUpdate = getPendingUpdate() + } MyScaffold(R.string.system_update_policy, onNavigateUp, 0.dp) { - var selectedPolicy by remember { mutableStateOf(Privilege.DPM.systemUpdatePolicy?.policyType) } + FullWidthRadioButtonItem(R.string.none, policyType == -1) { policyType = -1 } FullWidthRadioButtonItem( R.string.system_update_policy_automatic, - selectedPolicy == TYPE_INSTALL_AUTOMATIC - ) { selectedPolicy = TYPE_INSTALL_AUTOMATIC } + policyType == TYPE_INSTALL_AUTOMATIC + ) { policyType = TYPE_INSTALL_AUTOMATIC } FullWidthRadioButtonItem( R.string.system_update_policy_install_windowed, - selectedPolicy == TYPE_INSTALL_WINDOWED - ) { selectedPolicy = TYPE_INSTALL_WINDOWED } + policyType == TYPE_INSTALL_WINDOWED + ) { policyType = TYPE_INSTALL_WINDOWED } FullWidthRadioButtonItem( R.string.system_update_policy_postpone, - selectedPolicy == TYPE_POSTPONE - ) { selectedPolicy = TYPE_POSTPONE } - FullWidthRadioButtonItem(R.string.none, selectedPolicy == null) { selectedPolicy = null } - var windowedPolicyStart by remember { mutableStateOf("") } - var windowedPolicyEnd by remember { mutableStateOf("") } - AnimatedVisibility(selectedPolicy == 2) { + policyType == TYPE_POSTPONE + ) { policyType = TYPE_POSTPONE } + AnimatedVisibility(policyType == TYPE_INSTALL_WINDOWED) { Column(Modifier.padding(horizontal = HorizontalPadding)) { - Row(Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), Arrangement.SpaceBetween) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), Arrangement.SpaceBetween + ) { OutlinedTextField( value = windowedPolicyStart, label = { Text(stringResource(R.string.start_time)) }, @@ -1896,47 +1875,41 @@ fun SystemUpdatePolicyScreen(onNavigateUp: () -> Unit) { ) OutlinedTextField( value = windowedPolicyEnd, - onValueChange = {windowedPolicyEnd = it }, + onValueChange = { windowedPolicyEnd = it }, label = { Text(stringResource(R.string.end_time)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier - .fillMaxWidth(0.96F) - .padding(bottom = 2.dp) + modifier = Modifier.fillMaxWidth(0.96F) ) } - Text(stringResource(R.string.minutes_in_one_day), color = colorScheme.onSurfaceVariant, style = typography.bodyMedium) + Text(stringResource(R.string.minutes_in_one_day), + color = colorScheme.onSurfaceVariant, style = typography.bodyMedium) } } Button( onClick = { - val policy = - when(selectedPolicy) { - TYPE_INSTALL_AUTOMATIC-> SystemUpdatePolicy.createAutomaticInstallPolicy() - TYPE_INSTALL_WINDOWED-> SystemUpdatePolicy.createWindowedInstallPolicy(windowedPolicyStart.toInt(), windowedPolicyEnd.toInt()) - TYPE_POSTPONE-> SystemUpdatePolicy.createPostponeInstallPolicy() - else -> null - } - Privilege.DPM.setSystemUpdatePolicy(Privilege.DAR, policy) + setPolicy(SystemUpdatePolicyInfo( + policyType, windowedPolicyStart.toIntOrNull() ?: 0, + windowedPolicyEnd.toIntOrNull() ?: 0 + )) context.showOperationResultToast(true) }, modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding) + .padding(vertical = 4.dp, horizontal = HorizontalPadding), + enabled = policyType != TYPE_INSTALL_WINDOWED || + listOf(windowedPolicyStart, windowedPolicyEnd).map { it.toIntOrNull() } + .all { it != null && it <= 1440 } ) { Text(stringResource(R.string.apply)) } - if(VERSION.SDK_INT >= 26) { - val sysUpdateInfo = Privilege.DPM.getPendingSystemUpdate(Privilege.DAR) + if (VERSION.SDK_INT >= 26) { Column(Modifier.padding(HorizontalPadding)) { - if(sysUpdateInfo != null) { - Text(text = stringResource(R.string.update_received_time, Date(sysUpdateInfo.receivedTime))) - val securityPatchStateText = when(sysUpdateInfo.securityPatchState) { - SystemUpdateInfo.SECURITY_PATCH_STATE_FALSE -> R.string.no - SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE -> R.string.yes - else -> R.string.unknown - } - Text(text = stringResource(R.string.is_security_patch, stringResource(securityPatchStateText))) + if (pendingUpdate.exists) { + Text(stringResource(R.string.update_received_time, + parseTimestamp(pendingUpdate.time))) + Text(stringResource(R.string.is_security_patch, + stringResource(pendingUpdate.securityPatch.yesOrNo))) } else { Text(text = stringResource(R.string.no_system_update)) } @@ -1949,23 +1922,11 @@ fun SystemUpdatePolicyScreen(onNavigateUp: () -> Unit) { @SuppressLint("NewApi") @Composable -fun InstallSystemUpdateScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val callback = object: InstallSystemUpdateCallback() { - override fun onInstallUpdateError(errorCode: Int, errorMessage: String) { - super.onInstallUpdateError(errorCode, errorMessage) - val errDetail = when(errorCode) { - UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low - UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid - UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver - UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist - else -> R.string.unknown - } - val errMsg = context.getString(R.string.install_system_update_failed) + context.getString(errDetail) - context.popToast(errMsg) - } - } +fun InstallSystemUpdateScreen( + installSystemUpdate: (Uri, (String) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { var uri by remember { mutableStateOf(null) } + var installing by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri = it } MyScaffold(R.string.install_system_update, onNavigateUp) { @@ -1979,21 +1940,17 @@ fun InstallSystemUpdateScreen(onNavigateUp: () -> Unit) { ) { Text(stringResource(R.string.select_ota_package)) } - AnimatedVisibility(uri != null) { - Button( - onClick = { - val executor = Executors.newCachedThreadPool() - try { - Privilege.DPM.installSystemUpdate(Privilege.DAR, uri!!, executor, callback) - context.popToast(R.string.start_install_system_update) - } catch(e: Exception) { - errorMessage = e.message - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.install_system_update)) - } + Button( + onClick = { + installing = true + installSystemUpdate(uri!!) { message -> + errorMessage = message + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = uri != null && !installing + ) { + Text(stringResource(R.string.install_system_update)) } Spacer(Modifier.padding(vertical = 10.dp)) Notes(R.string.auto_reboot_after_install_succeed) diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt index 51447c1..e6ce051 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt @@ -233,6 +233,22 @@ fun SwitchItem( } } +@Composable +fun SwitchItem( + title: Int, state: Boolean, onCheckedChange: (Boolean) -> Unit, icon: Int? = null +) { + Row( + Modifier.fillMaxWidth().padding(25.dp, 5.dp, 15.dp, 5.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + if (icon != null) Icon(painterResource(icon), null, Modifier.padding(end = 20.dp)) + Text(stringResource(title), style = typography.titleLarge) + } + Switch(state, onCheckedChange, Modifier.padding(start = 10.dp)) + } +} + @Composable fun InfoItem(title: Int, text: Int, withInfo: Boolean = false, onClick: () -> Unit = {}) = InfoItem(title, stringResource(text), withInfo, onClick) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6e2ede2..3f7533d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -129,7 +129,7 @@ Служба резервного копирования Отключить обмен контактами по Bluetooth Режим общих критериев - Отключить USB-сигнал + Enable USB signal Блокировка экрана (Keyguard) Заблокировать сейчас Удалить ключ шифрования учетных данных diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cff76b6..c95bc0d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -138,7 +138,7 @@ Yedekleme Servisi Bluetooth Kişi Paylaşımını Devre Dışı Bırak Ortak Kriterler Modu - USB Sinyalini Devre Dışı Bırak + Enable USB signal Kilit Ekranı Ekranı Şimdi Kilitle Kilit Ekranı diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index abd9192..ce5eda5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -135,7 +135,7 @@ 备份服务 禁止蓝牙分享联系人 通用标准模式 - 禁用USB信号 + 启用USB信号 锁屏 立即锁屏 锁屏 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b963b9..da76e17 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -144,7 +144,7 @@ Backup service Disable bluetooth contact sharing Common criteria mode - Disable USB signal + Enable USB signal Keyguard Lock screen now Lock screen From 2c72912ea68a88d702a429180020818924bd6e0c Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Tue, 23 Sep 2025 20:41:22 +0800 Subject: [PATCH 05/26] ViewModel refactoring: Permissions part Add MyDbHelper and MyRepository, use database to store dhizuku clients, fix #168 --- .../owndroid/AppInstallerActivity.kt | 4 +- .../com/bintianqi/owndroid/DhizukuServer.kt | 25 +- .../com/bintianqi/owndroid/MainActivity.kt | 37 +- .../com/bintianqi/owndroid/MyApplication.kt | 4 +- .../java/com/bintianqi/owndroid/MyDbHelper.kt | 15 + .../com/bintianqi/owndroid/MyRepository.kt | 46 ++ .../com/bintianqi/owndroid/MyViewModel.kt | 209 +++++ .../java/com/bintianqi/owndroid/Privilege.kt | 1 + .../com/bintianqi/owndroid/dpm/Permissions.kt | 723 +++++++----------- .../java/com/bintianqi/owndroid/dpm/System.kt | 2 - .../com/bintianqi/owndroid/ui/Components.kt | 2 +- 11 files changed, 588 insertions(+), 480 deletions(-) create mode 100644 app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt create mode 100644 app/src/main/java/com/bintianqi/owndroid/MyRepository.kt diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt index 5d0d8a1..ca6b948 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt @@ -7,7 +7,6 @@ import androidx.activity.viewModels import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.ui.AppInstaller import com.bintianqi.owndroid.ui.theme.OwnDroidTheme @@ -15,11 +14,10 @@ class AppInstallerActivity:FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - val myVm by viewModels() val vm by viewModels() vm.initialize(intent) + val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) setContent { - val theme by myVm.theme.collectAsStateWithLifecycle() OwnDroidTheme(theme) { val uiState by vm.uiState.collectAsState() AppInstaller( diff --git a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt index 6b8aa5b..efe379f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt +++ b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt @@ -9,7 +9,6 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog @@ -24,7 +23,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.ui.theme.OwnDroidTheme import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.rosan.dhizuku.aidl.IDhizukuClient @@ -34,12 +32,9 @@ import com.rosan.dhizuku.server_api.DhizukuService import com.rosan.dhizuku.shared.DhizukuVariables import kotlinx.coroutines.delay import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json private const val TAG = "DhizukuServer" -const val DHIZUKU_CLIENTS_FILE = "dhizuku_clients.json" - class MyDhizukuProvider(): DhizukuProvider() { override fun onCreateService(client: IDhizukuClient): DhizukuService? { Log.d(TAG, "Creating MyDhizukuService") @@ -56,8 +51,6 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC pm.getNameForUid(callingUid) ?: return false, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES ) - val file = mContext.filesDir.resolve(DHIZUKU_CLIENTS_FILE) - val clients = Json.decodeFromString>(file.readText()) val signature = getPackageSignature(packageInfo) val requiredPermission = when (func) { "remote_transact", "remote_process" -> func @@ -65,9 +58,10 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC "get_delegated_scopes", "set_delegated_scopes" -> "delegated_scopes" else -> "other" } - val hasPermission = clients.find { - callingUid == it.uid && signature == it.signature && requiredPermission in it.permissions - } != null + val hasPermission = (mContext.applicationContext as MyApplication).myRepo + .checkDhizukuClientPermission( + callingUid, signature, requiredPermission + ) Log.d(TAG, "UID $callingUid, PID $callingPid, required permission: $requiredPermission, has permission: $hasPermission") return hasPermission } @@ -97,26 +91,19 @@ class DhizukuActivity : ComponentActivity() { val icon = appInfo.loadIcon(packageManager) val label = appInfo.loadLabel(packageManager).toString() fun close(grantPermission: Boolean) { - val file = filesDir.resolve(DHIZUKU_CLIENTS_FILE) - val json = Json { ignoreUnknownKeys = true } - val clients = json.decodeFromString>(file.readText()) - val index = clients.indexOfFirst { it.uid == uid } val clientInfo = DhizukuClientInfo( uid, getPackageSignature(packageInfo), if (grantPermission) DhizukuPermissions else emptyList() ) - if (index == -1) clients += clientInfo - else clients[index] = clientInfo - file.writeText(Json.encodeToString(clients)) + (application as MyApplication).myRepo.setDhizukuClient(clientInfo) finish() listener.onRequestPermission( if (grantPermission) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED ) } - val vm by viewModels() enableEdgeToEdge() + val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) setContent { var appLockDialog by remember { mutableStateOf(false) } - val theme by vm.theme.collectAsStateWithLifecycle() OwnDroidTheme(theme) { if (!appLockDialog) AlertDialog( icon = { diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index d4d58bd..7792da0 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -293,23 +293,40 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { ) { composable { HomeScreen(::navigate) } composable { - WorkModesScreen(it.toRoute(), ::navigateUp, { + WorkModesScreen(vm, it.toRoute(), ::navigateUp, { navController.navigate(Home) { popUpTo { inclusive = true } } + }, { + navController.navigate(WorkModes(false)) { + popUpTo(Home) { inclusive = true } + } }, ::navigate) } - composable { DhizukuServerSettingsScreen(::navigateUp) } - - composable { DelegatedAdminsScreen(::navigateUp, ::navigate) } - composable{ - AddDelegatedAdminScreen(vm.chosenPackage, ::choosePackage, it.toRoute(), ::navigateUp) + composable { + DhizukuServerSettingsScreen(vm.dhizukuClients, vm::getDhizukuClients, + vm::updateDhizukuClient, vm::getDhizukuServerEnabled, vm::setDhizukuServerEnabled, + ::navigateUp) + } + + composable { + DelegatedAdminsScreen(vm.delegatedAdmins, vm::getDelegatedAdmins, ::navigateUp, ::navigate) + } + composable{ + AddDelegatedAdminScreen(vm.chosenPackage, ::choosePackage, it.toRoute(), + vm::setDelegatedAdmin, ::navigateUp) + } + composable { DeviceInfoScreen(vm, ::navigateUp) } + composable { + LockScreenInfoScreen(vm::getLockScreenInfo, vm::setLockScreenInfo, ::navigateUp) + } + composable { + SupportMessageScreen(vm::getShortSupportMessage, vm::getLongSupportMessage, + vm::setShortSupportMessage, vm::setLongSupportMessage, ::navigateUp) } - composable { DeviceInfoScreen(::navigateUp) } - composable { LockScreenInfoScreen(::navigateUp) } - composable { SupportMessageScreen(::navigateUp) } composable { - TransferOwnershipScreen(::navigateUp) { + TransferOwnershipScreen(vm.deviceAdminReceivers, vm::getDeviceAdminReceivers, + vm::transferOwnership, ::navigateUp) { navController.navigate(WorkModes(false)) { popUpTo(Home) { inclusive = true } } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt index 9372942..9093ec7 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt @@ -5,12 +5,14 @@ import android.os.Build.VERSION import org.lsposed.hiddenapibypass.HiddenApiBypass class MyApplication : Application() { + lateinit var myRepo: MyRepository override fun onCreate() { super.onCreate() if (VERSION.SDK_INT >= 28) HiddenApiBypass.setHiddenApiExemptions("") SP = SharedPrefs(applicationContext) + val dbHelper = MyDbHelper(this) + myRepo = MyRepository(dbHelper) Privilege.initialize(applicationContext) - Privilege.updateStatus() } } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt new file mode 100644 index 0000000..bb4bdf6 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -0,0 +1,15 @@ +package com.bintianqi.owndroid + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 1) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + + "signature TEXT, permissions TEXT)") + } + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt new file mode 100644 index 0000000..0f08b0c --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt @@ -0,0 +1,46 @@ +package com.bintianqi.owndroid + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase + +class MyRepository(val dbHelper: MyDbHelper) { + fun getDhizukuClients(): List { + val list = mutableListOf() + dbHelper.readableDatabase.rawQuery("SELECT * FROM dhizuku_clients", null).use { cursor -> + while (cursor.moveToNext()) { + list += DhizukuClientInfo( + cursor.getInt(0), cursor.getString(1), + cursor.getString(2).split(",").filter { it.isNotEmpty() } + ) + } + } + return list + } + fun checkDhizukuClientPermission(uid: Int, signature: String?, permission: String): Boolean { + val cursor = if (signature == null) { + dbHelper.readableDatabase.rawQuery( + "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature IS NULL", + null + ) + } else { + dbHelper.readableDatabase.rawQuery( + "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature = ?", + arrayOf(signature) + ) + } + return cursor.use { + it.moveToNext() && permission in it.getString(0).split(",") + } + } + fun setDhizukuClient(info: DhizukuClientInfo) { + val cv = ContentValues() + cv.put("uid", info.uid) + cv.put("signature", info.signature) + cv.put("permissions", info.permissions.joinToString(",")) + dbHelper.writableDatabase.insertWithOnConflict("dhizuku_clients", null, cv, + SQLiteDatabase.CONFLICT_REPLACE) + } + fun deleteDhizukuClient(info: DhizukuClientInfo) { + dbHelper.writableDatabase.delete("dhizuku_clients", "uid = ${info.uid}", null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 3e1f743..782dcea 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -3,6 +3,8 @@ package com.bintianqi.owndroid import android.app.ActivityOptions import android.app.Application import android.app.PendingIntent +import android.app.admin.DeviceAdminInfo +import android.app.admin.DeviceAdminReceiver import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback import android.app.admin.FactoryResetProtectionPolicy @@ -30,18 +32,25 @@ import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import com.bintianqi.owndroid.Privilege.DAR import com.bintianqi.owndroid.Privilege.DPM +import com.bintianqi.owndroid.dpm.ACTIVATE_DEVICE_OWNER_COMMAND import com.bintianqi.owndroid.dpm.AppStatus import com.bintianqi.owndroid.dpm.CaCertInfo +import com.bintianqi.owndroid.dpm.DelegatedAdmin +import com.bintianqi.owndroid.dpm.DeviceAdmin import com.bintianqi.owndroid.dpm.FrpPolicyInfo import com.bintianqi.owndroid.dpm.HardwareProperties import com.bintianqi.owndroid.dpm.PendingSystemUpdateInfo import com.bintianqi.owndroid.dpm.SystemOptionsStatus import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo +import com.bintianqi.owndroid.dpm.delegatedScopesList import com.bintianqi.owndroid.dpm.getPackageInstaller import com.bintianqi.owndroid.dpm.isValidPackageName import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage import com.bintianqi.owndroid.dpm.permissionList import com.bintianqi.owndroid.dpm.temperatureTypes +import com.rosan.dhizuku.api.Dhizuku +import com.rosan.dhizuku.api.DhizukuRequestPermissionListener +import com.topjohnwu.superuser.Shell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -56,6 +65,7 @@ import java.security.cert.X509Certificate import java.util.concurrent.Executors class MyViewModel(application: Application): AndroidViewModel(application) { + val myRepo = getApplication().myRepo val PM = application.packageManager val theme = MutableStateFlow(ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme)) fun changeTheme(newTheme: ThemeSettings) { @@ -787,6 +797,205 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } DPM.installSystemUpdate(DAR, uri, application.mainExecutor, callback) } + + @RequiresApi(24) + fun isCreatingWorkProfileAllowed(): Boolean { + return DPM.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) + } + fun activateDoByShizuku(callback: (Boolean, String?) -> Unit) { + viewModelScope.launch { + useShizuku(application) { service -> + try { + val result = IUserService.Stub.asInterface(service) + .execute(ACTIVATE_DEVICE_OWNER_COMMAND) + if (result == null || result.getInt("code", -1) != 0) { + callback(false, null) + } else { + Privilege.updateStatus() + callback( + true, result.getString("output") + "\n" + result.getString("error") + ) + } + } catch (e: Exception) { + e.printStackTrace() + callback(false, null) + } + } + } + } + fun activateDoByRoot(callback: (Boolean, String?) -> Unit) { + Shell.getShell { shell -> + if(shell.isRoot) { + val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec() + val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n") + Privilege.updateStatus() + callback(result.isSuccess, output) + } else { + callback(false, application.getString(R.string.permission_denied)) + } + } + } + @RequiresApi(28) + fun activateDoByDhizuku(callback: (Boolean, String?) -> Unit) { + DPM.transferOwnership(DAR, MyAdminComponent, null) + SP.dhizuku = false + Privilege.initialize(application) + callback(true, null) + } + fun activateDhizukuMode(callback: (Boolean, String?) -> Unit) { + fun onSucceed() { + SP.dhizuku = true + Privilege.initialize(application) + callback(true, null) + } + if (Dhizuku.init(application)) { + if (Dhizuku.isPermissionGranted()) { + onSucceed() + } else { + Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { + override fun onRequestPermission(grantResult: Int) { + if(grantResult == PackageManager.PERMISSION_GRANTED) onSucceed() + } + }) + } + } else { + callback(false, application.getString(R.string.failed_to_init_dhizuku)) + } + } + fun clearDeviceOwner() { + DPM.clearDeviceOwnerApp(application.packageName) + } + @RequiresApi(24) + fun clearProfileOwner() { + DPM.clearProfileOwner(MyAdminComponent) + } + fun deactivateDhizukuMode() { + SP.dhizuku = false + Privilege.initialize(application) + } + val dhizukuClients = MutableStateFlow(emptyList>()) + fun getDhizukuClients() { + viewModelScope.launch { + dhizukuClients.value = myRepo.getDhizukuClients().mapNotNull { + val packageName = PM.getNameForUid(it.uid) + if (packageName == null) { + myRepo.deleteDhizukuClient(it) + null + } else { + it to getAppInfo(packageName) + } + } + } + } + fun getDhizukuServerEnabled(): Boolean { + return SP.dhizukuServer + } + fun setDhizukuServerEnabled(status: Boolean) { + SP.dhizukuServer = status + } + fun updateDhizukuClient(info: DhizukuClientInfo) { + myRepo.setDhizukuClient(info) + dhizukuClients.update { list -> + val ml = list.toMutableList() + val index = ml.indexOfFirst { it.first.uid == info.uid } + ml[index] = info to ml[index].second + ml + } + } + @RequiresApi(24) + fun getLockScreenInfo(): String { + return DPM.deviceOwnerLockScreenInfo?.toString() ?: "" + } + @RequiresApi(24) + fun setLockScreenInfo(text: String) { + DPM.setDeviceOwnerLockScreenInfo(DAR, text) + } + val delegatedAdmins = MutableStateFlow(emptyList()) + @RequiresApi(26) + fun getDelegatedAdmins() { + val list = mutableListOf() + delegatedScopesList.forEach { scope -> + DPM.getDelegatePackages(DAR, scope.id)?.forEach { pkg -> + val index = list.indexOfFirst { it.app.name == pkg } + if (index == -1) { + list += DelegatedAdmin(getAppInfo(pkg), listOf(scope.id)) + } else { + list[index] = DelegatedAdmin(list[index].app, list[index].scopes + scope.id) + } + } + } + delegatedAdmins.value = list + } + @RequiresApi(26) + fun setDelegatedAdmin(name: String, scopes: List) { + DPM.setDelegatedScopes(DAR, name, scopes) + getDelegatedAdmins() + } + @RequiresApi(34) + fun getDeviceFinanced(): Boolean { + return DPM.isDeviceFinanced + } + @RequiresApi(33) + fun getDpmRh(): String? { + return DPM.devicePolicyManagementRoleHolderPackage + } + fun getStorageEncryptionStatus(): Int { + return DPM.storageEncryptionStatus + } + @RequiresApi(28) + fun getDeviceIdAttestationSupported(): Boolean { + return DPM.isDeviceIdAttestationSupported + } + @RequiresApi(30) + fun getUniqueDeviceAttestationSupported(): Boolean { + return DPM.isUniqueDeviceAttestationSupported + } + fun getActiveAdmins(): String { + return DPM.activeAdmins?.joinToString("\n") { + it.flattenToShortString() + } ?: application.getString(R.string.none) + } + @RequiresApi(24) + fun getShortSupportMessage(): String { + return DPM.getShortSupportMessage(DAR)?.toString() ?: "" + } + @RequiresApi(24) + fun getLongSupportMessage(): String { + return DPM.getLongSupportMessage(DAR)?.toString() ?: "" + } + @RequiresApi(24) + fun setShortSupportMessage(text: String?) { + DPM.setShortSupportMessage(DAR, text) + } + @RequiresApi(24) + fun setLongSupportMessage(text: String?) { + DPM.setLongSupportMessage(DAR, text) + } + val deviceAdminReceivers = MutableStateFlow(emptyList()) + fun getDeviceAdminReceivers() { + viewModelScope.launch { + deviceAdminReceivers.value = PM.queryBroadcastReceivers( + Intent(DeviceAdminReceiver.ACTION_DEVICE_ADMIN_ENABLED), + PackageManager.GET_META_DATA + ).mapNotNull { + try { + DeviceAdminInfo(application, it) + } catch(_: Exception) { + null + } + }.filter { + it.isVisible && it.packageName != "com.bintianqi.owndroid" && + it.activityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 0 + }.map { + DeviceAdmin(getAppInfo(it.packageName), it.component) + } + } + } + @RequiresApi(28) + fun transferOwnership(component: ComponentName) { + DPM.transferOwnership(DAR, component, null) + Privilege.updateStatus() + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt index 242a16d..6ba405b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt @@ -31,6 +31,7 @@ object Privilege { } DPM = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager DAR = MyAdminComponent + updateStatus() } lateinit var DPM: DevicePolicyManager private set diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt index eb53845..9982458 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt @@ -2,13 +2,8 @@ package com.bintianqi.owndroid.dpm import android.app.admin.DevicePolicyManager import android.content.ComponentName -import android.content.Context -import android.content.pm.PackageManager import android.os.Build.VERSION -import android.os.PersistableBundle -import androidx.annotation.Keep import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image @@ -24,9 +19,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions @@ -57,6 +54,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -67,10 +65,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -85,15 +82,13 @@ import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.DHIZUKU_CLIENTS_FILE +import com.bintianqi.owndroid.AppInfo import com.bintianqi.owndroid.DhizukuClientInfo import com.bintianqi.owndroid.DhizukuPermissions import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.IUserService -import com.bintianqi.owndroid.MyAdminComponent +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.SP import com.bintianqi.owndroid.Settings import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog @@ -104,36 +99,32 @@ import com.bintianqi.owndroid.ui.MySmallTitleScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem -import com.bintianqi.owndroid.useShizuku import com.bintianqi.owndroid.yesOrNo import com.google.accompanist.drawablepainter.rememberDrawablePainter -import com.rosan.dhizuku.api.Dhizuku -import com.rosan.dhizuku.api.DhizukuRequestPermissionListener -import com.topjohnwu.superuser.Shell import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class WorkModes(val canNavigateUp: Boolean) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun WorkModesScreen( - params: WorkModes, onNavigateUp: () -> Unit, onActivate: () -> Unit, onNavigate: (Any) -> Unit + vm: MyViewModel, params: WorkModes, onNavigateUp: () -> Unit, onActivate: () -> Unit, + onDeactivate: () -> Unit, onNavigate: (Any) -> Unit ) { - val context = LocalContext.current - val coroutine = rememberCoroutineScope() val privilege by Privilege.status.collectAsStateWithLifecycle() /** 0: none, 1: device owner, 2: circular progress indicator, 3: result, 4: deactivate, 5: command */ var dialog by remember { mutableIntStateOf(0) } var operationSucceed by remember { mutableStateOf(false) } + var resultText by remember { mutableStateOf("") } LaunchedEffect(privilege) { if (!params.canNavigateUp && privilege.device) { delay(1000) if (dialog != 3) { // Activated by ADB command operationSucceed = true + resultText = "" dialog = 3 } } @@ -193,90 +184,36 @@ fun WorkModesScreen( }, contentWindowInsets = WindowInsets.ime ) { paddingValues -> - var navigateUpOnSucceed by remember { mutableStateOf(true) } - var resultText by remember { mutableStateOf("") } - fun handleResult(succeeded: Boolean, activateSucceeded: Boolean, output: String?) { - if(succeeded) { - operationSucceed = activateSucceeded - resultText = output ?: "" - dialog = 3 - Privilege.updateStatus() - } else { - dialog = 0 - context.showOperationResultToast(false) - } + fun handleResult(succeeded: Boolean, output: String?) { + operationSucceed = succeeded + resultText = output ?: "" + dialog = 3 } - Column(Modifier - .fillMaxSize() - .padding(paddingValues)) { - if(!privilege.profile && (VERSION.SDK_INT >= 28 || !privilege.dhizuku)) Row( - Modifier - .fillMaxWidth() - .clickable(!privilege.device || privilege.dhizuku) { dialog = 1 } - .background(if (privilege.device) colorScheme.primaryContainer else Color.Transparent) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Column { - Text(stringResource(R.string.device_owner), style = typography.titleLarge) - if(!privilege.device || privilege.dhizuku) Text( - stringResource(R.string.recommended), color = colorScheme.primary, style = typography.labelLarge - ) - } - Icon( - if(privilege.device) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, - tint = if(privilege.device) colorScheme.primary else colorScheme.onBackground - ) - } - if(privilege.profile) Row( - Modifier - .fillMaxWidth() - .background(colorScheme.primaryContainer) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Column { - Text(stringResource(R.string.profile_owner), style = typography.titleLarge) - } - Icon(Icons.Default.Check, null, tint = colorScheme.primary) - } - if(privilege.dhizuku || !(privilege.device || privilege.profile)) Row( - Modifier - .fillMaxWidth() - .clickable(!privilege.dhizuku) { - dialog = 2 - activateDhizukuMode(context, ::handleResult) + Column(Modifier.fillMaxSize().padding(paddingValues)) { + if (!privilege.profile) { + WorkingModeItem(R.string.device_owner, privilege.device) { + if (!privilege.device || (VERSION.SDK_INT >= 28 && privilege.dhizuku)) { + dialog = 1 } - .background(if (privilege.dhizuku) colorScheme.primaryContainer else Color.Transparent) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Text(stringResource(R.string.dhizuku), style = typography.titleLarge) - Icon( - if(privilege.dhizuku) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, - tint = if(privilege.dhizuku) colorScheme.primary else colorScheme.onBackground - ) + } + } + if (privilege.profile) WorkingModeItem(R.string.profile_owner, true) { } + if (privilege.dhizuku || !privilege.activated) { + WorkingModeItem(R.string.dhizuku, privilege.dhizuku) { + if (!privilege.dhizuku) { + dialog = 2 + vm.activateDhizukuMode(::handleResult) + } + } } if( - privilege.work || (VERSION.SDK_INT < 24 || - Privilege.DPM.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE)) - ) Row( - Modifier - .fillMaxWidth() - .clickable(!privilege.work) { onNavigate(CreateWorkProfile) } - .background(if (privilege.device) colorScheme.primaryContainer else Color.Transparent) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically + privilege.work || (VERSION.SDK_INT < 24 || vm.isCreatingWorkProfileAllowed()) ) { - Column { - Text(stringResource(R.string.work_profile), style = typography.titleLarge) + WorkingModeItem(R.string.work_profile, privilege.work) { + if (!privilege.work) onNavigate(CreateWorkProfile) } - Icon( - if(privilege.work) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, - tint = if(privilege.device) colorScheme.primary else colorScheme.onBackground - ) } - if ((privilege.device || privilege.profile) && !privilege.dhizuku) Row( + if (privilege.activated && !privilege.dhizuku) Row( Modifier .padding(top = 20.dp) .fillMaxWidth() @@ -302,27 +239,29 @@ fun WorkModesScreen( title = { Text(stringResource(R.string.activate_method)) }, text = { FlowRow(Modifier.fillMaxWidth()) { - if(!privilege.dhizuku) Button({ - dialog = 2 - coroutine.launch { - activateUsingShizuku(context, ::handleResult) + if (!privilege.dhizuku) { + Button({ dialog = 5 }, Modifier.padding(end = 8.dp)) { + Text(stringResource(R.string.adb_command)) + } + Button({ + dialog = 2 + vm.activateDoByShizuku(::handleResult) + }, Modifier.padding(end = 8.dp)) { + Text(stringResource(R.string.shizuku)) + } + Button({ + dialog = 2 + vm.activateDoByRoot(::handleResult) + }, Modifier.padding(end = 8.dp)) { + Text("Root") } - }, Modifier.padding(end = 8.dp)) { - Text(stringResource(R.string.shizuku)) } - if(!privilege.dhizuku) Button({ + if (VERSION.SDK_INT >= 28 && privilege.dhizuku) Button({ dialog = 2 - activateUsingRoot(context, ::handleResult) - }, Modifier.padding(end = 8.dp)) { - Text("Root") - } - if(VERSION.SDK_INT >= 28) Button({ - dialog = 2 - activateUsingDhizuku(context, ::handleResult) + vm.activateDoByDhizuku(::handleResult) }, Modifier.padding(end = 8.dp)) { Text(stringResource(R.string.dhizuku)) } - if (!privilege.dhizuku) Button({ dialog = 5 }) { Text(stringResource(R.string.adb_command)) } } }, confirmButton = { @@ -334,16 +273,14 @@ fun WorkModesScreen( if(dialog == 3) AlertDialog( title = { Text(stringResource(if(operationSucceed) R.string.succeeded else R.string.failed)) }, text = { - Column(Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState())) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { Text(resultText) } }, confirmButton = { TextButton({ dialog = 0 - if(navigateUpOnSucceed && operationSucceed && !params.canNavigateUp) onActivate() + if (operationSucceed && !params.canNavigateUp) onActivate() }) { Text(stringResource(R.string.confirm)) } @@ -365,18 +302,17 @@ fun WorkModesScreen( TextButton( { if(privilege.dhizuku) { - SP.dhizuku = false - Privilege.initialize(context) - Privilege.updateStatus() + vm.deactivateDhizukuMode() } else { if(privilege.device) { - Privilege.DPM.clearDeviceOwnerApp(context.packageName) + vm.clearDeviceOwner() } else if(VERSION.SDK_INT >= 24) { - Privilege.DPM.clearProfileOwner(MyAdminComponent) + vm.clearProfileOwner() } // Status updated in Receiver.onDisabled() } dialog = 0 + onDeactivate() }, enabled = time == 0, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) @@ -403,94 +339,21 @@ fun WorkModesScreen( } } -fun activateUsingShizuku(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - useShizuku(context) { service -> - try { - val result = IUserService.Stub.asInterface(service).execute(ACTIVATE_DEVICE_OWNER_COMMAND) - if (result == null) { - callback(false, false, null) - } else { - callback( - true, result.getInt("code", -1) == 0, - result.getString("output") + "\n" + result.getString("error") - ) - } - } catch (e: Exception) { - callback(false, false, null) - e.printStackTrace() - } - } -} - -fun activateUsingRoot(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - Shell.getShell { shell -> - if(shell.isRoot) { - val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec() - val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n") - callback(true, result.isSuccess, output) - } else { - callback(true, false, context.getString(R.string.permission_denied)) - } - } -} - -@RequiresApi(28) -fun activateUsingDhizuku(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - fun doTransfer() { - try { - if (SP.dhizuku) { - Privilege.DPM.transferOwnership(Privilege.DAR, MyAdminComponent, PersistableBundle()) - SP.dhizuku = false - Privilege.initialize(context) - } else { - val dpm = binderWrapperDevicePolicyManager(context) - if (dpm == null) { - callback(false, false, null) - return - } else { - dpm.transferOwnership(Dhizuku.getOwnerComponent(), MyAdminComponent, PersistableBundle()) - } - } - callback(true, true, null) - } catch (e: Exception) { - e.printStackTrace() - callback(false, false, null) - } - } - if(Dhizuku.init(context)) { - if(Dhizuku.isPermissionGranted()) { - doTransfer() - } else { - Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { - override fun onRequestPermission(grantResult: Int) { - if(grantResult == PackageManager.PERMISSION_GRANTED) doTransfer() - else callback(false, false, null) - } - }) - } - } else { - callback(true, false, context.getString(R.string.failed_to_init_dhizuku)) - } -} - -fun activateDhizukuMode(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - fun onSucceed() { - SP.dhizuku = true - Privilege.initialize(context) - callback(true, true, null) - } - if(Dhizuku.init(context)) { - if(Dhizuku.isPermissionGranted()) { - onSucceed() - } else { - Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { - override fun onRequestPermission(grantResult: Int) { - if(grantResult == PackageManager.PERMISSION_GRANTED) onSucceed() - } - }) - } - } else { - callback(true, false, context.getString(R.string.failed_to_init_dhizuku)) +@Composable +fun WorkingModeItem(text: Int, active: Boolean, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(if (active) colorScheme.primaryContainer else Color.Transparent) + .padding(HorizontalPadding, 10.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(text), style = typography.titleLarge) + Icon( + if(active) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, + tint = if(active) colorScheme.primary else colorScheme.onBackground + ) } } @@ -499,98 +362,81 @@ const val ACTIVATE_DEVICE_OWNER_COMMAND = "dpm set-device-owner com.bintianqi.ow @Serializable object DhizukuServerSettings @Composable -fun DhizukuServerSettingsScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val pm = context.packageManager - val file = context.filesDir.resolve(DHIZUKU_CLIENTS_FILE) - var enabled by remember { mutableStateOf(SP.dhizukuServer) } - val clients = remember { mutableStateListOf() } - fun changeEnableState(status: Boolean) { - enabled = status - SP.dhizukuServer = status - } - fun writeList() { - file.writeText(Json.encodeToString(clients.toList())) - } - LaunchedEffect(Unit) { - if (!file.exists()) file.writeText("[]") - } - LaunchedEffect(enabled) { - if (enabled) { - clients.clear() - val json = Json { ignoreUnknownKeys = true } - clients.addAll(json.decodeFromString>(file.readText())) - } - } +fun DhizukuServerSettingsScreen( + dhizukuClients: StateFlow>>, + getDhizukuClients: () -> Unit, updateDhizukuClient: (DhizukuClientInfo) -> Unit, + getServerEnabled: () -> Boolean, setServerEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { + var enabled by remember { mutableStateOf(getServerEnabled()) } + val clients by dhizukuClients.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getDhizukuClients() } MyLazyScaffold(R.string.dhizuku_server, onNavigateUp) { item { - SwitchItem(R.string.enable, getState = { SP.dhizukuServer }, onCheckedChange = ::changeEnableState) + SwitchItem(R.string.enable, enabled, { + setServerEnabled(it) + enabled = it + }) HorizontalDivider(Modifier.padding(vertical = 8.dp)) } - if (enabled) itemsIndexed(clients) { index, client -> - val name = pm.getNameForUid(client.uid) - if (name == null) { - clients.dropWhile { it.uid == client.uid } - writeList() - } else { - val info = pm.getApplicationInfo(name, 0) - var expand by remember { mutableStateOf(false) } - Card( + if (enabled) items(clients) { (client, app) -> + var expand by remember { mutableStateOf(false) } + Card( + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp) + ) { + Row( Modifier .fillMaxWidth() - .padding(HorizontalPadding, 8.dp) + .padding(8.dp, 8.dp, 0.dp, 8.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Row( - Modifier - .fillMaxWidth() - .padding(8.dp, 8.dp, 0.dp, 8.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { - Image( - rememberDrawablePainter(info.loadIcon(pm)), null, - Modifier - .padding(end = 16.dp) - .size(45.dp) - ) - Column { - Text(info.loadLabel(pm).toString(), style = typography.titleMedium) - Text(name, Modifier.alpha(0.7F), style = typography.bodyMedium) - } - } - val ts = when (DhizukuPermissions.filter { it !in client.permissions }.size) { - 0 -> ToggleableState.On - DhizukuPermissions.size -> ToggleableState.Off - else -> ToggleableState.Indeterminate - } - Row(verticalAlignment = Alignment.CenterVertically) { - TriStateCheckbox(ts, { - clients[index] = when (ts) { - ToggleableState.On, ToggleableState.Indeterminate -> client.copy(permissions = emptyList()) - ToggleableState.Off -> client.copy(permissions = DhizukuPermissions) - } - }) - val degrees by animateFloatAsState(if(expand) 180F else 0F) - IconButton({ expand = !expand }) { - Icon(Icons.Default.ArrowDropDown, null, Modifier.rotate(degrees)) - } + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + rememberDrawablePainter(app.icon), null, + Modifier.padding(end = 16.dp).size(45.dp) + ) + Column { + Text(app.label, style = typography.titleMedium) + Text(app.name, Modifier.alpha(0.7F), style = typography.bodyMedium) } } - AnimatedVisibility(expand, Modifier.padding(8.dp, 0.dp, 8.dp, 8.dp)) { - Column { - mapOf( - "remote_transact" to "Remote transact", "remote_process" to "Remote process", - "user_service" to "User service", "delegated_scopes" to "Delegated scopes", - "other" to context.getString(R.string.other) - ).forEach { (k, v) -> - Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(v) - Checkbox(k in client.permissions, { - val newPermissions = if (it) client.permissions.plus(k) else client.permissions.minus(k) - clients[index] = client.copy(permissions = newPermissions) - writeList() - }) - } + val ts = when (DhizukuPermissions.filter { it !in client.permissions }.size) { + 0 -> ToggleableState.On + DhizukuPermissions.size -> ToggleableState.Off + else -> ToggleableState.Indeterminate + } + Row(verticalAlignment = Alignment.CenterVertically) { + TriStateCheckbox(ts, { + if (ts == ToggleableState.Off) { + updateDhizukuClient(client.copy(permissions = DhizukuPermissions)) + } else { + updateDhizukuClient(client.copy(permissions = emptyList())) + } + }) + val degrees by animateFloatAsState(if(expand) 180F else 0F) + IconButton({ expand = !expand }) { + Icon(Icons.Default.ArrowDropDown, null, Modifier.rotate(degrees)) + } + } + } + AnimatedVisibility(expand, Modifier.padding(8.dp, 0.dp, 8.dp, 8.dp)) { + Column { + mapOf( + "remote_transact" to "Remote transact", "remote_process" to "Remote process", + "user_service" to "User service", "delegated_scopes" to "Delegated scopes", + "other" to "Other" + ).forEach { (k, v) -> + Row( + Modifier.fillMaxWidth(), Arrangement.SpaceBetween, + Alignment.CenterVertically + ) { + Text(v) + Checkbox(k in client.permissions, { + updateDhizukuClient(client.copy( + permissions = client.permissions.run { if (it) plus(k) else minus(k) } + )) + }) } } } @@ -604,10 +450,12 @@ fun DhizukuServerSettingsScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable -fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { +fun LockScreenInfoScreen( + getText: () -> String, setText: (String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var infoText by remember { mutableStateOf(Privilege.DPM.deviceOwnerLockScreenInfo?.toString() ?: "") } + var infoText by remember { mutableStateOf(getText()) } MyScaffold(R.string.lock_screen_info, onNavigateUp) { OutlinedTextField( value = infoText, @@ -622,7 +470,7 @@ fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - Privilege.DPM.setDeviceOwnerLockScreenInfo(Privilege.DAR, infoText) + setText(infoText) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth() @@ -632,7 +480,7 @@ fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - Privilege.DPM.setDeviceOwnerLockScreenInfo(Privilege.DAR, null) + setText("") infoText = "" context.showOperationResultToast(true) }, @@ -645,113 +493,103 @@ fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { } } -@Keep +data class DelegatedScope(val id: String, val string: Int, val requiresApi: Int = 26) @Suppress("InlinedApi") -enum class DelegatedScope(val id: String, @StringRes val string: Int, val requiresApi: Int = 0) { - AppRestrictions(DevicePolicyManager.DELEGATION_APP_RESTRICTIONS, R.string.manage_application_restrictions), - BlockUninstall(DevicePolicyManager.DELEGATION_BLOCK_UNINSTALL, R.string.block_uninstall), - CertInstall(DevicePolicyManager.DELEGATION_CERT_INSTALL, R.string.manage_certificates), - CertSelection(DevicePolicyManager.DELEGATION_CERT_SELECTION, R.string.select_keychain_certificates, 29), - EnableSystemApp(DevicePolicyManager.DELEGATION_ENABLE_SYSTEM_APP, R.string.enable_system_app), - InstallExistingPackage(DevicePolicyManager.DELEGATION_INSTALL_EXISTING_PACKAGE, R.string.install_existing_packages, 28), - KeepUninstalledPackages(DevicePolicyManager.DELEGATION_KEEP_UNINSTALLED_PACKAGES, R.string.manage_uninstalled_packages, 28), - NetworkLogging(DevicePolicyManager.DELEGATION_NETWORK_LOGGING, R.string.network_logging, 29), - PackageAccess(DevicePolicyManager.DELEGATION_PACKAGE_ACCESS, R.string.change_package_state), - PermissionGrant(DevicePolicyManager.DELEGATION_PERMISSION_GRANT, R.string.grant_permissions), - SecurityLogging(DevicePolicyManager.DELEGATION_SECURITY_LOGGING, R.string.security_logging, 31) -} +val delegatedScopesList = listOf( + DelegatedScope(DevicePolicyManager.DELEGATION_APP_RESTRICTIONS, R.string.manage_application_restrictions), + DelegatedScope(DevicePolicyManager.DELEGATION_BLOCK_UNINSTALL, R.string.block_uninstall), + DelegatedScope(DevicePolicyManager.DELEGATION_CERT_INSTALL, R.string.manage_certificates), + DelegatedScope(DevicePolicyManager.DELEGATION_CERT_SELECTION, R.string.select_keychain_certificates, 29), + DelegatedScope(DevicePolicyManager.DELEGATION_ENABLE_SYSTEM_APP, R.string.enable_system_app), + DelegatedScope(DevicePolicyManager.DELEGATION_INSTALL_EXISTING_PACKAGE, R.string.install_existing_packages, 28), + DelegatedScope(DevicePolicyManager.DELEGATION_KEEP_UNINSTALLED_PACKAGES, R.string.manage_uninstalled_packages, 28), + DelegatedScope(DevicePolicyManager.DELEGATION_NETWORK_LOGGING, R.string.network_logging, 29), + DelegatedScope(DevicePolicyManager.DELEGATION_PACKAGE_ACCESS, R.string.change_package_state), + DelegatedScope(DevicePolicyManager.DELEGATION_PERMISSION_GRANT, R.string.grant_permissions), + DelegatedScope(DevicePolicyManager.DELEGATION_SECURITY_LOGGING, R.string.security_logging, 31) +).filter { VERSION.SDK_INT >= it.requiresApi } + +data class DelegatedAdmin(val app: AppInfo, val scopes: List) @Serializable object DelegatedAdmins @RequiresApi(26) @Composable -fun DelegatedAdminsScreen(onNavigateUp: () -> Unit, onNavigate: (AddDelegatedAdmin) -> Unit) { - val packages = remember { mutableStateMapOf>() } - fun refresh() { - val list = mutableMapOf>() - DelegatedScope.entries.forEach { ds -> - if(VERSION.SDK_INT >= ds.requiresApi) { - Privilege.DPM.getDelegatePackages(Privilege.DAR, ds.id)?.forEach { pkg -> - if(list[pkg] != null) { - list[pkg]!!.add(ds) - } else { - list[pkg] = mutableListOf(ds) +fun DelegatedAdminsScreen( + delegatedAdmins: StateFlow>, getDelegatedAdmins: () -> Unit, + onNavigateUp: () -> Unit, onNavigate: (AddDelegatedAdmin) -> Unit +) { + val admins by delegatedAdmins.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getDelegatedAdmins() } + MyLazyScaffold(R.string.delegated_admins, onNavigateUp) { + items(admins, { it.app.name }) { (app, scopes) -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp).animateItem(), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = rememberDrawablePainter(app.icon), contentDescription = null, + modifier = Modifier.padding(start = 12.dp, end = 18.dp).size(40.dp) + ) + Column { + Text(app.label) + Text(app.name, Modifier.alpha(0.8F), style = typography.bodyMedium) } } + IconButton({ onNavigate(AddDelegatedAdmin(app.name, scopes)) }) { + Icon(Icons.Outlined.Edit, null) + } } } - packages.clear() - packages.putAll(list) - } - LaunchedEffect(Unit) { refresh() } - MyScaffold(R.string.delegated_admins, onNavigateUp, 0.dp) { - packages.forEach { (pkg, scopes) -> + item { Row( - Modifier + modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) - .padding(start = 14.dp, end = 8.dp), - Arrangement.SpaceBetween + .clickable { onNavigate(AddDelegatedAdmin()) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column { - Text(pkg, style = typography.titleMedium) - Text( - scopes.size.toString() + " " + stringResource(R.string.delegated_scope), - color = colorScheme.onSurfaceVariant, style = typography.bodyMedium - ) - } - IconButton({ onNavigate(AddDelegatedAdmin(pkg, scopes)) }) { - Icon(Icons.Outlined.Edit, stringResource(R.string.edit)) - } + Icon(Icons.Default.Add, null, modifier = Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.add_delegated_admin), style = typography.titleMedium) } } - if(packages.isEmpty()) Text( - stringResource(R.string.none), - color = colorScheme.onSurfaceVariant, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(vertical = 4.dp) - ) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onNavigate(AddDelegatedAdmin()) } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Add, null, modifier = Modifier.padding(end = 12.dp)) - Text(stringResource(R.string.add_delegated_admin), style = typography.titleMedium) - } } } -@Serializable data class AddDelegatedAdmin(val pkg: String = "", val scopes: List = emptyList()) +@Serializable data class AddDelegatedAdmin(val pkg: String = "", val scopes: List = emptyList()) @RequiresApi(26) @Composable fun AddDelegatedAdminScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, - data: AddDelegatedAdmin, onNavigateUp: () -> Unit + chosenPackage: Channel, onChoosePackage: () -> Unit, data: AddDelegatedAdmin, + setDelegatedAdmin: (String, List) -> Unit, onNavigateUp: () -> Unit ) { val updateMode = data.pkg.isNotEmpty() var input by remember { mutableStateOf(data.pkg) } - val scopes = remember { mutableStateListOf(*data.scopes.toTypedArray()) } + val scopes = rememberSaveable { mutableStateListOf(*data.scopes.toTypedArray()) } LaunchedEffect(Unit) { input = chosenPackage.receive() } MySmallTitleScaffold(if(updateMode) R.string.place_holder else R.string.add_delegated_admin, onNavigateUp, 0.dp) { - PackageNameTextField(input, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { input = it } - DelegatedScope.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { scope -> - val checked = scope in scopes + if (updateMode) { + OutlinedTextField(input, {}, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + enabled = false, label = { Text(stringResource(R.string.package_name)) }) + } else { + PackageNameTextField(input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { input = it } + } + delegatedScopesList.forEach { scope -> + val checked = scope.id in scopes Row( Modifier .fillMaxWidth() - .clickable { if (!checked) scopes += scope else scopes -= scope } + .clickable { if (!checked) scopes += scope.id else scopes -= scope.id } .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - Checkbox(checked, { if(it) scopes += scope else scopes -= scope }, modifier = Modifier.padding(horizontal = 4.dp)) + Checkbox(checked, { if(it) scopes += scope.id else scopes -= scope.id }, + modifier = Modifier.padding(horizontal = 4.dp)) Column { Text(stringResource(scope.string)) Text(scope.id, style = typography.bodyMedium, color = colorScheme.onSurfaceVariant) @@ -760,63 +598,57 @@ fun AddDelegatedAdminScreen( } Button( onClick = { - Privilege.DPM.setDelegatedScopes(Privilege.DAR, input, scopes.map { it.id }) + setDelegatedAdmin(input, scopes) onNavigateUp() }, - modifier = Modifier - .fillMaxWidth() - .padding(HorizontalPadding, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, vertical = 4.dp), enabled = input.isNotBlank() && (!updateMode || scopes.toList() != data.scopes) ) { Text(stringResource(if(updateMode) R.string.update else R.string.add)) } if(updateMode) Button( onClick = { - Privilege.DPM.setDelegatedScopes(Privilege.DAR, input, emptyList()) + setDelegatedAdmin(input, emptyList()) onNavigateUp() }, - modifier = Modifier - .fillMaxWidth() - .padding(HorizontalPadding), + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError) ) { Text(stringResource(R.string.delete)) } + Spacer(Modifier.height(40.dp)) } } @Serializable object DeviceInfo @Composable -fun DeviceInfoScreen(onNavigateUp: () -> Unit) { +fun DeviceInfoScreen(vm: MyViewModel, onNavigateUp: () -> Unit) { val privilege by Privilege.status.collectAsStateWithLifecycle() var dialog by remember { mutableIntStateOf(0) } MyScaffold(R.string.device_info, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT>=34 && (privilege.device || privilege.org)) { - InfoItem(R.string.financed_device, Privilege.DPM.isDeviceFinanced.yesOrNo) + if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.org)) { + InfoItem(R.string.financed_device, vm.getDeviceFinanced().yesOrNo) } - if(VERSION.SDK_INT >= 33) { - val dpmRole = Privilege.DPM.devicePolicyManagementRoleHolderPackage - InfoItem(R.string.dpmrh, dpmRole ?: stringResource(R.string.none)) + if (VERSION.SDK_INT >= 33) { + InfoItem(R.string.dpmrh, vm.getDpmRh() ?: stringResource(R.string.none)) } - val encryptionStatus = mutableMapOf( - DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE to R.string.es_inactive, - DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE to R.string.es_active, - DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED to R.string.es_unsupported - ) - if(VERSION.SDK_INT >= 23) { encryptionStatus[DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY] = R.string.es_active_default_key } - if(VERSION.SDK_INT >= 24) { encryptionStatus[DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER] = R.string.es_active_per_user } - InfoItem(R.string.encryption_status, encryptionStatus[Privilege.DPM.storageEncryptionStatus] ?: R.string.unknown) - if(VERSION.SDK_INT >= 28) { - InfoItem(R.string.support_device_id_attestation, Privilege.DPM.isDeviceIdAttestationSupported.yesOrNo, true) { dialog = 1 } + val encryptionStatus = when (vm.getStorageEncryptionStatus()) { + DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE -> R.string.es_inactive + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE -> R.string.es_active + DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED -> R.string.es_unsupported + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY -> R.string.es_active_default_key + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER -> R.string.es_active_per_user + else -> R.string.unknown + } + InfoItem(R.string.encryption_status, encryptionStatus) + if (VERSION.SDK_INT >= 28) { + InfoItem(R.string.support_device_id_attestation, vm.getDeviceIdAttestationSupported().yesOrNo, true) { dialog = 1 } } if (VERSION.SDK_INT >= 30) { - InfoItem(R.string.support_unique_device_attestation, Privilege.DPM.isUniqueDeviceAttestationSupported.yesOrNo, true) { dialog = 2 } - } - val adminList = Privilege.DPM.activeAdmins - if(adminList != null) { - InfoItem(R.string.activated_device_admin, adminList.joinToString("\n") { it.flattenToShortString() }) + InfoItem(R.string.support_unique_device_attestation, vm.getUniqueDeviceAttestationSupported().yesOrNo, true) { dialog = 2 } } + InfoItem(R.string.activated_device_admin, vm.getActiveAdmins()) } if(dialog != 0) AlertDialog( text = { Text(stringResource(if(dialog == 1) R.string.info_device_id_attestation else R.string.info_unique_device_attestation)) }, @@ -829,15 +661,17 @@ fun DeviceInfoScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable -fun SupportMessageScreen(onNavigateUp: () -> Unit) { +fun SupportMessageScreen( + getShortMessage: () -> String, getLongMessage: () -> String, setShortMessage: (String?) -> Unit, + setLongMessage: (String?) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current var shortMsg by remember { mutableStateOf("") } var longMsg by remember { mutableStateOf("") } - val refreshMsg = { - shortMsg = Privilege.DPM.getShortSupportMessage(Privilege.DAR)?.toString() ?: "" - longMsg = Privilege.DPM.getLongSupportMessage(Privilege.DAR)?.toString() ?: "" + LaunchedEffect(Unit) { + shortMsg = getShortMessage() + longMsg = getLongMessage() } - LaunchedEffect(Unit) { refreshMsg() } MyScaffold(R.string.support_messages, onNavigateUp) { OutlinedTextField( value = shortMsg, @@ -851,8 +685,7 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setShortSupportMessage(Privilege.DAR, shortMsg) - refreshMsg() + setShortMessage(shortMsg) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.49F) @@ -861,8 +694,8 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setShortSupportMessage(Privilege.DAR, null) - refreshMsg() + setShortMessage(null) + shortMsg = "" context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -884,8 +717,7 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setLongSupportMessage(Privilege.DAR, longMsg) - refreshMsg() + setLongMessage(longMsg) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.49F) @@ -894,8 +726,8 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setLongSupportMessage(Privilege.DAR, null) - refreshMsg() + setLongMessage(null) + longMsg = "" context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -907,57 +739,60 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { } } +data class DeviceAdmin(val app: AppInfo, val admin: ComponentName) + @Serializable object TransferOwnership @RequiresApi(28) @Composable -fun TransferOwnershipScreen(onNavigateUp: () -> Unit, onTransferred: () -> Unit) { - val context = LocalContext.current +fun TransferOwnershipScreen( + deviceAdmins: StateFlow>, getDeviceAdmins: () -> Unit, + transferOwnership: (ComponentName) -> Unit, onNavigateUp: () -> Unit, onTransferred: () -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() - val focusMgr = LocalFocusManager.current - var input by remember { mutableStateOf("") } - val componentName = ComponentName.unflattenFromString(input) + var selectedIndex by remember { mutableIntStateOf(-1) } var dialog by remember { mutableStateOf(false) } - MyScaffold(R.string.transfer_ownership, onNavigateUp) { - OutlinedTextField( - value = input, onValueChange = { input = it }, label = { Text(stringResource(R.string.target_component_name)) }, - modifier = Modifier.fillMaxWidth(), - isError = input != "" && componentName == null, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusMgr.clearFocus() }) - ) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { dialog = true }, - modifier = Modifier.fillMaxWidth(), - enabled = componentName != null - ) { - Text(stringResource(R.string.transfer)) + val receivers by deviceAdmins.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getDeviceAdmins() } + MyLazyScaffold(R.string.transfer_ownership, onNavigateUp) { + itemsIndexed(receivers) { index, admin -> + Row( + Modifier.fillMaxWidth().clickable { selectedIndex = index }.padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selectedIndex == index, { selectedIndex = index }) + Image(rememberDrawablePainter(admin.app.icon), null, Modifier.size(40.dp)) + Column(Modifier.padding(start = 8.dp)) { + Text(admin.app.label) + Text(admin.app.name, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + } + item { + Button( + onClick = { dialog = true }, + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 10.dp), + enabled = receivers.getOrNull(selectedIndex) != null + ) { + Text(stringResource(R.string.transfer)) + } + Notes(R.string.info_transfer_ownership, HorizontalPadding) } - Spacer(Modifier.padding(vertical = 10.dp)) - Notes(R.string.info_transfer_ownership) } - if(dialog) AlertDialog( + if (dialog) AlertDialog( text = { Text(stringResource( R.string.transfer_ownership_warning, stringResource(if(privilege.device) R.string.device_owner else R.string.profile_owner), - ComponentName.unflattenFromString(input)!!.packageName + receivers[selectedIndex].app.name )) }, confirmButton = { TextButton( onClick = { - try { - Privilege.DPM.transferOwnership(Privilege.DAR, componentName!!, null) - Privilege.updateStatus() - context.showOperationResultToast(true) - dialog = false - onTransferred() - } catch(e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) - } + transferOwnership(receivers[selectedIndex].admin) + dialog = false + onTransferred() }, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) ) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 99227f6..9138dfd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -16,7 +16,6 @@ import android.app.admin.DevicePolicyManager.WIPE_EUICC import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE import android.app.admin.DevicePolicyManager.WIPE_RESET_PROTECTION_DATA import android.app.admin.DevicePolicyManager.WIPE_SILENTLY -import android.app.admin.SystemUpdateInfo import android.app.admin.SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC import android.app.admin.SystemUpdatePolicy.TYPE_INSTALL_WINDOWED import android.app.admin.SystemUpdatePolicy.TYPE_POSTPONE @@ -135,7 +134,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import java.io.ByteArrayOutputStream -import java.util.Date import java.util.TimeZone import kotlin.math.roundToLong diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt index e6ce051..8c6507c 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt @@ -259,7 +259,7 @@ fun InfoItem(title: Int, text: String, withInfo: Boolean = false, onClick: () -> Modifier.fillMaxWidth().padding(vertical = 6.dp).padding(start = HorizontalPadding, end = 8.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Column { + Column(Modifier.weight(1F)) { Text(stringResource(title), style = typography.titleLarge) Text(text, Modifier.alpha(0.8F)) } From 289afb63ffc7ae00ae59f447275b35af62996ecb Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Wed, 24 Sep 2025 17:20:28 +0800 Subject: [PATCH 06/26] ViewModel refactoring: UserRestriction part fix bugs (#165) --- .../com/bintianqi/owndroid/MainActivity.kt | 9 +- .../com/bintianqi/owndroid/MyViewModel.kt | 19 ++ .../java/com/bintianqi/owndroid/Privilege.kt | 1 + .../owndroid/UserRestrictionsRepository.kt | 110 ++++++++++ .../bintianqi/owndroid/dpm/UserRestriction.kt | 195 +++--------------- 5 files changed, 168 insertions(+), 166 deletions(-) create mode 100644 app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 7792da0..80cf791 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -526,15 +526,14 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } composable { - UserRestrictionScreen(::navigateUp) { - navigate(it) - } + UserRestrictionScreen(vm::getUserRestrictions, ::navigateUp, ::navigate) } composable { - UserRestrictionEditorScreen(::navigateUp) + UserRestrictionEditorScreen(vm.userRestrictions, vm::setUserRestriction, ::navigateUp) } composable(mapOf(serializableNavTypePair>())) { - UserRestrictionOptionsScreen(it.toRoute(), ::navigateUp) + UserRestrictionOptionsScreen(it.toRoute(), vm.userRestrictions, + vm::setUserRestriction, ::navigateUp) } composable { UsersScreen(::navigateUp, ::navigate) } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 782dcea..c3db43e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -996,6 +996,25 @@ class MyViewModel(application: Application): AndroidViewModel(application) { DPM.transferOwnership(DAR, component, null) Privilege.updateStatus() } + val userRestrictions = MutableStateFlow(emptyMap()) + @RequiresApi(24) + fun getUserRestrictions() { + val bundle = DPM.getUserRestrictions(DAR) + userRestrictions.value = bundle.keySet().associateWith { bundle.getBoolean(it) } + } + fun setUserRestriction(name: String, state: Boolean): Boolean { + return try { + if (state) { + DPM.addUserRestriction(DAR, name) + } else { + DPM.clearUserRestriction(DAR, name) + } + userRestrictions.update { it.plus(name to state) } + true + } catch (_: SecurityException) { + false + } + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt index 6ba405b..190f05a 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt @@ -24,6 +24,7 @@ object Privilege { if (dhizukuDpm != null) { DPM = dhizukuDpm DAR = Dhizuku.getOwnerComponent() + updateStatus() return } } diff --git a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt new file mode 100644 index 0000000..7caee10 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt @@ -0,0 +1,110 @@ +package com.bintianqi.owndroid + +import android.os.Build +import android.os.UserManager +import com.bintianqi.owndroid.dpm.Restriction + +@Suppress("InlinedApi") +object UserRestrictionsRepository { + val network = listOf( + Restriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS, R.string.config_mobile_network, R.drawable.signal_cellular_alt_fill0), + Restriction(UserManager.DISALLOW_CONFIG_WIFI, R.string.config_wifi, R.drawable.wifi_fill0), + Restriction(UserManager.DISALLOW_DATA_ROAMING, R.string.data_roaming, R.drawable.network_cell_fill0, 24), + Restriction(UserManager.DISALLOW_CELLULAR_2G, R.string.cellular_2g, R.drawable.network_cell_fill0, 34), + Restriction(UserManager.DISALLOW_ULTRA_WIDEBAND_RADIO, R.string.ultra_wideband_radio, R.drawable.wifi_tethering_fill0, 34), + Restriction(UserManager.DISALLOW_ADD_WIFI_CONFIG, R.string.add_wifi_conf, R.drawable.wifi_fill0, 33), + Restriction(UserManager.DISALLOW_CHANGE_WIFI_STATE, R.string.change_wifi_state, R.drawable.wifi_fill0, 33), + Restriction(UserManager.DISALLOW_WIFI_DIRECT, R.string.wifi_direct, R.drawable.wifi_tethering_fill0), + Restriction(UserManager.DISALLOW_WIFI_TETHERING, R.string.wifi_tethering, R.drawable.wifi_tethering_fill0, 33), + Restriction(UserManager.DISALLOW_SHARING_ADMIN_CONFIGURED_WIFI, R.string.share_admin_wifi, R.drawable.share_fill0, 33), + Restriction(UserManager.DISALLOW_NETWORK_RESET, R.string.network_reset, R.drawable.reset_wrench_fill0, 23), + Restriction(UserManager.DISALLOW_CONFIG_TETHERING, R.string.config_tethering, R.drawable.wifi_tethering_fill0), + Restriction(UserManager.DISALLOW_CONFIG_VPN, R.string.config_vpn, R.drawable.vpn_key_fill0), + Restriction(UserManager.DISALLOW_CONFIG_PRIVATE_DNS, R.string.config_private_dns, R.drawable.dns_fill0, 29), + Restriction(UserManager.DISALLOW_AIRPLANE_MODE, R.string.airplane_mode, R.drawable.airplanemode_active_fill0, 28), + Restriction(UserManager.DISALLOW_CONFIG_CELL_BROADCASTS, R.string.config_cell_broadcasts, R.drawable.cell_tower_fill0), + Restriction(UserManager.DISALLOW_SMS, R.string.sms, R.drawable.sms_fill0), + Restriction(UserManager.DISALLOW_OUTGOING_CALLS, R.string.outgoing_calls, R.drawable.phone_forwarded_fill0), + Restriction(UserManager.DISALLOW_SIM_GLOBALLY, R.string.download_esim, R.drawable.sim_card_download_fill0), + Restriction(UserManager.DISALLOW_THREAD_NETWORK, R.string.thread_network, R.drawable.router_fill0, 36) + ) + val connectivity = listOf( + Restriction(UserManager.DISALLOW_BLUETOOTH, R.string.bluetooth, R.drawable.bluetooth_fill0, 26), + Restriction(UserManager.DISALLOW_BLUETOOTH_SHARING, R.string.bt_share, R.drawable.bluetooth_searching_fill0, 26), + Restriction(UserManager.DISALLOW_SHARE_LOCATION, R.string.share_location, R.drawable.location_on_fill0), + Restriction(UserManager.DISALLOW_CONFIG_LOCATION, R.string.config_location, R.drawable.location_on_fill0, 28), + Restriction(UserManager.DISALLOW_NEAR_FIELD_COMMUNICATION_RADIO, R.string.nfc, R.drawable.nfc_fill0, 35), + Restriction(UserManager.DISALLOW_OUTGOING_BEAM, R.string.outgoing_beam, R.drawable.nfc_fill0, 22), + Restriction(UserManager.DISALLOW_USB_FILE_TRANSFER, R.string.usb_file_transfer, R.drawable.usb_fill0), + Restriction(UserManager.DISALLOW_MOUNT_PHYSICAL_MEDIA, R.string.mount_physical_media, R.drawable.sd_card_fill0), + Restriction(UserManager.DISALLOW_PRINTING, R.string.printing, R.drawable.print_fill0, 28) + ) + val applications = listOf( + Restriction(UserManager.DISALLOW_INSTALL_APPS, R.string.install_app, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY, R.string.install_unknown_src_globally, R.drawable.android_fill0, 29), + Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES, R.string.inst_unknown_src, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_UNINSTALL_APPS, R.string.uninstall_app, R.drawable.delete_fill0), + Restriction(UserManager.DISALLOW_APPS_CONTROL, R.string.apps_ctrl, R.drawable.apps_fill0), + Restriction(UserManager.DISALLOW_CONFIG_DEFAULT_APPS, R.string.config_default_apps, R.drawable.apps_fill0, 34) + ) + val media = listOf( + Restriction(UserManager.DISALLOW_CONFIG_BRIGHTNESS, R.string.config_brightness, R.drawable.brightness_5_fill0, 28), + Restriction(UserManager.DISALLOW_CONFIG_SCREEN_TIMEOUT, R.string.config_scr_timeout, R.drawable.screen_lock_portrait_fill0, 28), + Restriction(UserManager.DISALLOW_AMBIENT_DISPLAY, R.string.ambient_display, R.drawable.brightness_5_fill0, 28), + Restriction(UserManager.DISALLOW_ADJUST_VOLUME, R.string.adjust_volume, R.drawable.volume_up_fill0), + Restriction(UserManager.DISALLOW_UNMUTE_MICROPHONE, R.string.unmute_microphone, R.drawable.mic_fill0), + Restriction(UserManager.DISALLOW_CAMERA_TOGGLE, R.string.camera_toggle, R.drawable.cameraswitch_fill0, 31), + Restriction(UserManager.DISALLOW_MICROPHONE_TOGGLE, R.string.microphone_toggle, R.drawable.mic_fill0, 31) + ) + val users = listOf( + Restriction(UserManager.DISALLOW_ADD_USER, R.string.add_user, R.drawable.account_circle_fill0), + Restriction(UserManager.DISALLOW_REMOVE_USER, R.string.remove_user, R.drawable.account_circle_fill0), + Restriction(UserManager.DISALLOW_USER_SWITCH, R.string.switch_user, R.drawable.account_circle_fill0, 28), + Restriction(UserManager.DISALLOW_ADD_MANAGED_PROFILE, R.string.create_work_profile, R.drawable.work_fill0, 26), + Restriction(UserManager.DISALLOW_REMOVE_MANAGED_PROFILE, R.string.delete_work_profile, R.drawable.delete_forever_fill0, 26), + Restriction(UserManager.DISALLOW_ADD_PRIVATE_PROFILE, R.string.create_private_space, R.drawable.lock_fill0, 35), + Restriction(UserManager.DISALLOW_SET_USER_ICON, R.string.set_user_icon, R.drawable.account_circle_fill0, 24), + Restriction(UserManager.DISALLOW_CROSS_PROFILE_COPY_PASTE, R.string.cross_profile_copy, R.drawable.content_paste_fill0), + Restriction(UserManager.DISALLOW_SHARE_INTO_MANAGED_PROFILE, R.string.share_into_managed_profile, R.drawable.share_fill0, 28), + Restriction(UserManager.DISALLOW_UNIFIED_PASSWORD, R.string.unified_pwd, R.drawable.work_fill0, 28) + ) + val other = listOf( + Restriction(UserManager.DISALLOW_AUTOFILL, R.string.autofill, R.drawable.password_fill0, 26), + Restriction(UserManager.DISALLOW_CONFIG_CREDENTIALS, R.string.config_credentials, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_CONTENT_CAPTURE, R.string.content_capture, R.drawable.screenshot_fill0, 29), + Restriction(UserManager.DISALLOW_CONTENT_SUGGESTIONS, R.string.content_suggestions, R.drawable.sms_fill0, 29), + Restriction(UserManager.DISALLOW_ASSIST_CONTENT, R.string.assist_content, R.drawable.info_fill0, 35), + Restriction(UserManager.DISALLOW_CREATE_WINDOWS, R.string.create_windows, R.drawable.web_asset), + Restriction(UserManager.DISALLOW_SET_WALLPAPER, R.string.set_wallpaper, R.drawable.wallpaper_fill0, 24), + Restriction(UserManager.DISALLOW_GRANT_ADMIN, R.string.grant_admin, R.drawable.security_fill0, 34), + Restriction(UserManager.DISALLOW_FUN, R.string.`fun`, R.drawable.stadia_controller_fill0, 23), + Restriction(UserManager.DISALLOW_MODIFY_ACCOUNTS, R.string.modify_accounts, R.drawable.manage_accounts_fill0), + Restriction(UserManager.DISALLOW_CONFIG_LOCALE, R.string.config_locale, R.drawable.language_fill0, 28), + Restriction(UserManager.DISALLOW_CONFIG_DATE_TIME, R.string.config_date_time, R.drawable.schedule_fill0, 28), + Restriction(UserManager.DISALLOW_SYSTEM_ERROR_DIALOGS, R.string.sys_err_dialog, R.drawable.warning_fill0, 28), + Restriction(UserManager.DISALLOW_FACTORY_RESET, R.string.factory_reset, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_SAFE_BOOT, R.string.safe_boot, R.drawable.security_fill0, 23), + Restriction(UserManager.DISALLOW_DEBUGGING_FEATURES, R.string.debug_features, R.drawable.adb_fill0) + ) + + fun getData(id: String): Pair> { + val category = UserRestrictionCategory.entries.find { it.id == id }!! + return category.title to when (category) { + UserRestrictionCategory.Network -> network + UserRestrictionCategory.Connectivity -> connectivity + UserRestrictionCategory.Applications -> applications + UserRestrictionCategory.Media -> media + UserRestrictionCategory.Users -> users + UserRestrictionCategory.Other -> other + }.filter { Build.VERSION.SDK_INT >= it.requiresApi } + } +} + +enum class UserRestrictionCategory(val id: String, val title: Int, val icon: Int) { + Network("network", R.string.network, R.drawable.language_fill0), + Connectivity("connectivity", R.string.connectivity, R.drawable.devices_other_fill0), + Applications("applications", R.string.applications, R.drawable.apps_fill0), + Media("media", R.string.media, R.drawable.volume_up_fill0), + Users("users", R.string.users, R.drawable.manage_accounts_fill0), + Other("other", R.string.other, R.drawable.more_horiz_fill0) +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index 6ba859f..e7b3dda 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -1,10 +1,6 @@ package com.bintianqi.owndroid.dpm -import android.os.Build -import android.os.UserManager -import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -39,8 +36,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -57,17 +52,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.UserRestrictionCategory +import com.bintianqi.owndroid.UserRestrictionsRepository import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyLazyScaffold import com.bintianqi.owndroid.ui.NavIcon +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable @Serializable data class Restriction( val id: String, - @StringRes val name: Int, - @DrawableRes val icon: Int, + val name: Int, + val icon: Int, val requiresApi: Int = 0 ) @@ -76,12 +74,12 @@ data class Restriction( @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(24) @Composable -fun UserRestrictionScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun UserRestrictionScreen( + getRestrictions: () -> Unit,onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - fun navigateToOptions(title: Int, items: List) { - onNavigate(UserRestrictionOptions(title, items)) - } + LaunchedEffect(Unit) { getRestrictions() } Scaffold( Modifier.nestedScroll(sb.nestedScrollConnection), topBar = { @@ -114,49 +112,29 @@ fun UserRestrictionScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { Text(text = stringResource(R.string.some_features_invalid_in_work_profile), modifier = Modifier.padding(start = 16.dp)) } Spacer(Modifier.padding(vertical = 2.dp)) - FunctionItem(R.string.network, icon = R.drawable.language_fill0) { - navigateToOptions(R.string.network, RestrictionData.internet) - } - FunctionItem(R.string.connectivity, icon = R.drawable.devices_other_fill0) { - navigateToOptions(R.string.connectivity, RestrictionData.connectivity) - } - FunctionItem(R.string.applications, icon = R.drawable.apps_fill0) { - navigateToOptions(R.string.applications, RestrictionData.applications) - } - FunctionItem(R.string.users, icon = R.drawable.account_circle_fill0) { - navigateToOptions(R.string.users, RestrictionData.users) - } - FunctionItem(R.string.media, icon = R.drawable.volume_up_fill0) { - navigateToOptions(R.string.media, RestrictionData.media) - } - FunctionItem(R.string.other, icon = R.drawable.more_horiz_fill0) { - navigateToOptions(R.string.other, RestrictionData.other) + UserRestrictionCategory.entries.forEach { + FunctionItem(it.title, icon = it.icon) { + onNavigate(UserRestrictionOptions(it.id)) + } } } } } @Serializable -data class UserRestrictionOptions( - val title: Int, val items: List -) +data class UserRestrictionOptions(val id: String) @RequiresApi(24) @Composable fun UserRestrictionOptionsScreen( - data: UserRestrictionOptions, onNavigateUp: () -> Unit + args: UserRestrictionOptions, userRestrictions: StateFlow>, + setRestriction: (String, Boolean) -> Boolean, onNavigateUp: () -> Unit ) { val context = LocalContext.current - val status = remember { mutableStateMapOf() } - fun refresh() { - val restrictions = Privilege.DPM.getUserRestrictions(Privilege.DAR) - data.items.forEach { - status.put(it.id, restrictions.getBoolean(it.id)) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(data.title, onNavigateUp) { - items(data.items.filter { Build.VERSION.SDK_INT >= it.requiresApi }) { restriction -> + val status by userRestrictions.collectAsStateWithLifecycle() + val (title, items) = UserRestrictionsRepository.getData(args.id) + MyLazyScaffold(title, onNavigateUp) { + items(items) { restriction -> Row( Modifier.fillMaxWidth().padding(15.dp, 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically @@ -174,18 +152,11 @@ fun UserRestrictionOptionsScreen( Switch( status[restriction.id] == true, { - try { - if (it) { - Privilege.DPM.addUserRestriction(Privilege.DAR, restriction.id) - } else { - Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction.id) - } - } catch (e: Exception) { - e.printStackTrace() + if (!setRestriction(restriction.id, it)) { context.showOperationResultToast(false) } - refresh() - } + }, + Modifier.padding(start = 8.dp) ) } } @@ -195,104 +166,18 @@ fun UserRestrictionOptionsScreen( } } -@Suppress("InlinedApi") -object RestrictionData { - val internet = listOf( - Restriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS, R.string.config_mobile_network, R.drawable.signal_cellular_alt_fill0), - Restriction(UserManager.DISALLOW_CONFIG_WIFI, R.string.config_wifi, R.drawable.wifi_fill0), - Restriction(UserManager.DISALLOW_DATA_ROAMING, R.string.data_roaming, R.drawable.network_cell_fill0, 24), - Restriction(UserManager.DISALLOW_CELLULAR_2G, R.string.cellular_2g, R.drawable.network_cell_fill0, 34), - Restriction(UserManager.DISALLOW_ULTRA_WIDEBAND_RADIO, R.string.ultra_wideband_radio, R.drawable.wifi_tethering_fill0, 34), - Restriction(UserManager.DISALLOW_ADD_WIFI_CONFIG, R.string.add_wifi_conf, R.drawable.wifi_fill0, 33), - Restriction(UserManager.DISALLOW_CHANGE_WIFI_STATE, R.string.change_wifi_state, R.drawable.wifi_fill0, 33), - Restriction(UserManager.DISALLOW_WIFI_DIRECT, R.string.wifi_direct, R.drawable.wifi_tethering_fill0), - Restriction(UserManager.DISALLOW_WIFI_TETHERING, R.string.wifi_tethering, R.drawable.wifi_tethering_fill0, 33), - Restriction(UserManager.DISALLOW_SHARING_ADMIN_CONFIGURED_WIFI, R.string.share_admin_wifi, R.drawable.share_fill0, 33), - Restriction(UserManager.DISALLOW_NETWORK_RESET, R.string.network_reset, R.drawable.reset_wrench_fill0, 23), - Restriction(UserManager.DISALLOW_CONFIG_TETHERING, R.string.config_tethering, R.drawable.wifi_tethering_fill0), - Restriction(UserManager.DISALLOW_CONFIG_VPN, R.string.config_vpn, R.drawable.vpn_key_fill0), - Restriction(UserManager.DISALLOW_CONFIG_PRIVATE_DNS, R.string.config_private_dns, R.drawable.dns_fill0, 29), - Restriction(UserManager.DISALLOW_AIRPLANE_MODE, R.string.airplane_mode, R.drawable.airplanemode_active_fill0, 28), - Restriction(UserManager.DISALLOW_CONFIG_CELL_BROADCASTS, R.string.config_cell_broadcasts, R.drawable.cell_tower_fill0), - Restriction(UserManager.DISALLOW_SMS, R.string.sms, R.drawable.sms_fill0), - Restriction(UserManager.DISALLOW_OUTGOING_CALLS, R.string.outgoing_calls, R.drawable.phone_forwarded_fill0), - Restriction(UserManager.DISALLOW_SIM_GLOBALLY, R.string.download_esim, R.drawable.sim_card_download_fill0), - Restriction(UserManager.DISALLOW_THREAD_NETWORK, R.string.thread_network, R.drawable.router_fill0, 36) - ) - val connectivity = listOf( - Restriction(UserManager.DISALLOW_BLUETOOTH, R.string.bluetooth, R.drawable.bluetooth_fill0, 26), - Restriction(UserManager.DISALLOW_BLUETOOTH_SHARING, R.string.bt_share, R.drawable.bluetooth_searching_fill0, 26), - Restriction(UserManager.DISALLOW_SHARE_LOCATION, R.string.share_location, R.drawable.location_on_fill0), - Restriction(UserManager.DISALLOW_CONFIG_LOCATION, R.string.config_location, R.drawable.location_on_fill0, 28), - Restriction(UserManager.DISALLOW_NEAR_FIELD_COMMUNICATION_RADIO, R.string.nfc, R.drawable.nfc_fill0, 35), - Restriction(UserManager.DISALLOW_OUTGOING_BEAM, R.string.outgoing_beam, R.drawable.nfc_fill0, 22), - Restriction(UserManager.DISALLOW_USB_FILE_TRANSFER, R.string.usb_file_transfer, R.drawable.usb_fill0), - Restriction(UserManager.DISALLOW_MOUNT_PHYSICAL_MEDIA, R.string.mount_physical_media, R.drawable.sd_card_fill0), - Restriction(UserManager.DISALLOW_PRINTING, R.string.printing, R.drawable.print_fill0, 28) - ) - val applications = listOf( - Restriction(UserManager.DISALLOW_INSTALL_APPS, R.string.install_app, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY, R.string.install_unknown_src_globally, R.drawable.android_fill0, 29), - Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES, R.string.inst_unknown_src, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_UNINSTALL_APPS, R.string.uninstall_app, R.drawable.delete_fill0), - Restriction(UserManager.DISALLOW_APPS_CONTROL, R.string.apps_ctrl, R.drawable.apps_fill0), - Restriction(UserManager.DISALLOW_CONFIG_DEFAULT_APPS, R.string.config_default_apps, R.drawable.apps_fill0, 34) - ) - val media = listOf( - Restriction(UserManager.DISALLOW_CONFIG_BRIGHTNESS, R.string.config_brightness, R.drawable.brightness_5_fill0, 28), - Restriction(UserManager.DISALLOW_CONFIG_SCREEN_TIMEOUT, R.string.config_scr_timeout, R.drawable.screen_lock_portrait_fill0, 28), - Restriction(UserManager.DISALLOW_AMBIENT_DISPLAY, R.string.ambient_display, R.drawable.brightness_5_fill0, 28), - Restriction(UserManager.DISALLOW_ADJUST_VOLUME, R.string.adjust_volume, R.drawable.volume_up_fill0), - Restriction(UserManager.DISALLOW_UNMUTE_MICROPHONE, R.string.unmute_microphone, R.drawable.mic_fill0), - Restriction(UserManager.DISALLOW_CAMERA_TOGGLE, R.string.camera_toggle, R.drawable.cameraswitch_fill0, 31), - Restriction(UserManager.DISALLOW_MICROPHONE_TOGGLE, R.string.microphone_toggle, R.drawable.mic_fill0, 31) - ) - val users = listOf( - Restriction(UserManager.DISALLOW_ADD_USER, R.string.add_user, R.drawable.account_circle_fill0), - Restriction(UserManager.DISALLOW_REMOVE_USER, R.string.remove_user, R.drawable.account_circle_fill0), - Restriction(UserManager.DISALLOW_USER_SWITCH, R.string.switch_user, R.drawable.account_circle_fill0, 28), - Restriction(UserManager.DISALLOW_ADD_MANAGED_PROFILE, R.string.create_work_profile, R.drawable.work_fill0, 26), - Restriction(UserManager.DISALLOW_REMOVE_MANAGED_PROFILE, R.string.delete_work_profile, R.drawable.delete_forever_fill0, 26), - Restriction(UserManager.DISALLOW_ADD_PRIVATE_PROFILE, R.string.create_private_space, R.drawable.lock_fill0, 35), - Restriction(UserManager.DISALLOW_SET_USER_ICON, R.string.set_user_icon, R.drawable.account_circle_fill0, 24), - Restriction(UserManager.DISALLOW_CROSS_PROFILE_COPY_PASTE, R.string.cross_profile_copy, R.drawable.content_paste_fill0), - Restriction(UserManager.DISALLOW_SHARE_INTO_MANAGED_PROFILE, R.string.share_into_managed_profile, R.drawable.share_fill0, 28), - Restriction(UserManager.DISALLOW_UNIFIED_PASSWORD, R.string.unified_pwd, R.drawable.work_fill0, 28) - ) - val other = listOf( - Restriction(UserManager.DISALLOW_AUTOFILL, R.string.autofill, R.drawable.password_fill0, 26), - Restriction(UserManager.DISALLOW_CONFIG_CREDENTIALS, R.string.config_credentials, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_CONTENT_CAPTURE, R.string.content_capture, R.drawable.screenshot_fill0, 29), - Restriction(UserManager.DISALLOW_CONTENT_SUGGESTIONS, R.string.content_suggestions, R.drawable.sms_fill0, 29), - Restriction(UserManager.DISALLOW_ASSIST_CONTENT, R.string.assist_content, R.drawable.info_fill0, 35), - Restriction(UserManager.DISALLOW_CREATE_WINDOWS, R.string.create_windows, R.drawable.web_asset), - Restriction(UserManager.DISALLOW_SET_WALLPAPER, R.string.set_wallpaper, R.drawable.wallpaper_fill0, 24), - Restriction(UserManager.DISALLOW_GRANT_ADMIN, R.string.grant_admin, R.drawable.security_fill0, 34), - Restriction(UserManager.DISALLOW_FUN, R.string.`fun`, R.drawable.stadia_controller_fill0, 23), - Restriction(UserManager.DISALLOW_MODIFY_ACCOUNTS, R.string.modify_accounts, R.drawable.manage_accounts_fill0), - Restriction(UserManager.DISALLOW_CONFIG_LOCALE, R.string.config_locale, R.drawable.language_fill0, 28), - Restriction(UserManager.DISALLOW_CONFIG_DATE_TIME, R.string.config_date_time, R.drawable.schedule_fill0, 28), - Restriction(UserManager.DISALLOW_SYSTEM_ERROR_DIALOGS, R.string.sys_err_dialog, R.drawable.warning_fill0, 28), - Restriction(UserManager.DISALLOW_FACTORY_RESET, R.string.factory_reset, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_SAFE_BOOT, R.string.safe_boot, R.drawable.security_fill0, 23), - Restriction(UserManager.DISALLOW_DEBUGGING_FEATURES, R.string.debug_features, R.drawable.adb_fill0) - ) -} - @Serializable object UserRestrictionEditor @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(24) @Composable -fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { +fun UserRestrictionEditorScreen( + restrictions: StateFlow>, setRestriction: (String, Boolean) -> Boolean, + onNavigateUp: () -> Unit +) { val context = LocalContext.current - val list = remember { mutableStateListOf() } - fun refresh() { - val restrictions = Privilege.DPM.getUserRestrictions(Privilege.DAR) - list.clear() - list.addAll(restrictions.keySet().filter { restrictions.getBoolean(it) }) - } - LaunchedEffect(Unit) { refresh() } + val map by restrictions.collectAsStateWithLifecycle() + val list = map.filter { it.value }.map { it.key } Scaffold( topBar = { TopAppBar( @@ -310,13 +195,7 @@ fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { ) { Text(it) IconButton({ - try { - Privilege.DPM.clearUserRestriction(Privilege.DAR, it) - } catch (e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) - } - refresh() + if (!setRestriction(it, false)) context.showOperationResultToast(false) }) { Icon(Icons.Outlined.Delete, null) } @@ -325,17 +204,10 @@ fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { item { var input by remember { mutableStateOf("") } fun add() { - try { - Privilege.DPM.addUserRestriction(Privilege.DAR, input) - input = "" - } catch (e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) - } - refresh() + if (!setRestriction(input, false)) context.showOperationResultToast(false) } OutlinedTextField( - input, { input = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 20.dp), + input, { input = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), label = { Text("id") }, trailingIcon = { IconButton(::add, enabled = input.isNotBlank()) { @@ -345,6 +217,7 @@ fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), keyboardActions = KeyboardActions { add() } ) + Spacer(Modifier.height(40.dp)) } } } From 5928dbb657e6398138b6035fe6f20a25269a272d Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Thu, 25 Sep 2025 23:01:02 +0800 Subject: [PATCH 07/26] Bugfix and new APIs Fix permitted accessibility services and permitted ime, close #165 New APIs, close #166 Optimize shortcuts creation, add ShortcutUtils --- Readme-en.md | 6 + Readme.md | 6 + .../com/bintianqi/owndroid/ApiReceiver.kt | 16 +++ .../com/bintianqi/owndroid/MyViewModel.kt | 17 ++- .../java/com/bintianqi/owndroid/Receiver.kt | 3 - .../java/com/bintianqi/owndroid/Settings.kt | 4 +- .../com/bintianqi/owndroid/ShortcutUtils.kt | 57 +++++++++ .../owndroid/ShortcutsReceiverActivity.kt | 54 ++------- .../bintianqi/owndroid/dpm/Applications.kt | 6 +- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 112 +++++++----------- 10 files changed, 158 insertions(+), 123 deletions(-) create mode 100644 app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt diff --git a/Readme-en.md b/Readme-en.md index f4ecd7b..b367766 100644 --- a/Readme-en.md +++ b/Readme-en.md @@ -93,6 +93,8 @@ Samsung restricts Android's multiple users feature. There is currently no soluti ## API +OwnDroid provides an API based on Intent and BroadcastReceiver. + | ID | Extras | Minimum Android version | |--------------------------|------------------------|:-----------------------:| | `HIDE` | `package` | | @@ -104,6 +106,10 @@ Samsung restricts Android's multiple users feature. There is currently no soluti | `SET_PERMISSION_DEFAULT` | `package` `permission` | 6 | | `SET_PERMISSION_GRANTED` | `package` `permission` | 6 | | `SET_PERMISSION_DENIED` | `package` `permission` | 6 | +| `SET_CAMERA_DISABLED` | | | +| `SET_CAMERA_ENABLED` | | | +| `SET_USB_DISABLED` | | 12 | +| `SET_USB_ENABLED` | | 12 | | `LOCK` | | | | `REBOOT` | | 7 | diff --git a/Readme.md b/Readme.md index a2eb37d..3e4de41 100644 --- a/Readme.md +++ b/Readme.md @@ -91,6 +91,8 @@ user limit reached ## API +OwnDroid提供了一个基于Intent和BroadcastReceiver的API。 + | ID | Extra | 最小安卓版本 | |--------------------------|------------------------|:------:| | `HIDE` | `package` | | @@ -102,6 +104,10 @@ user limit reached | `SET_PERMISSION_DEFAULT` | `package` `permission` | 6 | | `SET_PERMISSION_GRANTED` | `package` `permission` | 6 | | `SET_PERMISSION_DENIED` | `package` `permission` | 6 | +| `SET_CAMERA_DISABLED` | | | +| `SET_CAMERA_ENABLED` | | | +| `SET_USB_DISABLED` | | 12 | +| `SET_USB_ENABLED` | | 12 | | `LOCK` | | | | `REBOOT` | | 7 | diff --git a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt index aa784b4..5f12d1d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt @@ -47,6 +47,22 @@ class ApiReceiver: BroadcastReceiver() { } "LOCK" -> { Privilege.DPM.lockNow(); true } "REBOOT" -> { Privilege.DPM.reboot(Privilege.DAR); true } + "SET_CAMERA_DISABLED" -> { + Privilege.DPM.setCameraDisabled(Privilege.DAR, true) + true + } + "SET_CAMERA_ENABLED" -> { + Privilege.DPM.setCameraDisabled(Privilege.DAR, false) + true + } + "SET_USB_DISABLED" -> { + Privilege.DPM.isUsbDataSignalingEnabled = false + true + } + "SET_USB_ENABLED" -> { + Privilege.DPM.isUsbDataSignalingEnabled = true + true + } else -> { log += "\nInvalid action" false diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index c3db43e..5f5d6ea 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -44,9 +44,10 @@ import com.bintianqi.owndroid.dpm.SystemOptionsStatus import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo import com.bintianqi.owndroid.dpm.delegatedScopesList import com.bintianqi.owndroid.dpm.getPackageInstaller +import com.bintianqi.owndroid.dpm.handlePrivilegeChange import com.bintianqi.owndroid.dpm.isValidPackageName import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage -import com.bintianqi.owndroid.dpm.permissionList +import com.bintianqi.owndroid.dpm.runtimePermissions import com.bintianqi.owndroid.dpm.temperatureTypes import com.rosan.dhizuku.api.Dhizuku import com.rosan.dhizuku.api.DhizukuRequestPermissionListener @@ -165,7 +166,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { @RequiresApi(23) fun getPackagePermissions(name: String) { if (name.isValidPackageName) { - packagePermissions.value = permissionList().associate { + packagePermissions.value = runtimePermissions.associate { it.permission to DPM.getPermissionGrantState(DAR, name, it.permission) } } else { @@ -458,7 +459,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } fun setCameraDisabled(disabled: Boolean) { DPM.setCameraDisabled(DAR, disabled) - createShortcuts(application) + ShortcutUtils.setShortcut(application, MyShortcut.DisableCamera, !disabled) systemOptionsStatus.update { it.copy(cameraDisabled = DPM.getCameraDisabled(null)) } } fun setScreenCaptureDisabled(disabled: Boolean) { @@ -491,7 +492,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } fun setMasterVolumeMuted(muted: Boolean) { DPM.setMasterVolumeMuted(DAR, muted) - createShortcuts(application) + ShortcutUtils.setShortcut(application, MyShortcut.Mute, !muted) systemOptionsStatus.update { it.copy(masterVolumeMuted = DPM.isMasterVolumeMuted(DAR)) } } @RequiresApi(26) @@ -812,6 +813,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { callback(false, null) } else { Privilege.updateStatus() + handlePrivilegeChange(application) callback( true, result.getString("output") + "\n" + result.getString("error") ) @@ -828,7 +830,10 @@ class MyViewModel(application: Application): AndroidViewModel(application) { if(shell.isRoot) { val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec() val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n") - Privilege.updateStatus() + if (result.isSuccess) { + Privilege.updateStatus() + handlePrivilegeChange(application) + } callback(result.isSuccess, output) } else { callback(false, application.getString(R.string.permission_denied)) @@ -840,12 +845,14 @@ class MyViewModel(application: Application): AndroidViewModel(application) { DPM.transferOwnership(DAR, MyAdminComponent, null) SP.dhizuku = false Privilege.initialize(application) + handlePrivilegeChange(application) callback(true, null) } fun activateDhizukuMode(callback: (Boolean, String?) -> Unit) { fun onSucceed() { SP.dhizuku = true Privilege.initialize(application) + handlePrivilegeChange(application) callback(true, null) } if (Dhizuku.init(application)) { diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index e7b88d9..362a873 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -11,7 +11,6 @@ import android.os.UserHandle import android.os.UserManager import androidx.core.app.NotificationCompat import com.bintianqi.owndroid.dpm.handleNetworkLogs -import com.bintianqi.owndroid.dpm.handlePrivilegeChange import com.bintianqi.owndroid.dpm.processSecurityLogs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,13 +34,11 @@ class Receiver : DeviceAdminReceiver() { override fun onEnabled(context: Context, intent: Intent) { super.onEnabled(context, intent) Privilege.updateStatus() - handlePrivilegeChange(context) } override fun onDisabled(context: Context, intent: Intent) { super.onDisabled(context, intent) Privilege.updateStatus() - handlePrivilegeChange(context) } override fun onProfileProvisioningComplete(context: Context, intent: Intent) { diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/Settings.kt index 530bccd..7691225 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Settings.kt @@ -57,7 +57,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.core.content.edit -import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.ui.FunctionItem @@ -155,8 +154,7 @@ fun SettingsOptionsScreen(onNavigateUp: () -> Unit) { R.string.shortcuts, icon = R.drawable.open_in_new, getState = { SP.shortcuts }, onCheckedChange = { SP.shortcuts = it - ShortcutManagerCompat.removeAllDynamicShortcuts(context) - createShortcuts(context) + ShortcutUtils.setAllShortcuts(context) } ) } diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt new file mode 100644 index 0000000..45ca5fa --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt @@ -0,0 +1,57 @@ +package com.bintianqi.owndroid + +import android.content.Context +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat + +object ShortcutUtils { + fun setAllShortcuts(context: Context) { + if (SP.shortcuts) { + val list = listOf( + createShortcut(context, MyShortcut.Lock, true), + createShortcut(context, MyShortcut.DisableCamera, + !Privilege.DPM.getCameraDisabled(Privilege.DAR)), + createShortcut(context, MyShortcut.Mute, + !Privilege.DPM.isMasterVolumeMuted(Privilege.DAR)) + ) + ShortcutManagerCompat.setDynamicShortcuts(context, list) + } else { + ShortcutManagerCompat.removeAllDynamicShortcuts(context) + } + } + fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) { + ShortcutManagerCompat.pushDynamicShortcut( + context, createShortcut(context, shortcut, state) + ) + } + private fun createShortcut( + context: Context, shortcut: MyShortcut, state: Boolean + ): ShortcutInfoCompat { + val icon = IconCompat.createWithResource( + context, + if (!state && shortcut.iconDisable != null) shortcut.iconDisable else shortcut.iconEnable + ) + return ShortcutInfoCompat.Builder(context, shortcut.id) + .setIcon(icon) + .setShortLabel(context.getText( + if (!state && shortcut.labelDisable != null) shortcut.labelDisable else shortcut.labelEnable + )) + .setIntent( + Intent(context, ShortcutsReceiverActivity::class.java) + .setAction("com.bintianqi.owndroid.action.${shortcut.id}") + ) + .build() + } +} + +enum class MyShortcut( + val id: String, val labelEnable: Int, val labelDisable: Int? = null, val iconEnable: Int, + val iconDisable: Int? = null +) { + Lock("LOCK", R.string.lock_screen, iconEnable = R.drawable.lock_fill0), + DisableCamera("DISABLE_CAMERA", R.string.disable_cam, R.string.enable_camera, + R.drawable.no_photography_fill0, R.drawable.photo_camera_fill0), + Mute("MUTE", R.string.mute, R.string.unmute, R.drawable.volume_off_fill0, R.drawable.volume_up_fill0) +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt index b8dee4a..780adb1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt @@ -1,12 +1,8 @@ package com.bintianqi.owndroid import android.app.Activity -import android.content.Context -import android.content.Intent import android.os.Bundle -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat +import android.util.Log class ShortcutsReceiverActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -17,50 +13,24 @@ class ShortcutsReceiverActivity : Activity() { when (action) { "LOCK" -> Privilege.DPM.lockNow() "DISABLE_CAMERA" -> { - Privilege.DPM.setCameraDisabled(Privilege.DAR, !Privilege.DPM.getCameraDisabled(Privilege.DAR)) - createShortcuts(this) + val state = Privilege.DPM.getCameraDisabled(Privilege.DAR) + Privilege.DPM.setCameraDisabled(Privilege.DAR, !state) + ShortcutUtils.setShortcut(this, MyShortcut.DisableCamera, state) } "MUTE" -> { - Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !Privilege.DPM.isMasterVolumeMuted(Privilege.DAR)) - createShortcuts(this) + val state = Privilege.DPM.isMasterVolumeMuted(Privilege.DAR) + Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !state) + ShortcutUtils.setShortcut(this, MyShortcut.Mute, state) } } + Log.d(TAG, "Received intent: $action") + showOperationResultToast(true) } } finally { finish() } } -} - -fun createShortcuts(context: Context) { - if (!SP.shortcuts) return - val action = "com.bintianqi.owndroid.action" - val baseIntent = Intent(context, ShortcutsReceiverActivity::class.java) - val cameraDisabled = Privilege.DPM.getCameraDisabled(Privilege.DAR) - val muted = Privilege.DPM.isMasterVolumeMuted(Privilege.DAR) - val list = listOf( - ShortcutInfoCompat.Builder(context, "LOCK") - .setIcon(IconCompat.createWithResource(context, R.drawable.screen_lock_portrait_fill0)) - .setShortLabel(context.getString(R.string.lock_screen)) - .setIntent(Intent(baseIntent).setAction("$action.LOCK")), - ShortcutInfoCompat.Builder(context, "DISABLE_CAMERA") - .setIcon( - IconCompat.createWithResource( - context, - if (cameraDisabled) R.drawable.photo_camera_fill0 else R.drawable.no_photography_fill0 - ) - ) - .setShortLabel(context.getString(if (cameraDisabled) R.string.enable_camera else R.string.disable_cam)) - .setIntent(Intent(baseIntent).setAction("$action.DISABLE_CAMERA")), - ShortcutInfoCompat.Builder(context, "MUTE") - .setIcon( - IconCompat.createWithResource( - context, - if (muted) R.drawable.volume_up_fill0 else R.drawable.volume_off_fill0 - ) - ) - .setShortLabel(context.getString(if (muted) R.string.unmute else R.string.mute)) - .setIntent(Intent(baseIntent).setAction("$action.MUTE")) - ) - ShortcutManagerCompat.setDynamicShortcuts(context, list.map { it.build() }) + companion object { + private const val TAG = "ShortcutsReceiver" + } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index f8bab24..e043570 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -315,7 +315,7 @@ fun PermissionsManagerScreen( Spacer(Modifier.padding(vertical = 4.dp)) } } - items(permissionList(), { it.permission }) { + items(runtimePermissions, { it.permission }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -630,11 +630,11 @@ fun PermittedAsAndImPackages( item { SwitchItem(R.string.allow_all, state = allowAll, onCheckedChange = { allowAll = it }) } - if (allowAll) items(packages, { it.name }) { + if (!allowAll) items(packages, { it.name }) { ApplicationItem(it) { setPackage(it.name, false) } } item { - if (allowAll) { + if (!allowAll) { PackageNameTextField(packageName, onChoosePackage, Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } Button( diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index 8d01fb9..9eca2c1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -13,14 +13,11 @@ import android.content.pm.IPackageInstaller import android.content.pm.PackageInstaller import android.os.Build.VERSION import android.util.Log -import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi -import androidx.annotation.StringRes -import androidx.core.content.pm.ShortcutManagerCompat import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP -import com.bintianqi.owndroid.createShortcuts +import com.bintianqi.owndroid.ShortcutUtils import com.rosan.dhizuku.api.Dhizuku import com.rosan.dhizuku.api.DhizukuBinderWrapper import kotlinx.coroutines.flow.MutableStateFlow @@ -90,67 +87,49 @@ val dhizukuErrorStatus = MutableStateFlow(0) data class PermissionItem( val permission: String, - @StringRes val label: Int, - @DrawableRes val icon: Int, - val profileOwnerRestricted: Boolean = false + val label: Int, + val icon: Int, + val profileOwnerRestricted: Boolean = false, + val requiresApi: Int = 23 ) -fun permissionList(): List{ - val list = mutableListOf() - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.POST_NOTIFICATIONS, R.string.permission_POST_NOTIFICATIONS, R.drawable.notifications_fill0)) - } - list.add(PermissionItem(Manifest.permission.READ_EXTERNAL_STORAGE, R.string.permission_READ_EXTERNAL_STORAGE, R.drawable.folder_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_WRITE_EXTERNAL_STORAGE, R.drawable.folder_fill0)) - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.READ_MEDIA_AUDIO, R.string.permission_READ_MEDIA_AUDIO, R.drawable.music_note_fill0)) - list.add(PermissionItem(Manifest.permission.READ_MEDIA_VIDEO, R.string.permission_READ_MEDIA_VIDEO, R.drawable.movie_fill0)) - list.add(PermissionItem(Manifest.permission.READ_MEDIA_IMAGES, R.string.permission_READ_MEDIA_IMAGES, R.drawable.image_fill0)) - } - list.add(PermissionItem(Manifest.permission.CAMERA, R.string.permission_CAMERA, R.drawable.photo_camera_fill0, true)) - list.add(PermissionItem(Manifest.permission.RECORD_AUDIO, R.string.permission_RECORD_AUDIO, R.drawable.mic_fill0, true)) - list.add(PermissionItem(Manifest.permission.ACCESS_COARSE_LOCATION, R.string.permission_ACCESS_COARSE_LOCATION, R.drawable.location_on_fill0, true)) - list.add(PermissionItem(Manifest.permission.ACCESS_FINE_LOCATION, R.string.permission_ACCESS_FINE_LOCATION, R.drawable.location_on_fill0, true)) - if(VERSION.SDK_INT >= 29) { - list.add(PermissionItem(Manifest.permission.ACCESS_BACKGROUND_LOCATION, R.string.permission_ACCESS_BACKGROUND_LOCATION, R.drawable.location_on_fill0, true)) - } - list.add(PermissionItem(Manifest.permission.READ_CONTACTS, R.string.permission_READ_CONTACTS, R.drawable.contacts_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_CONTACTS, R.string.permission_WRITE_CONTACTS, R.drawable.contacts_fill0)) - list.add(PermissionItem(Manifest.permission.READ_CALENDAR, R.string.permission_READ_CALENDAR, R.drawable.calendar_month_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_CALENDAR, R.string.permission_WRITE_CALENDAR, R.drawable.calendar_month_fill0)) - if(VERSION.SDK_INT >= 31) { - list.add(PermissionItem(Manifest.permission.BLUETOOTH_CONNECT, R.string.permission_BLUETOOTH_CONNECT, R.drawable.bluetooth_fill0)) - list.add(PermissionItem(Manifest.permission.BLUETOOTH_SCAN, R.string.permission_BLUETOOTH_SCAN, R.drawable.bluetooth_searching_fill0)) - list.add(PermissionItem(Manifest.permission.BLUETOOTH_ADVERTISE, R.string.permission_BLUETOOTH_ADVERTISE, R.drawable.bluetooth_fill0)) - } - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.NEARBY_WIFI_DEVICES, R.string.permission_NEARBY_WIFI_DEVICES, R.drawable.wifi_fill0)) - } - list.add(PermissionItem(Manifest.permission.CALL_PHONE, R.string.permission_CALL_PHONE, R.drawable.call_fill0)) - if(VERSION.SDK_INT >= 26) { - list.add(PermissionItem(Manifest.permission.ANSWER_PHONE_CALLS, R.string.permission_ANSWER_PHONE_CALLS, R.drawable.call_fill0)) - list.add(PermissionItem(Manifest.permission.READ_PHONE_NUMBERS, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0)) - } - list.add(PermissionItem(Manifest.permission.READ_PHONE_STATE, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0)) - list.add(PermissionItem(Manifest.permission.USE_SIP, R.string.permission_USE_SIP, R.drawable.call_fill0)) - if(VERSION.SDK_INT >= 31) { - list.add(PermissionItem(Manifest.permission.UWB_RANGING, R.string.permission_UWB_RANGING, R.drawable.cell_tower_fill0)) - } - list.add(PermissionItem(Manifest.permission.READ_SMS, R.string.permission_READ_SMS, R.drawable.sms_fill0)) - list.add(PermissionItem(Manifest.permission.RECEIVE_SMS, R.string.permission_RECEIVE_SMS, R.drawable.sms_fill0)) - list.add(PermissionItem(Manifest.permission.SEND_SMS, R.string.permission_SEND_SMS, R.drawable.sms_fill0)) - list.add(PermissionItem(Manifest.permission.READ_CALL_LOG, R.string.permission_READ_CALL_LOG, R.drawable.call_log_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_CALL_LOG, R.string.permission_WRITE_CALL_LOG, R.drawable.call_log_fill0)) - list.add(PermissionItem(Manifest.permission.RECEIVE_WAP_PUSH, R.string.permission_RECEIVE_WAP_PUSH, R.drawable.wifi_fill0)) - list.add(PermissionItem(Manifest.permission.BODY_SENSORS, R.string.permission_BODY_SENSORS, R.drawable.sensors_fill0, true)) - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.BODY_SENSORS_BACKGROUND, R.string.permission_BODY_SENSORS_BACKGROUND, R.drawable.sensors_fill0)) - } - if(VERSION.SDK_INT > 29) { - list.add(PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true)) - } - return list -} +@Suppress("InlinedApi") +val runtimePermissions = listOf( + PermissionItem(Manifest.permission.POST_NOTIFICATIONS, R.string.permission_POST_NOTIFICATIONS, R.drawable.notifications_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_EXTERNAL_STORAGE, R.string.permission_READ_EXTERNAL_STORAGE, R.drawable.folder_fill0), + PermissionItem(Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_WRITE_EXTERNAL_STORAGE, R.drawable.folder_fill0), + PermissionItem(Manifest.permission.READ_MEDIA_AUDIO, R.string.permission_READ_MEDIA_AUDIO, R.drawable.music_note_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_MEDIA_VIDEO, R.string.permission_READ_MEDIA_VIDEO, R.drawable.movie_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_MEDIA_IMAGES, R.string.permission_READ_MEDIA_IMAGES, R.drawable.image_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.CAMERA, R.string.permission_CAMERA, R.drawable.photo_camera_fill0, true), + PermissionItem(Manifest.permission.RECORD_AUDIO, R.string.permission_RECORD_AUDIO, R.drawable.mic_fill0, true), + PermissionItem(Manifest.permission.ACCESS_COARSE_LOCATION, R.string.permission_ACCESS_COARSE_LOCATION, R.drawable.location_on_fill0, true), + PermissionItem(Manifest.permission.ACCESS_FINE_LOCATION, R.string.permission_ACCESS_FINE_LOCATION, R.drawable.location_on_fill0, true), + PermissionItem(Manifest.permission.ACCESS_BACKGROUND_LOCATION, R.string.permission_ACCESS_BACKGROUND_LOCATION, R.drawable.location_on_fill0, true, 29), + PermissionItem(Manifest.permission.READ_CONTACTS, R.string.permission_READ_CONTACTS, R.drawable.contacts_fill0), + PermissionItem(Manifest.permission.WRITE_CONTACTS, R.string.permission_WRITE_CONTACTS, R.drawable.contacts_fill0), + PermissionItem(Manifest.permission.READ_CALENDAR, R.string.permission_READ_CALENDAR, R.drawable.calendar_month_fill0), + PermissionItem(Manifest.permission.WRITE_CALENDAR, R.string.permission_WRITE_CALENDAR, R.drawable.calendar_month_fill0), + PermissionItem(Manifest.permission.BLUETOOTH_CONNECT, R.string.permission_BLUETOOTH_CONNECT, R.drawable.bluetooth_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.BLUETOOTH_SCAN, R.string.permission_BLUETOOTH_SCAN, R.drawable.bluetooth_searching_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.BLUETOOTH_ADVERTISE, R.string.permission_BLUETOOTH_ADVERTISE, R.drawable.bluetooth_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.NEARBY_WIFI_DEVICES, R.string.permission_NEARBY_WIFI_DEVICES, R.drawable.wifi_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.CALL_PHONE, R.string.permission_CALL_PHONE, R.drawable.call_fill0), + PermissionItem(Manifest.permission.ANSWER_PHONE_CALLS, R.string.permission_ANSWER_PHONE_CALLS, R.drawable.call_fill0, requiresApi = 26), + PermissionItem(Manifest.permission.READ_PHONE_NUMBERS, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0, requiresApi = 26), + PermissionItem(Manifest.permission.READ_PHONE_STATE, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0), + PermissionItem(Manifest.permission.USE_SIP, R.string.permission_USE_SIP, R.drawable.call_fill0), + PermissionItem(Manifest.permission.UWB_RANGING, R.string.permission_UWB_RANGING, R.drawable.cell_tower_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.READ_SMS, R.string.permission_READ_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.RECEIVE_SMS, R.string.permission_RECEIVE_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.SEND_SMS, R.string.permission_SEND_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.READ_CALL_LOG, R.string.permission_READ_CALL_LOG, R.drawable.call_log_fill0), + PermissionItem(Manifest.permission.WRITE_CALL_LOG, R.string.permission_WRITE_CALL_LOG, R.drawable.call_log_fill0), + PermissionItem(Manifest.permission.RECEIVE_WAP_PUSH, R.string.permission_RECEIVE_WAP_PUSH, R.drawable.wifi_fill0), + PermissionItem(Manifest.permission.BODY_SENSORS, R.string.permission_BODY_SENSORS, R.drawable.sensors_fill0, true), + PermissionItem(Manifest.permission.BODY_SENSORS_BACKGROUND, R.string.permission_BODY_SENSORS_BACKGROUND, R.drawable.sensors_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true, 29) +).filter { VERSION.SDK_INT >= it.requiresApi } @RequiresApi(26) fun handleNetworkLogs(context: Context, batchToken: Long) { @@ -468,16 +447,15 @@ fun parsePackageInstallerMessage(context: Context, result: Intent): String { fun handlePrivilegeChange(context: Context) { val privilege = Privilege.status.value SP.dhizukuServer = false + SP.shortcuts = privilege.activated if (privilege.activated) { - createShortcuts(context) + ShortcutUtils.setAllShortcuts(context) if (!privilege.dhizuku) { setDefaultAffiliationID() } } else { SP.isDefaultAffiliationIdSet = false - if(VERSION.SDK_INT >= 25) { - ShortcutManagerCompat.removeAllDynamicShortcuts(context) - } + ShortcutUtils.setAllShortcuts(context) SP.isApiEnabled = false } } From a9452ac14e1df26eb6e4f1152532188011d470e2 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sat, 27 Sep 2025 15:57:45 +0800 Subject: [PATCH 08/26] ViewModel refactoring: Work profile part close #169 Improve UI, fix #172 fix #165 --- app/src/main/AndroidManifest.xml | 1 + .../com/bintianqi/owndroid/MainActivity.kt | 21 +- .../com/bintianqi/owndroid/MyViewModel.kt | 86 +++++- .../com/bintianqi/owndroid/ShizukuService.kt | 4 +- .../bintianqi/owndroid/dpm/Applications.kt | 4 + .../java/com/bintianqi/owndroid/dpm/System.kt | 1 + .../com/bintianqi/owndroid/dpm/WorkProfile.kt | 268 +++++++++++------- app/src/main/res/values-ru/strings.xml | 7 - app/src/main/res/values-tr/strings.xml | 7 - app/src/main/res/values-zh-rCN/strings.xml | 11 +- app/src/main/res/values/strings.xml | 11 +- 11 files changed, 275 insertions(+), 146 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bdbe7d4..c316c33 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,7 @@ + diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 80cf791..1443842 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -417,11 +417,22 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { AddApnSettingScreen(it.arguments?.getParcelable("setting"), ::navigateUp) } composable { WorkProfileScreen(::navigateUp, ::navigate) } - composable { OrganizationOwnedProfileScreen(::navigateUp) } - composable { CreateWorkProfileScreen(::navigateUp) } - composable { SuspendPersonalAppScreen(::navigateUp) } - composable { CrossProfileIntentFilterScreen(::navigateUp) } - composable { DeleteWorkProfileScreen(::navigateUp) } + composable { + OrganizationOwnedProfileScreen(vm::activateOrgProfileByShizuku, ::navigateUp) + } + composable { + CreateWorkProfileScreen(vm::createWorkProfile, ::navigateUp) + } + composable { + SuspendPersonalAppScreen( + vm::getPersonalAppsSuspendedReason, vm::setPersonalAppsSuspended, + vm::getProfileMaxTimeOff, vm::setProfileMaxTimeOff, ::navigateUp + ) + } + composable { + CrossProfileIntentFilterScreen(vm::addCrossProfileIntentFilter, ::navigateUp) + } + composable { DeleteWorkProfileScreen(vm::wipeData, ::navigateUp) } composable { val canSwitchView = (it.toRoute() as ApplicationsList).canSwitchView diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 5f5d6ea..c2d58c4 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,5 +1,6 @@ package com.bintianqi.owndroid +import android.accounts.Account import android.app.ActivityOptions import android.app.Application import android.app.PendingIntent @@ -35,13 +36,17 @@ import com.bintianqi.owndroid.Privilege.DPM import com.bintianqi.owndroid.dpm.ACTIVATE_DEVICE_OWNER_COMMAND import com.bintianqi.owndroid.dpm.AppStatus import com.bintianqi.owndroid.dpm.CaCertInfo +import com.bintianqi.owndroid.dpm.CreateWorkProfileOptions import com.bintianqi.owndroid.dpm.DelegatedAdmin import com.bintianqi.owndroid.dpm.DeviceAdmin import com.bintianqi.owndroid.dpm.FrpPolicyInfo import com.bintianqi.owndroid.dpm.HardwareProperties +import com.bintianqi.owndroid.dpm.IntentFilterDirection +import com.bintianqi.owndroid.dpm.IntentFilterOptions import com.bintianqi.owndroid.dpm.PendingSystemUpdateInfo import com.bintianqi.owndroid.dpm.SystemOptionsStatus import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo +import com.bintianqi.owndroid.dpm.activateOrgProfileCommand import com.bintianqi.owndroid.dpm.delegatedScopesList import com.bintianqi.owndroid.dpm.getPackageInstaller import com.bintianqi.owndroid.dpm.handlePrivilegeChange @@ -294,7 +299,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } fun setCmPackage(name: String, status: Boolean) { cmPackages.update { list -> - if (status) list + getAppInfo(name) else list.dropWhile { it.name == name } + if (status) list + getAppInfo(name) else list.filter { it.name != name } } } @RequiresApi(34) @@ -315,7 +320,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } fun setPimPackage(name: String, status: Boolean) { pimPackages.update { packages -> - if (status) packages + getAppInfo(name) else packages.dropWhile { it.name == name } + if (status) packages + getAppInfo(name) else packages.filter { it.name != name } } } fun setPimPolicy(allowAll: Boolean): Boolean { @@ -335,7 +340,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } fun setPasPackage(name: String, status: Boolean) { pasPackages.update { packages -> - if (status) packages + getAppInfo(name) else packages.dropWhile { it.name == name } + if (status) packages + getAppInfo(name) else packages.filter { it.name != name } } } fun setPasPolicy(allowAll: Boolean): Boolean { @@ -1022,6 +1027,81 @@ class MyViewModel(application: Application): AndroidViewModel(application) { false } } + fun createWorkProfile(options: CreateWorkProfileOptions): Intent { + val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) + if (VERSION.SDK_INT >= 23) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, + MyAdminComponent + ) + } else { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_PACKAGE_NAME, + application.packageName + ) + } + if (options.migrateAccount && VERSION.SDK_INT >= 22) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE, + Account(options.accountName, options.accountType) + ) + if (VERSION.SDK_INT >= 26) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION, + options.keepAccount + ) + } + } + if (VERSION.SDK_INT >= 24) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, + options.skipEncrypt + ) + } + if (VERSION.SDK_INT >= 33) { + intent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_ALLOW_OFFLINE, options.offline) + } + return intent + } + fun activateOrgProfileByShizuku(callback: (Boolean) -> Unit) { + viewModelScope.launch { + var succeed = false + useShizuku(application) { service -> + val result = IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand) + succeed = result?.getInt("code", -1) == 0 + callback(succeed) + } + if (succeed) Privilege.updateStatus() + } + } + @RequiresApi(30) + fun getPersonalAppsSuspendedReason(): Int { + return DPM.getPersonalAppsSuspendedReasons(DAR) + } + @RequiresApi(30) + fun setPersonalAppsSuspended(suspended: Boolean) { + DPM.setPersonalAppsSuspended(DAR, suspended) + } + @RequiresApi(30) + fun getProfileMaxTimeOff(): Long { + return DPM.getManagedProfileMaximumTimeOff(DAR) + } + @RequiresApi(30) + fun setProfileMaxTimeOff(time: Long) { + DPM.setManagedProfileMaximumTimeOff(DAR, time) + } + fun addCrossProfileIntentFilter(options: IntentFilterOptions) { + val filter = IntentFilter(options.action) + if (options.category.isNotEmpty()) filter.addCategory(options.category) + if (options.mimeType.isNotEmpty()) filter.addDataType(options.mimeType) + val flags = when(options.direction) { + IntentFilterDirection.ToManaged -> DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED + IntentFilterDirection.ToParent -> DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT + IntentFilterDirection.Both -> DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED or + DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT + } + DPM.addCrossProfileIntentFilter(DAR, filter, flags) + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt index a70eecf..99c5b0d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt @@ -57,7 +57,9 @@ fun useShizuku(context: Context, action: (IBinder?) -> Unit) { } else { Sui.init(context.packageName) fun requestPermissionResultListener(requestCode: Int, grantResult: Int) { - if(grantResult != PackageManager.PERMISSION_GRANTED) { + if (grantResult == PackageManager.PERMISSION_GRANTED) { + Shizuku.bindUserService(getShizukuArgs(context), connection) + } else { context.popToast(R.string.permission_denied) } Shizuku.removeRequestPermissionResultListener(::requestPermissionResultListener) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index e043570..b95f472 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -273,6 +273,7 @@ fun ApplicationDetailsScreen( ) if(VERSION.SDK_INT >= 28) FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { dialog = 1 } FunctionItem(R.string.uninstall, icon = R.drawable.delete_fill0) { dialog = 2 } + Spacer(Modifier.height(40.dp)) } if(dialog == 1 && VERSION.SDK_INT >= 28) ClearAppStorageDialog(packageName, vm::clearAppData) { dialog = 0 } @@ -604,6 +605,7 @@ fun CredentialManagerPolicyScreen( ) { Text(stringResource(R.string.apply)) } + Spacer(Modifier.height(40.dp)) } } } @@ -658,6 +660,7 @@ fun PermittedAsAndImPackages( } Spacer(Modifier.height(10.dp)) Notes(note, HorizontalPadding) + Spacer(Modifier.height(40.dp)) } } } @@ -767,6 +770,7 @@ fun PackageFunctionScreen( Text(stringResource(R.string.add)) } if (notes != null) Notes(notes, HorizontalPadding) + Spacer(Modifier.height(40.dp)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 9138dfd..1016c37 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -1262,6 +1262,7 @@ private fun LockTaskPackages( Text(stringResource(R.string.add)) } Notes(R.string.info_lock_task_packages) + Spacer(Modifier.height(40.dp)) } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt index 1fac984..642920d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt @@ -1,22 +1,9 @@ package com.bintianqi.owndroid.dpm -import android.accounts.Account -import android.app.admin.DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_ALLOW_OFFLINE -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_PACKAGE_NAME -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION -import android.app.admin.DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT -import android.app.admin.DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED -import android.app.admin.DevicePolicyManager.PERSONAL_APPS_NOT_SUSPENDED -import android.app.admin.DevicePolicyManager.PERSONAL_APPS_SUSPENDED_PROFILE_TIMEOUT +import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.WIPE_EUICC import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE -import android.content.ActivityNotFoundException import android.content.Intent -import android.content.IntentFilter import android.os.Binder import android.os.Build.VERSION import androidx.activity.compose.rememberLauncherForActivityResult @@ -24,6 +11,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility 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 @@ -33,8 +21,13 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -45,6 +38,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -55,18 +49,19 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.IUserService -import com.bintianqi.owndroid.MyAdminComponent +import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.CircularProgressDialog +import com.bintianqi.owndroid.ui.ExpandExposedTextFieldIcon +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem -import com.bintianqi.owndroid.useShizuku +import com.bintianqi.owndroid.yesOrNo import kotlinx.serialization.Serializable @Serializable object WorkProfile @@ -86,22 +81,28 @@ fun WorkProfileScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } } +data class CreateWorkProfileOptions( + val skipEncrypt: Boolean, val offline: Boolean, val migrateAccount: Boolean, + val accountName: String, val accountType: String, val keepAccount: Boolean +) + @Serializable object CreateWorkProfile @Composable -fun CreateWorkProfileScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun CreateWorkProfileScreen( + createIntent: (CreateWorkProfileOptions) -> Intent, onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - MyScaffold(R.string.create_work_profile, onNavigateUp) { + MyScaffold(R.string.create_work_profile, onNavigateUp, 0.dp) { var skipEncrypt by remember { mutableStateOf(false) } var offlineProvisioning by remember { mutableStateOf(true) } var migrateAccount by remember { mutableStateOf(false) } var migrateAccountName by remember { mutableStateOf("") } var migrateAccountType by remember { mutableStateOf("") } var keepAccount by remember { mutableStateOf(true) } - if(VERSION.SDK_INT >= 22) { - CheckBoxItem(R.string.migrate_account, migrateAccount) { migrateAccount = it } + if (VERSION.SDK_INT >= 22) { + FullWidthCheckBoxItem(R.string.migrate_account, migrateAccount) { migrateAccount = it } AnimatedVisibility(migrateAccount) { val fr = FocusRequester() Column(modifier = Modifier.padding(start = 10.dp)) { @@ -110,47 +111,40 @@ fun CreateWorkProfileScreen(onNavigateUp: () -> Unit) { label = { Text(stringResource(R.string.account_name)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions { fr.requestFocus() }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) ) OutlinedTextField( value = migrateAccountType, onValueChange = { migrateAccountType = it }, label = { Text(stringResource(R.string.account_type)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions { focusMgr.clearFocus() }, - modifier = Modifier.fillMaxWidth().focusRequester(fr) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .focusRequester(fr) ) if(VERSION.SDK_INT >= 26) { - CheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = it } + FullWidthCheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = it } } } } } - if(VERSION.SDK_INT >= 24) CheckBoxItem(R.string.skip_encryption, skipEncrypt) { skipEncrypt = it } - if(VERSION.SDK_INT >= 33) CheckBoxItem(R.string.offline_provisioning, offlineProvisioning) { offlineProvisioning = it } + if (VERSION.SDK_INT >= 24) FullWidthCheckBoxItem( + R.string.skip_encryption, skipEncrypt + ) { skipEncrypt = it } + if (VERSION.SDK_INT >= 33) FullWidthCheckBoxItem( + R.string.offline_provisioning, offlineProvisioning + ) { offlineProvisioning = it } Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - try { - val intent = Intent(ACTION_PROVISION_MANAGED_PROFILE) - if(VERSION.SDK_INT >= 23) { - intent.putExtra(EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, MyAdminComponent) - } else { - intent.putExtra(EXTRA_PROVISIONING_DEVICE_ADMIN_PACKAGE_NAME, context.packageName) - } - if(migrateAccount && VERSION.SDK_INT >= 22) { - intent.putExtra(EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE, Account(migrateAccountName, migrateAccountType)) - if(VERSION.SDK_INT >= 26) { - intent.putExtra(EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION, keepAccount) - } - } - if(VERSION.SDK_INT >= 24) { intent.putExtra(EXTRA_PROVISIONING_SKIP_ENCRYPTION, skipEncrypt) } - if(VERSION.SDK_INT >= 33) { intent.putExtra(EXTRA_PROVISIONING_ALLOW_OFFLINE, offlineProvisioning) } - launcher.launch(intent) - } catch(_: ActivityNotFoundException) { - context.popToast(R.string.unsupported) - } + val intent = createIntent(CreateWorkProfileOptions( + skipEncrypt, offlineProvisioning, migrateAccount, migrateAccountName, + migrateAccountType, keepAccount + )) + launcher.launch(intent) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.create)) } @@ -161,18 +155,19 @@ fun CreateWorkProfileScreen(onNavigateUp: () -> Unit) { @RequiresApi(30) @Composable -fun OrganizationOwnedProfileScreen(onNavigateUp: () -> Unit) { +fun OrganizationOwnedProfileScreen( + onActivate: ((Boolean) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var activating by remember { mutableStateOf(false) } var dialog by remember { mutableStateOf(false) } MyScaffold(R.string.org_owned_work_profile, onNavigateUp) { Button({ - useShizuku(context) { service -> - val result = IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand) - if (result?.getInt("code", -1) == 0) { - context.showOperationResultToast(true) - } else { - context.showOperationResultToast(false) - } + activating = true + onActivate { + activating = false + context.showOperationResultToast(it) + if (it) onNavigateUp() } }) { Text(stringResource(R.string.shizuku)) @@ -189,6 +184,7 @@ fun OrganizationOwnedProfileScreen(onNavigateUp: () -> Unit) { }, onDismissRequest = { dialog = false } ) + if (activating) CircularProgressDialog { } } } @@ -199,41 +195,46 @@ val activateOrgProfileCommand = "dpm mark-profile-owner-on-organization-owned-de @RequiresApi(30) @Composable -fun SuspendPersonalAppScreen(onNavigateUp: () -> Unit) { +fun SuspendPersonalAppScreen( + getSuspendedReasons: () -> Int, setSuspended: (Boolean) -> Unit, getMaxTime: () -> Long, + setMaxTime: (Long) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var suspend by remember { mutableStateOf(Privilege.DPM.getPersonalAppsSuspendedReasons(Privilege.DAR) != PERSONAL_APPS_NOT_SUSPENDED) } + var reason by remember { mutableIntStateOf(DevicePolicyManager.PERSONAL_APPS_NOT_SUSPENDED) } + var time by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + reason = getSuspendedReasons() + time = getMaxTime().toString() + } MyScaffold(R.string.suspend_personal_app, onNavigateUp) { - SwitchItem(R.string.suspend_personal_app, state = suspend, + SwitchItem(R.string.suspend_personal_app, state = reason != 0, onCheckedChange = { - Privilege.DPM.setPersonalAppsSuspended(Privilege.DAR, it) - suspend = Privilege.DPM.getPersonalAppsSuspendedReasons(Privilege.DAR) != PERSONAL_APPS_NOT_SUSPENDED + setSuspended(it) + reason = if (it) DevicePolicyManager.PERSONAL_APPS_SUSPENDED_EXPLICITLY + else DevicePolicyManager.PERSONAL_APPS_NOT_SUSPENDED }, padding = false ) - var time by remember { mutableStateOf("") } - time = Privilege.DPM.getManagedProfileMaximumTimeOff(Privilege.DAR).toString() Spacer(Modifier.padding(vertical = 10.dp)) Text(text = stringResource(R.string.profile_max_time_off), style = typography.titleLarge) Text(text = stringResource(R.string.profile_max_time_out_desc)) - Text( - text = stringResource( - R.string.personal_app_suspended_because_timeout, - Privilege.DPM.getPersonalAppsSuspendedReasons(Privilege.DAR) == PERSONAL_APPS_SUSPENDED_PROFILE_TIMEOUT - ) - ) + Text(stringResource( + R.string.personal_app_suspended_because_timeout, + stringResource((reason == DevicePolicyManager.PERSONAL_APPS_SUSPENDED_PROFILE_TIMEOUT).yesOrNo) + )) OutlinedTextField( value = time, onValueChange = { time=it }, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), label = { Text(stringResource(R.string.time_unit_ms)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }) ) - Text(text = stringResource(R.string.cannot_less_than_72_hours)) Button( onClick = { - Privilege.DPM.setManagedProfileMaximumTimeOff(Privilege.DAR, time.toLong()) + setMaxTime(time.toLong()) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + enabled = time.toLongOrNull() != null ) { Text(stringResource(R.string.apply)) } @@ -241,14 +242,33 @@ fun SuspendPersonalAppScreen(onNavigateUp: () -> Unit) { } } +data class IntentFilterOptions( + val action: String, val category: String, val mimeType: String, + val direction: IntentFilterDirection +) +enum class IntentFilterDirection(val text: Int) { + ToParent(R.string.work_to_personal), ToManaged(R.string.personal_to_work), + Both(R.string.both_direction) +} + @Serializable object CrossProfileIntentFilter +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun CrossProfileIntentFilterScreen(onNavigateUp: () -> Unit) { +fun CrossProfileIntentFilterScreen( + addFilter: (IntentFilterOptions) -> Unit, + onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current + var action by remember { mutableStateOf("") } + var customCategory by remember { mutableStateOf(false) } + var category by remember { mutableStateOf("") } + var customMimeType by remember { mutableStateOf(false) } + var mimeType by remember { mutableStateOf("") } + var dropdown by remember { mutableStateOf(false) } + var direction by remember { mutableStateOf(IntentFilterDirection.Both) } MyScaffold(R.string.intent_filter, onNavigateUp) { - var action by remember { mutableStateOf("") } OutlinedTextField( value = action, onValueChange = { action = it }, label = { Text("Action") }, @@ -256,32 +276,61 @@ fun CrossProfileIntentFilterScreen(onNavigateUp: () -> Unit) { keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }), modifier = Modifier.fillMaxWidth() ) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - Privilege.DPM.addCrossProfileIntentFilter(Privilege.DAR, IntentFilter(action), FLAG_PARENT_CAN_ACCESS_MANAGED) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.add_intent_filter_work_to_personal)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(customCategory, { + customCategory = it + category = "" + }) + OutlinedTextField( + category, { category = it }, Modifier.fillMaxWidth(), + label = { Text("Category") }, enabled = customCategory + ) + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(customMimeType, { + customMimeType = it + mimeType = "" + }) + OutlinedTextField( + mimeType, { mimeType = it }, Modifier.fillMaxWidth(), + label = { Text("MIME type") }, enabled = customMimeType + ) + } + ExposedDropdownMenuBox(dropdown, { dropdown = it }, Modifier.padding(vertical = 5.dp)) { + OutlinedTextField( + stringResource(direction.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + label = { Text(stringResource(R.string.direction)) }, readOnly = true, + trailingIcon = { ExpandExposedTextFieldIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + IntentFilterDirection.entries.forEach { + DropdownMenuItem({ Text(stringResource(it.text)) }, { + direction = it + dropdown = false + }) + } + } } Button( - onClick = { - Privilege.DPM.addCrossProfileIntentFilter(Privilege.DAR, IntentFilter(action), FLAG_MANAGED_CAN_ACCESS_PARENT) + { + addFilter(IntentFilterOptions( + action, category, mimeType, direction + )) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + enabled = action.isNotBlank() && (!customCategory || category.isNotBlank()) && + (!customMimeType || mimeType.isNotBlank()) ) { - Text(stringResource(R.string.add_intent_filter_personal_to_work)) + Text(stringResource(R.string.add)) } - Spacer(Modifier.padding(vertical = 2.dp)) Button( onClick = { Privilege.DPM.clearCrossProfileIntentFilters(Privilege.DAR) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) ) { Text(stringResource(R.string.clear_cross_profile_filters)) } @@ -292,28 +341,34 @@ fun CrossProfileIntentFilterScreen(onNavigateUp: () -> Unit) { @Serializable object DeleteWorkProfile @Composable -fun DeleteWorkProfileScreen(onNavigateUp: () -> Unit) { +fun DeleteWorkProfileScreen( + deleteProfile: (Boolean, Int, String) -> Unit, onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current - var flag by remember { mutableIntStateOf(0) } + var flags by remember { mutableIntStateOf(0) } var warning by remember { mutableStateOf(false) } - var silent by remember { mutableStateOf(false) } var reason by remember { mutableStateOf("") } MyScaffold(R.string.delete_work_profile, onNavigateUp) { - CheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { flag = flag xor WIPE_EXTERNAL_STORAGE } - if(VERSION.SDK_INT >= 28) CheckBoxItem(R.string.wipe_euicc, flag and WIPE_EUICC != 0) { flag = flag xor WIPE_EUICC } - CheckBoxItem(R.string.wipe_silently, silent) { silent = it } - AnimatedVisibility(!silent && VERSION.SDK_INT >= 28) { - OutlinedTextField( - value = reason, onValueChange = { reason = it }, - label = { Text(stringResource(R.string.reason)) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) - ) + CheckBoxItem(R.string.wipe_external_storage, flags and WIPE_EXTERNAL_STORAGE != 0) { + flags = flags xor WIPE_EXTERNAL_STORAGE } + if(VERSION.SDK_INT >= 28) CheckBoxItem(R.string.wipe_euicc, flags and WIPE_EUICC != 0) { + flags = flags xor WIPE_EUICC + } + CheckBoxItem(R.string.wipe_silently, flags and DevicePolicyManager.WIPE_SILENTLY != 0) { + flags = flags xor DevicePolicyManager.WIPE_SILENTLY + reason = "" + } + if (VERSION.SDK_INT >= 28) OutlinedTextField( + value = reason, onValueChange = { reason = it }, + label = { Text(stringResource(R.string.reason)) }, + enabled = flags and DevicePolicyManager.WIPE_SILENTLY == 0, + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) + ) Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { focusMgr.clearFocus() - silent = reason == "" warning = true }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), @@ -322,8 +377,7 @@ fun DeleteWorkProfileScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.delete)) } } - if(warning) { - LaunchedEffect(Unit) { silent = reason == "" } + if (warning) { AlertDialog( title = { Text(text = stringResource(R.string.warning), color = colorScheme.error) @@ -335,11 +389,7 @@ fun DeleteWorkProfileScreen(onNavigateUp: () -> Unit) { confirmButton = { TextButton( onClick = { - if(VERSION.SDK_INT >= 28 && !silent) { - Privilege.DPM.wipeData(flag, reason) - } else { - Privilege.DPM.wipeData(flag) - } + deleteProfile(false, flags, reason) }, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) ) { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3f7533d..86d42a7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -75,7 +75,6 @@ Владелец профиля Владелец устройства Делегированные администраторы - делегированные возможности Управление ограничениями для приложений Управление сертификатами Выберите сертификат связки ключей @@ -101,7 +100,6 @@ Отключить управление аккаунтами Тип аккаунта Передача прав владения - Имя целевого компонента Информация на экране блокировки Сообщение поддержки Краткое сообщение @@ -212,8 +210,6 @@ Время получения обновления: %1$s Установить системное обновление Выберите OTA-пакет... - Начать установку системного обновления - Установка системного обновления не удалась: Низкий заряд батареи Файл обновления недействителен Неверная версия ОС @@ -339,10 +335,7 @@ Максимальное время отключения Личные приложения будут приостановлены после закрытия рабочего профиля на указанное время. 0 означает отсутствие ограничения. Личное приложение приостановлено по причине: %1$s - Не может быть меньше 72 часов Фильтр намерений - Добавить (из рабочего в личный) - Добавить (из личного в рабочий) Очистить все фильтры Идентификатор организации Длина должна быть от 6 до 64 символов diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c95bc0d..31f9576 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -76,7 +76,6 @@ Profil Sahibi Cihaz Sahibi Yetkilendirilmiş Yöneticiler - Yetkilendirilmiş Kapsam Uygulama Kısıtlamalarını Yönet Sertifikaları Yönet KeyChain Sertifikasını Seç @@ -102,7 +101,6 @@ Hesap Yönetimini Devre Dışı Bırak Hesap Türü Sahipliği Devret - Hedef Bileşen Adı Kilit Ekranı Bilgisi Destek Mesajları Kısa Mesaj @@ -243,8 +241,6 @@ Sistem Güncellemesini Yükle OTA paketini seç... - Sistem güncellemesini yüklemeye başla - "Sistem güncellemesi yüklenemedi: " Pil seviyesi düşük Güncelleme dosyası geçersiz Yanlış işletim sistemi sürümü @@ -365,10 +361,7 @@ Maksimum Kapalı Kalma Süresi İş profili kapatıldıktan sonra kişisel uygulamalar bu süre boyunca askıya alınacak. 0, sınırsız anlamına gelir. Kişisel uygulama şu nedenle askıya alındı: %1$s - 72 saatten az olamaz Niyet Filtresi - Ekle (işten kişisel profile) - Ekle (kişisel profilden işe) Tüm filtreleri temizle Kurum Kimliği Uzunluk 6 ile 64 karakter arasında olmalıdır diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ce5eda5..e7a5914 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -74,7 +74,6 @@ Profile owner Device owner 委托管理员 - 委托作用域 管理应用限制 管理证书 选择密钥链证书 @@ -99,7 +98,6 @@ 禁用账号管理 账号类型 转移所有权 - 目标组件名 锁屏提示信息 提供支持的消息 提供支持的短消息 @@ -221,8 +219,6 @@ 系统更新接收时间: %1$s 安装系统更新 选择OTA包... - 开始安装系统更新 - 安装系统更新失败: 电量低 OTA包无效 系统版本错误 @@ -343,10 +339,11 @@ 资料关闭时间 工作资料处于关闭状态的时间达到该限制后会挂起个人应用,0为无限制 个人应用已经因此挂起:%1$s - 不能少于72小时 Intent过滤器 - 添加(工作到个人) - 添加(个人到工作) + 方向 + 双向 + 工作到个人 + 个人到工作 清除所有过滤器 组织ID 长度应在6~64个字符之间 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da76e17..704a87d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -79,7 +79,6 @@ Profile owner Device owner Delegated admins - delegated scope Manage application restrictions Manage certificates Select KeyChain certificate @@ -105,7 +104,6 @@ Disable account management Account type Transfer Ownership - Target component name Lock screen info Support Messages Short message @@ -249,8 +247,6 @@ Install system update Select OTA package... - Start installing system update - "Install system update failed: " Battery is low Update file is invalid Incorrect OS version @@ -374,10 +370,11 @@ Max time off Personal apps will be suspended after the work profile is closed for this amount of time. 0 means no limit. Personal app suspended because of this: %1$s - Cannot less than 72 hours Intent filter - Add(work to personal) - Add(personal to work) + Direction + Both direction + Work to personal + Personal to work Clear all filters Organization ID The length should be between 6~64 characters From 43b1314e3aba5b30f5d45300cef508593c4b3c50 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Tue, 30 Sep 2025 21:21:12 +0800 Subject: [PATCH 09/26] ViewModel refactoring: Users part Bugfix and improvement (#166, #174, #177, #178) --- Readme-en.md | 40 +-- Readme.md | 40 +-- .../com/bintianqi/owndroid/ApiReceiver.kt | 25 +- .../com/bintianqi/owndroid/MainActivity.kt | 26 +- .../com/bintianqi/owndroid/MyViewModel.kt | 192 ++++++++-- .../main/java/com/bintianqi/owndroid/Utils.kt | 22 +- .../com/bintianqi/owndroid/dpm/Network.kt | 26 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 90 +++-- .../java/com/bintianqi/owndroid/dpm/Users.kt | 339 ++++++++---------- app/src/main/res/values-ru/strings.xml | 3 +- app/src/main/res/values-tr/strings.xml | 3 +- app/src/main/res/values-zh-rCN/strings.xml | 7 +- app/src/main/res/values/strings.xml | 7 +- 13 files changed, 471 insertions(+), 349 deletions(-) diff --git a/Readme-en.md b/Readme-en.md index b367766..ee1cd8f 100644 --- a/Readme-en.md +++ b/Readme-en.md @@ -93,27 +93,25 @@ Samsung restricts Android's multiple users feature. There is currently no soluti ## API -OwnDroid provides an API based on Intent and BroadcastReceiver. +OwnDroid provides an Intent-based API. You need to set the API key in settings and enable the API. The numbers in brackets represent the minimum Android version required. -| ID | Extras | Minimum Android version | -|--------------------------|------------------------|:-----------------------:| -| `HIDE` | `package` | | -| `UNHIDE` | `package` | | -| `SUSPEND` | `package` | 7 | -| `UNSUSPEND` | `package` | 7 | -| `ADD_USER_RESTRICTION` | `restriction` | | -| `CLEAR_USER_RESTRICTION` | `restriction` | | -| `SET_PERMISSION_DEFAULT` | `package` `permission` | 6 | -| `SET_PERMISSION_GRANTED` | `package` `permission` | 6 | -| `SET_PERMISSION_DENIED` | `package` `permission` | 6 | -| `SET_CAMERA_DISABLED` | | | -| `SET_CAMERA_ENABLED` | | | -| `SET_USB_DISABLED` | | 12 | -| `SET_USB_ENABLED` | | 12 | -| `LOCK` | | | -| `REBOOT` | | 7 | - -[Available user restrictions](https://developer.android.com/reference/android/os/UserManager#constants_1) +- HIDE(package: String) +- UNHIDE(package: String) +- SUSPEND(package: String) (7) +- UNSUSPEND(package: String) (7) +- ADD_USER_RESTRICTION(restriction: Boolean) +- CLEAR_USER_RESTRICTION(restriction: Boolean) +- SET_PERMISSION_DEFAULT(package: String, permission: String) (6) +- SET_PERMISSION_GRANTED(package: String, permission: String) (6) +- SET_PERMISSION_DENIED(package: String, permission: String) (6) +- SET_SCREEN_CAPTURE_DISABLED() +- SET_SCREEN_CAPTURE_ENABLED() +- SET_CAMERA_DISABLED() +- SET_CAMERA_ENABLED() +- SET_USB_DISABLED() (12) +- SET_USB_ENABLED() (12) +- LOCK() +- REBOOT() (7) ```shell # An example of hiding app in ADB shell @@ -129,6 +127,8 @@ val intent = Intent("com.bintianqi.owndroid.action.HIDE") context.sendBroadcast(intent) ``` +[Available user restrictions](https://developer.android.com/reference/android/os/UserManager#constants_1) + ## Build You can use Gradle in command line to build OwnDroid. diff --git a/Readme.md b/Readme.md index 3e4de41..92454c1 100644 --- a/Readme.md +++ b/Readme.md @@ -91,27 +91,25 @@ user limit reached ## API -OwnDroid提供了一个基于Intent和BroadcastReceiver的API。 +OwnDroid提供了一个基于Intent的API。你需要在设置中设置密钥并启用API。括号中的数字是最小的安卓版本。 -| ID | Extra | 最小安卓版本 | -|--------------------------|------------------------|:------:| -| `HIDE` | `package` | | -| `UNHIDE` | `package` | | -| `SUSPEND` | `package` | 7 | -| `UNSUSPEND` | `package` | 7 | -| `ADD_USER_RESTRICTION` | `restriction` | | -| `CLEAR_USER_RESTRICTION` | `restriction` | | -| `SET_PERMISSION_DEFAULT` | `package` `permission` | 6 | -| `SET_PERMISSION_GRANTED` | `package` `permission` | 6 | -| `SET_PERMISSION_DENIED` | `package` `permission` | 6 | -| `SET_CAMERA_DISABLED` | | | -| `SET_CAMERA_ENABLED` | | | -| `SET_USB_DISABLED` | | 12 | -| `SET_USB_ENABLED` | | 12 | -| `LOCK` | | | -| `REBOOT` | | 7 | - -[可用的用户限制](https://developer.android.google.cn/reference/android/os/UserManager#constants_1) +- HIDE(package: String) +- UNHIDE(package: String) +- SUSPEND(package: String) (7) +- UNSUSPEND(package: String) (7) +- ADD_USER_RESTRICTION(restriction: Boolean) +- CLEAR_USER_RESTRICTION(restriction: Boolean) +- SET_PERMISSION_DEFAULT(package: String, permission: String) (6) +- SET_PERMISSION_GRANTED(package: String, permission: String) (6) +- SET_PERMISSION_DENIED(package: String, permission: String) (6) +- SET_SCREEN_CAPTURE_DISABLED() +- SET_SCREEN_CAPTURE_ENABLED() +- SET_CAMERA_DISABLED() +- SET_CAMERA_ENABLED() +- SET_USB_DISABLED() (12) +- SET_USB_ENABLED() (12) +- LOCK() +- REBOOT() (7) ```shell # 一个在ADB shell中隐藏app的示例 @@ -127,6 +125,8 @@ val intent = Intent("com.bintianqi.owndroid.action.HIDE") context.sendBroadcast(intent) ``` +[可用的用户限制](https://developer.android.google.cn/reference/android/os/UserManager#constants_1) + ## 构建 你可以在命令行中使用Gradle以构建OwnDroid diff --git a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt index 5f12d1d..58b0699 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt @@ -20,13 +20,13 @@ class ApiReceiver: BroadcastReceiver() { if (!permission.isNullOrEmpty()) log += "\npermission: $permission" try { @SuppressWarnings("NewApi") - val ok = when(intent.action?.removePrefix("com.bintianqi.owndroid.action.")) { + when(intent.action?.removePrefix("com.bintianqi.owndroid.action.")) { "HIDE" -> Privilege.DPM.setApplicationHidden(Privilege.DAR, app, true) "UNHIDE" -> Privilege.DPM.setApplicationHidden(Privilege.DAR, app, false) - "SUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), true).isEmpty() - "UNSUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), false).isEmpty() - "ADD_USER_RESTRICTION" -> { Privilege.DPM.addUserRestriction(Privilege.DAR, restriction); true } - "CLEAR_USER_RESTRICTION" -> { Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction); true } + "SUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), true) + "UNSUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), false) + "ADD_USER_RESTRICTION" -> { Privilege.DPM.addUserRestriction(Privilege.DAR, restriction) } + "CLEAR_USER_RESTRICTION" -> { Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction) } "SET_PERMISSION_DEFAULT" -> { Privilege.DPM.setPermissionGrantState( Privilege.DAR, app!!, permission!!, @@ -45,30 +45,31 @@ class ApiReceiver: BroadcastReceiver() { DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED ) } - "LOCK" -> { Privilege.DPM.lockNow(); true } - "REBOOT" -> { Privilege.DPM.reboot(Privilege.DAR); true } + "LOCK" -> { Privilege.DPM.lockNow() } + "REBOOT" -> { Privilege.DPM.reboot(Privilege.DAR) } "SET_CAMERA_DISABLED" -> { Privilege.DPM.setCameraDisabled(Privilege.DAR, true) - true } "SET_CAMERA_ENABLED" -> { Privilege.DPM.setCameraDisabled(Privilege.DAR, false) - true } "SET_USB_DISABLED" -> { Privilege.DPM.isUsbDataSignalingEnabled = false - true } "SET_USB_ENABLED" -> { Privilege.DPM.isUsbDataSignalingEnabled = true - true + } + "SET_SCREEN_CAPTURE_DISABLED" -> { + Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, true) + } + "SET_SCREEN_CAPTURE_ENABLED" -> { + Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, false) } else -> { log += "\nInvalid action" false } } - log += "\nsuccess: $ok" } catch(e: Exception) { e.printStackTrace() val message = (e::class.qualifiedName ?: "Exception") + ": " + (e.message ?: "") diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 1443842..420acd3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -547,14 +547,24 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { vm::setUserRestriction, ::navigateUp) } - composable { UsersScreen(::navigateUp, ::navigate) } - composable { UserInfoScreen(::navigateUp) } - composable { UsersOptionsScreen(::navigateUp) } - composable { UserOperationScreen(::navigateUp) } - composable { CreateUserScreen(::navigateUp) } - composable { ChangeUsernameScreen(::navigateUp) } - composable { UserSessionMessageScreen(::navigateUp) } - composable { AffiliationIdScreen(::navigateUp) } + composable { UsersScreen(vm, ::navigateUp, ::navigate) } + composable { UserInfoScreen(vm::getUserInformation, ::navigateUp) } + composable { + UsersOptionsScreen(vm::getLogoutEnabled, vm::setLogoutEnabled, ::navigateUp) + } + composable { + UserOperationScreen(vm::startUser, vm::switchUser, vm::stopUser, vm::deleteUser, ::navigateUp) + } + composable { CreateUserScreen(vm::createUser, ::navigateUp) } + composable { ChangeUsernameScreen(vm::setProfileName, ::navigateUp) } + composable { + UserSessionMessageScreen(vm::getUserSessionMessages, vm::setStartUserSessionMessage, + vm::setEndUserSessionMessage, ::navigateUp) + } + composable { + AffiliationIdScreen(vm.affiliationIds, vm::getAffiliationIds, vm::setAffiliationId, + ::navigateUp) + } composable { PasswordScreen(::navigateUp, ::navigate) } composable { PasswordInfoScreen(::navigateUp) } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index c2d58c4..41c6ce6 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -20,9 +20,13 @@ import android.content.IntentFilter import android.content.pm.ApplicationInfo import android.content.pm.PackageInstaller import android.content.pm.PackageManager +import android.graphics.Bitmap import android.net.Uri +import android.os.Binder import android.os.Build.VERSION import android.os.HardwarePropertiesManager +import android.os.UserHandle +import android.os.UserManager import androidx.annotation.RequiresApi import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -36,6 +40,7 @@ import com.bintianqi.owndroid.Privilege.DPM import com.bintianqi.owndroid.dpm.ACTIVATE_DEVICE_OWNER_COMMAND import com.bintianqi.owndroid.dpm.AppStatus import com.bintianqi.owndroid.dpm.CaCertInfo +import com.bintianqi.owndroid.dpm.CreateUserResult import com.bintianqi.owndroid.dpm.CreateWorkProfileOptions import com.bintianqi.owndroid.dpm.DelegatedAdmin import com.bintianqi.owndroid.dpm.DeviceAdmin @@ -46,6 +51,7 @@ import com.bintianqi.owndroid.dpm.IntentFilterOptions import com.bintianqi.owndroid.dpm.PendingSystemUpdateInfo import com.bintianqi.owndroid.dpm.SystemOptionsStatus import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo +import com.bintianqi.owndroid.dpm.UserInformation import com.bintianqi.owndroid.dpm.activateOrgProfileCommand import com.bintianqi.owndroid.dpm.delegatedScopesList import com.bintianqi.owndroid.dpm.getPackageInstaller @@ -68,6 +74,8 @@ import java.security.MessageDigest import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.security.cert.X509Certificate +import java.time.ZoneId +import java.time.ZonedDateTime import java.util.concurrent.Executors class MyViewModel(application: Application): AndroidViewModel(application) { @@ -124,11 +132,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val hiddenPackages = MutableStateFlow(emptyList()) fun getHiddenPackages() { - viewModelScope.launch { - hiddenPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { - DPM.isApplicationHidden(DAR, it.packageName) - }.map { getAppInfo(it) } - } + hiddenPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isApplicationHidden(DAR, it.packageName) + }.map { getAppInfo(it) } } fun setPackageHidden(name: String, status: Boolean): Boolean { val result = DPM.setApplicationHidden(DAR, name, status) @@ -139,11 +145,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { // Uninstall blocked packages val ubPackages = MutableStateFlow(emptyList()) fun getUbPackages() { - viewModelScope.launch { - ubPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { - DPM.isUninstallBlocked(DAR, it.packageName) - }.map { getAppInfo(it) } - } + ubPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isUninstallBlocked(DAR, it.packageName) + }.map { getAppInfo(it) } } fun setPackageUb(name: String, status: Boolean) { DPM.setUninstallBlocked(DAR, name, status) @@ -421,19 +425,33 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } @RequiresApi(24) fun requestBugReport(): Boolean { - return DPM.requestBugreport(DAR) + return try { + DPM.requestBugreport(DAR) + } catch (e: Exception) { + e.printStackTrace() + false + } } @RequiresApi(24) fun getOrgName(): String { - return DPM.getOrganizationName(DAR).toString() + return try { + DPM.getOrganizationName(DAR)?.toString() ?: "" + } catch (_: Exception) { + "" + } } @RequiresApi(24) fun setOrgName(name: String) { DPM.setOrganizationName(DAR, name) } @RequiresApi(31) - fun setOrgId(id: String) { - DPM.setOrganizationId(id) + fun setOrgId(id: String): Boolean { + return try { + DPM.setOrganizationId(id) + true + } catch (_: IllegalStateException) { + false + } } @RequiresApi(31) fun getEnrollmentSpecificId(): String { @@ -557,12 +575,16 @@ class MyViewModel(application: Application): AndroidViewModel(application) { properties.temperatures.isEmpty()) { break } + hardwareProperties.value = properties delay(hpRefreshInterval) } } @RequiresApi(28) - fun setTime(time: Long): Boolean { - return DPM.setTime(DAR, time) + fun setTime(time: Long, useCurrentTz: Boolean): Boolean { + val offset = if (useCurrentTz) { + ZonedDateTime.now(ZoneId.systemDefault()).offset.totalSeconds * 1000L + } else 0L + return DPM.setTime(DAR, time - offset) } @RequiresApi(28) fun setTimeZone(tz: String): Boolean { @@ -674,9 +696,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } val installedCaCerts = MutableStateFlow(emptyList()) fun getCaCerts() { - viewModelScope.launch { - installedCaCerts.value = DPM.getInstalledCaCerts(DAR).mapNotNull { parseCaCert(it) } - } + installedCaCerts.value = DPM.getInstalledCaCerts(DAR).mapNotNull { parseCaCert(it) } } fun parseCaCert(uri: Uri): CaCertInfo? { return try { @@ -696,7 +716,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { CaCertInfo( hash, cert.serialNumber.toString(16), cert.issuerX500Principal.name, cert.subjectX500Principal.name, - parseDate(cert.notBefore), parseDate(cert.notAfter), bytes + cert.notBefore.time, cert.notAfter.time, bytes ) } catch (e: CertificateException) { e.printStackTrace() @@ -809,7 +829,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { return DPM.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) } fun activateDoByShizuku(callback: (Boolean, String?) -> Unit) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { useShizuku(application) { service -> try { val result = IUserService.Stub.asInterface(service) @@ -887,7 +907,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } val dhizukuClients = MutableStateFlow(emptyList>()) fun getDhizukuClients() { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { dhizukuClients.value = myRepo.getDhizukuClients().mapNotNull { val packageName = PM.getNameForUid(it.uid) if (packageName == null) { @@ -985,7 +1005,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } val deviceAdminReceivers = MutableStateFlow(emptyList()) fun getDeviceAdminReceivers() { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { deviceAdminReceivers.value = PM.queryBroadcastReceivers( Intent(DeviceAdminReceiver.ACTION_DEVICE_ADMIN_ENABLED), PackageManager.GET_META_DATA @@ -1064,7 +1084,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { return intent } fun activateOrgProfileByShizuku(callback: (Boolean) -> Unit) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { var succeed = false useShizuku(application) { service -> val result = IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand) @@ -1102,6 +1122,130 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } DPM.addCrossProfileIntentFilter(DAR, filter, flags) } + + val UM = application.getSystemService(Context.USER_SERVICE) as UserManager + @RequiresApi(28) + fun getLogoutEnabled(): Boolean { + return DPM.isLogoutEnabled + } + @RequiresApi(28) + fun setLogoutEnabled(enabled: Boolean) { + DPM.setLogoutEnabled(DAR, enabled) + } + fun getUserInformation(): UserInformation { + val uh = Binder.getCallingUserHandle() + return UserInformation( + if (VERSION.SDK_INT >= 24) UserManager.supportsMultipleUsers() else false, + if (VERSION.SDK_INT >= 31) UserManager.isHeadlessSystemUserMode() else false, + if (VERSION.SDK_INT >= 23) UM.isSystemUser else false, + if (VERSION.SDK_INT >= 34) UM.isAdminUser else false, + if (VERSION.SDK_INT >= 25) UM.isDemoUser else false, + if (VERSION.SDK_INT >= 23) UM.getUserCreationTime(uh) else 0, + if (VERSION.SDK_INT >= 28) DPM.isLogoutEnabled else false, + if (VERSION.SDK_INT >= 28) DPM.isEphemeralUser(DAR) else false, + if (VERSION.SDK_INT >= 28) DPM.isAffiliatedUser else false, + UM.getSerialNumberForUser(uh) + ) + } + @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 switchUser(id: Int, isUserId: Boolean): Boolean { + val uh = getUserHandle(id, isUserId) + if (uh == null) return false + DPM.switchUser(DAR, uh) + return true + } + @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 getUserOperationResultText(code: Int): Int { + return when (code) { + 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_MAX_RUNNING_USERS -> R.string.limit_reached + UserManager.USER_OPERATION_ERROR_CURRENT_USER -> R.string.fail_current_user + else -> R.string.unknown + } + } + @RequiresApi(24) + fun createUser(name: String, flags: Int, callback: (CreateUserResult) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + try { + val uh = DPM.createAndManageUser(DAR, name, DAR, null, flags) + if (uh == null) { + callback(CreateUserResult(R.string.failed)) + } else { + callback(CreateUserResult(R.string.succeeded, UM.getSerialNumberForUser(uh))) + } + } catch (e: Exception) { + e.printStackTrace() + if (VERSION.SDK_INT >= 28 && e is UserManager.UserOperationException) { + callback(CreateUserResult(getUserOperationResultText(e.userOperationResult))) + } else { + callback(CreateUserResult(R.string.error)) + } + } + } + } + val affiliationIds = MutableStateFlow(emptyList()) + @RequiresApi(26) + fun getAffiliationIds() { + affiliationIds.value = DPM.getAffiliationIds(DAR).toList() + } + @RequiresApi(26) + fun setAffiliationId(id: String, state: Boolean) { + val newList = affiliationIds.value.run { if (state) plus(id) else minus(id) } + DPM.setAffiliationIds(DAR, newList.toSet()) + affiliationIds.value = newList + } + fun setProfileName(name: String) { + DPM.setProfileName(DAR, name) + } + @RequiresApi(23) + fun setUserIcon(bitmap: Bitmap) { + DPM.setUserIcon(DAR, bitmap) + } + @RequiresApi(28) + fun getSecondaryUsers(): List { + return DPM.getSecondaryUsers(DAR).map { UM.getSerialNumberForUser(it) } + } + @RequiresApi(28) + fun getUserSessionMessages(): Pair { + return (DPM.getStartUserSessionMessage(DAR)?.toString() ?: "") to + (DPM.getEndUserSessionMessage(DAR)?.toString() ?: "") + } + @RequiresApi(28) + fun setStartUserSessionMessage(message: String?) { + DPM.setStartUserSessionMessage(DAR, message) + } + @RequiresApi(28) + fun setEndUserSessionMessage(message: String?) { + DPM.setEndUserSessionMessage(DAR, message) + } + @RequiresApi(28) + fun logoutUser(): Int { + return getUserOperationResultText(DPM.logoutUser(DAR)) + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index 14fdfff..f6a9947 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.widget.Toast -import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope @@ -23,9 +22,6 @@ import java.io.IOException import java.io.InputStream import java.security.MessageDigest import java.text.SimpleDateFormat -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit @@ -72,20 +68,12 @@ fun formatFileSize(bytes: Long): String { val Boolean.yesOrNo @StringRes get() = if(this) R.string.yes else R.string.no -@RequiresApi(26) -fun parseTimestamp(timestamp: Long): String { - val instant = Instant.ofEpochMilli(timestamp) - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) - return formatter.format(instant) +fun formatTime(ms: Long): String { + return SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(Date(ms)) +} +fun formatDate(date: Date): String { + return SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(date) } - -fun parseDate(date: Date): String = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(date) - -val Long.humanReadableDate: String - get() = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault()).format(Date(this)) - -fun formatDate(pattern: String, value: Long): String - = SimpleDateFormat(pattern, Locale.getDefault()).format(Date(value)) fun Context.showOperationResultToast(success: Boolean) { popToast(if(success) R.string.success else R.string.failed) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 65da686..aef2ab6 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -53,6 +53,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -130,9 +131,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.humanReadableDate +import com.bintianqi.owndroid.formatTime import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -157,9 +157,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import java.net.InetAddress -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.reflect.jvm.jvmErasure @Serializable object Network @@ -1036,14 +1033,14 @@ fun NetworkStatsScreen( } } OutlinedTextField( - value = startTime.let { if(it == -1L) "" else it.humanReadableDate }, onValueChange = {}, readOnly = true, + value = startTime.let { if(it == -1L) "" else formatTime(it) }, onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.start_time)) }, interactionSource = startTimeTextFieldInteractionSource, isError = startTime >= endTime, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) ) OutlinedTextField( - value = endTime.humanReadableDate, onValueChange = {}, readOnly = true, + value = formatTime(endTime), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.end_time)) }, interactionSource = endTimeTextFieldInteractionSource, isError = startTime >= endTime, @@ -1315,18 +1312,9 @@ fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page -> val data = nsv.stats[page] Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { - Row(Modifier.align(Alignment.CenterHorizontally).padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { - SimpleDateFormat("", Locale.getDefault()).format(Date(data.startTime)) - Text( - formatDate("yyyy/MM/dd", data.startTime) + "\n" + formatDate("HH:mm:ss", data.startTime), - textAlign = TextAlign.Center - ) - Text("~", Modifier.padding(horizontal = 8.dp)) - Text( - formatDate("yyyy/MM/dd", data.endTime) + "\n" + formatDate("HH:mm:ss", data.endTime), - textAlign = TextAlign.Center - ) - } + Text(formatTime(data.startTime) + "\n~\n" + formatTime(data.endTime), + Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) + Spacer(Modifier.height(5.dp)) val txBytes = data.txBytes Text(stringResource(R.string.transmitted), style = typography.titleLarge) Column(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 1016c37..7f0aa33 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -112,8 +112,7 @@ import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.humanReadableDate -import com.bintianqi.owndroid.parseTimestamp +import com.bintianqi.owndroid.formatTime import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -290,7 +289,7 @@ fun SystemManagerScreen( onClick = { if (dialog == 3 && VERSION.SDK_INT >= 24) vm.setOrgName(input) if (dialog == 4 && VERSION.SDK_INT >= 31) { - vm.setOrgId(input) + context.showOperationResultToast(vm.setOrgId(input)) enrollmentSpecificId = vm.getEnrollmentSpecificId() } dialog = 0 @@ -368,6 +367,24 @@ fun SystemOptionsScreen(vm: MyViewModel, onNavigateUp: () -> Unit) { SwitchItem(R.string.enable_usb_signal, status.usbSignalEnabled, vm::setUsbSignalEnabled, R.drawable.usb_fill0) } + if (VERSION.SDK_INT >= 23 && VERSION.SDK_INT < 34) { + Row( + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.status_bar), style = typography.titleMedium) + Button({ + vm.setStatusBarDisabled(true) + }, Modifier.padding(horizontal = 4.dp)) { + Text(stringResource(R.string.disable)) + } + Button({ + vm.setStatusBarDisabled(false) + }) { + Text(stringResource(R.string.enable)) + } + } + } } if(dialog != 0) AlertDialog( text = { @@ -520,7 +537,7 @@ fun HardwareMonitorScreen( @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(28) @Composable -fun ChangeTimeScreen(setTime: (Long) -> Boolean, onNavigateUp: () -> Unit) { +fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current var tab by remember { mutableIntStateOf(0) } @@ -528,8 +545,9 @@ fun ChangeTimeScreen(setTime: (Long) -> Boolean, onNavigateUp: () -> Unit) { tab = pagerState.currentPage val coroutine = rememberCoroutineScope() var picker by remember { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker + var useCurrentTz by remember { mutableStateOf(true) } val datePickerState = rememberDatePickerState() - val timePickerState = rememberTimePickerState() + val timePickerState = rememberTimePickerState(is24Hour = true) val dateInteractionSource = remember { MutableInteractionSource() } val timeInteractionSource = remember { MutableInteractionSource() } if(dateInteractionSource.collectIsPressedAsState().value) picker = 1 @@ -571,14 +589,15 @@ fun ChangeTimeScreen(setTime: (Long) -> Boolean, onNavigateUp: () -> Unit) { ) { if(page == 0) { OutlinedTextField( - value = datePickerState.selectedDateMillis?.humanReadableDate ?: "", + value = datePickerState.selectedDateMillis?.let { formatTime(it) } ?: "", onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.date)) }, interactionSource = dateInteractionSource, modifier = Modifier.fillMaxWidth() ) OutlinedTextField( - value = timePickerState.hour.toString() + ":" + timePickerState.minute.toString(), + value = timePickerState.hour.toString().padStart(2, '0') + ":" + + timePickerState.minute.toString().padStart(2, '0'), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.time)) }, interactionSource = timeInteractionSource, @@ -586,11 +605,14 @@ fun ChangeTimeScreen(setTime: (Long) -> Boolean, onNavigateUp: () -> Unit) { .fillMaxWidth() .padding(vertical = 4.dp) ) + CheckBoxItem(R.string.use_current_timezone, useCurrentTz) { + useCurrentTz = it + } Button( onClick = { val timeMillis = datePickerState.selectedDateMillis!! + timePickerState.hour * 3600000 + timePickerState.minute * 60000 - context.showOperationResultToast(setTime(timeMillis)) + context.showOperationResultToast(setTime(timeMillis, useCurrentTz)) }, modifier = Modifier.fillMaxWidth(), enabled = datePickerState.selectedDateMillis != null @@ -609,7 +631,7 @@ fun ChangeTimeScreen(setTime: (Long) -> Boolean, onNavigateUp: () -> Unit) { ) Button( onClick = { - context.showOperationResultToast(setTime(inputTime.toLong())) + context.showOperationResultToast(setTime(inputTime.toLong(), false)) }, modifier = Modifier .fillMaxWidth() @@ -653,11 +675,14 @@ fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> U val focusMgr = LocalFocusManager.current var inputTimezone by remember { mutableStateOf("") } var dialog by remember { mutableStateOf(false) } - MyScaffold(R.string.change_timezone, onNavigateUp) { + val availableIds = TimeZone.getAvailableIDs() + val validInput = inputTimezone in availableIds + MyScaffold(R.string.change_timezone, onNavigateUp) { OutlinedTextField( value = inputTimezone, label = { Text(stringResource(R.string.timezone_id)) }, onValueChange = { inputTimezone = it }, + isError = inputTimezone.isNotEmpty() && !validInput, trailingIcon = { IconButton(onClick = { dialog = true }) { Icon(imageVector = Icons.AutoMirrored.Default.List, contentDescription = null) @@ -673,7 +698,7 @@ fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> U context.showOperationResultToast(setTimeZone(inputTimezone)) }, modifier = Modifier.fillMaxWidth(), - enabled = inputTimezone.isNotEmpty() + enabled = inputTimezone.isNotEmpty() && validInput ) { Text(stringResource(R.string.apply)) } @@ -683,7 +708,7 @@ fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> U if(dialog) AlertDialog( text = { LazyColumn { - items(TimeZone.getAvailableIDs()) { + items(availableIds) { Text( text = it, modifier = Modifier @@ -1322,8 +1347,8 @@ data class CaCertInfo( val serialNumber: String, val issuer: String, val subject: String, - val issuedTime: String, - val expiresTime: String, + val issuedTime: Long, + val expiresTime: Long, val bytes: ByteArray ) @@ -1373,8 +1398,7 @@ fun CaCertScreen( }) { Icon(Icons.Default.Add, stringResource(R.string.install)) } - }, - contentWindowInsets = WindowInsets.ime + } ) { paddingValues -> LazyColumn( Modifier @@ -1388,6 +1412,7 @@ fun CaCertScreen( .fillMaxWidth() .clickable { selectedCaCert = cert + dialog = 2 } .animateItem() .padding(vertical = 10.dp, horizontal = 8.dp) @@ -1412,9 +1437,9 @@ fun CaCertScreen( Text("Issuer", style = typography.labelLarge) SelectionContainer { Text(cert.issuer) } Text("Issued on", style = typography.labelLarge) - SelectionContainer { Text(cert.issuedTime) } + SelectionContainer { Text(formatTime(cert.issuedTime)) } Text("Expires on", style = typography.labelLarge) - SelectionContainer { Text(cert.expiresTime) } + SelectionContainer { Text(formatTime(cert.expiresTime)) } Text("SHA-256 fingerprint", style = typography.labelLarge) SelectionContainer { Text(cert.hash) } if (dialog == 2) Row( @@ -1693,17 +1718,17 @@ fun FrpPolicyScreen( keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), modifier = Modifier.fillMaxWidth() ) - } - Button( - onClick = { - focusMgr.clearFocus() - setFrpPolicy(FrpPolicyInfo(true, usePolicy, enabled, accountList)) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - Text(stringResource(R.string.apply)) + Button( + onClick = { + focusMgr.clearFocus() + setFrpPolicy(FrpPolicyInfo(true, usePolicy, enabled, accountList)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.apply)) + } } } Notes(R.string.info_frp_policy, HorizontalPadding) @@ -1755,7 +1780,7 @@ fun WipeDataScreen( dialog = 1 }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 5.dp) ) { Text("WipeData") } @@ -1767,7 +1792,7 @@ fun WipeDataScreen( dialog = 2 }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 5.dp) ) { Text("WipeDevice") } @@ -1905,8 +1930,7 @@ fun SystemUpdatePolicyScreen( if (VERSION.SDK_INT >= 26) { Column(Modifier.padding(HorizontalPadding)) { if (pendingUpdate.exists) { - Text(stringResource(R.string.update_received_time, - parseTimestamp(pendingUpdate.time))) + Text(stringResource(R.string.update_received_time, formatTime(pendingUpdate.time))) Text(stringResource(R.string.is_security_patch, stringResource(pendingUpdate.securityPatch.yesOrNo))) } else { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt index 88689fa..958c48a 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt @@ -1,18 +1,13 @@ package com.bintianqi.owndroid.dpm import android.app.admin.DevicePolicyManager -import android.content.Context 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 androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -20,6 +15,7 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -44,11 +40,8 @@ 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.Modifier import androidx.compose.ui.draw.clip @@ -62,9 +55,10 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.parseTimestamp +import com.bintianqi.owndroid.formatTime import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog @@ -77,17 +71,16 @@ import com.bintianqi.owndroid.ui.Notes 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 +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable @Serializable object Users @Composable -fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() + /** 1: secondary users, 2: logout*/ var dialog by remember { mutableIntStateOf(0) } MyScaffold(R.string.users, onNavigateUp, 0.dp) { if(VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated) { @@ -118,7 +111,11 @@ fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { context.popToast(R.string.select_an_image) launcher.launch("image/*") } - if(changeUserIconDialog) ChangeUserIconDialog(bitmap!!) { changeUserIconDialog = false } + if (changeUserIconDialog) ChangeUserIconDialog( + bitmap!!, { + vm.setUserIcon(bitmap!!) + changeUserIconDialog = false + }) { changeUserIconDialog = false } } if(VERSION.SDK_INT >= 28 && privilege.device) { FunctionItem(R.string.user_session_msg, icon = R.drawable.notifications_fill0) { onNavigate(UserSessionMessage) } @@ -127,36 +124,39 @@ fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { FunctionItem(R.string.affiliation_id, icon = R.drawable.id_card_fill0) { onNavigate(AffiliationId) } } } - if(dialog != 0 && VERSION.SDK_INT >= 28) AlertDialog( - title = { Text(stringResource(if(dialog == 1) R.string.secondary_users else R.string.logout)) }, + if (VERSION.SDK_INT >= 28 && dialog == 1) AlertDialog( + title = { Text(stringResource(R.string.secondary_users)) }, text = { - if(dialog == 1) { - val um = context.getSystemService(Context.USER_SERVICE) as UserManager - val list = Privilege.DPM.getSecondaryUsers(Privilege.DAR) - 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() }) - } + val list = vm.getSecondaryUsers() + val text = if (list.isEmpty()) { + stringResource(R.string.no_secondary_users) } else { - Text(stringResource(R.string.info_logout)) + "(" + stringResource(R.string.serial_number) + ")\n" + list.joinToString("\n") } + Text(text) }, confirmButton = { - TextButton( - onClick = { - if(dialog == 2) { - val result = Privilege.DPM.logoutUser(Privilege.DAR) - context.popToast(userOperationResultCode(result)) - } - dialog = 0 - } - ) { + 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)) + }, + confirmButton = { + TextButton({ + context.popToast(vm.logoutUser()) + dialog = 0 + }) { Text(stringResource(R.string.confirm)) } }, dismissButton = { - if(dialog != 1) TextButton(onClick = { dialog = 0 }) { + TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } }, @@ -167,41 +167,53 @@ fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { @Serializable object UsersOptions @Composable -fun UsersOptionsScreen(onNavigateUp: () -> Unit) { +fun UsersOptionsScreen( + getLogoutEnabled: () -> Boolean, setLogoutEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { + var logoutEnabled by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { logoutEnabled = getLogoutEnabled() } MyScaffold(R.string.options, onNavigateUp, 0.dp) { if(VERSION.SDK_INT >= 28) { - SwitchItem(R.string.enable_logout, getState = { Privilege.DPM.isLogoutEnabled }, - onCheckedChange = { Privilege.DPM.setLogoutEnabled(Privilege.DAR, it) }) + SwitchItem(R.string.enable_logout, logoutEnabled, { + setLogoutEnabled(it) + logoutEnabled = it + }) } } } +data class UserInformation( + val multiUser: Boolean = false, val headless: Boolean = false, val system: Boolean = false, + val admin: Boolean = false, val demo: Boolean = false, val time: Long = 0, + val logout: Boolean = false, val ephemeral: Boolean = false, val affiliated: Boolean = false, + val serial: Long = 0 +) + @Serializable object UserInfo @Composable -fun UserInfoScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val privilege by Privilege.status.collectAsStateWithLifecycle() - val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - val user = Process.myUserHandle() +fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { + var info by remember { mutableStateOf(UserInformation()) } var infoDialog by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + info = getInfo() + } MyScaffold(R.string.user_info, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 24) InfoItem(R.string.support_multiuser, UserManager.supportsMultipleUsers().yesOrNo) - if(VERSION.SDK_INT >= 31) InfoItem(R.string.headless_system_user_mode, UserManager.isHeadlessSystemUserMode().yesOrNo, true) { infoDialog = 1 } - Spacer(Modifier.padding(vertical = 8.dp)) - if(VERSION.SDK_INT >= 23) InfoItem(R.string.system_user, userManager.isSystemUser.yesOrNo) - if(VERSION.SDK_INT >= 34) InfoItem(R.string.admin_user, userManager.isAdminUser.yesOrNo) - if(VERSION.SDK_INT >= 25) InfoItem(R.string.demo_user, userManager.isDemoUser.yesOrNo) - if(VERSION.SDK_INT >= 26) userManager.getUserCreationTime(user).let { - if(it != 0L) InfoItem(R.string.creation_time, parseTimestamp(it)) - } + if (VERSION.SDK_INT >= 24) InfoItem(R.string.support_multiuser, info.multiUser.yesOrNo) + if (VERSION.SDK_INT >= 31) InfoItem(R.string.headless_system_user_mode, info.headless.yesOrNo, true) { infoDialog = 1 } + Spacer(Modifier.height(8.dp)) + if (VERSION.SDK_INT >= 23) InfoItem(R.string.system_user, info.system.yesOrNo) + if (VERSION.SDK_INT >= 34) InfoItem(R.string.admin_user, info.admin.yesOrNo) + if (VERSION.SDK_INT >= 25) InfoItem(R.string.demo_user, info.demo.yesOrNo) + if (info.time != 0L) InfoItem(R.string.creation_time, formatTime(info.time)) + if (VERSION.SDK_INT >= 28) { - InfoItem(R.string.logout_enabled, Privilege.DPM.isLogoutEnabled.yesOrNo) - InfoItem(R.string.ephemeral_user, Privilege.DPM.isEphemeralUser(Privilege.DAR).yesOrNo) - InfoItem(R.string.affiliated_user, privilege.affiliated.yesOrNo) + InfoItem(R.string.logout_enabled, info.logout.yesOrNo) + InfoItem(R.string.ephemeral_user, info.ephemeral.yesOrNo) + InfoItem(R.string.affiliated_user, info.affiliated.yesOrNo) } InfoItem(R.string.user_id, (Binder.getCallingUid() / 100000).toString()) - InfoItem(R.string.user_serial_number, userManager.getSerialNumberForUser(Process.myUserHandle()).toString()) + InfoItem(R.string.user_serial_number, info.serial.toString()) } if(infoDialog != 0) AlertDialog( text = { Text(stringResource(R.string.info_headless_system_user_mode)) }, @@ -217,24 +229,15 @@ fun UserInfoScreen(onNavigateUp: () -> Unit) { @Serializable object UserOperation @Composable -fun UserOperationScreen(onNavigateUp: () -> Unit) { +fun UserOperationScreen( + startUser: (Int, Boolean) -> Int, switchUser: (Int, Boolean) -> Boolean, + stopUser: (Int, Boolean) -> Int, deleteUser: (Int, Boolean) -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager 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) { - context.popToast(R.string.user_not_exist) - } else { - operation(userHandle) - } - } + var dialog by remember { mutableStateOf(false) } val legalInput = input.toIntOrNull() != null MyScaffold(R.string.user_operation, onNavigateUp) { if(VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { @@ -257,10 +260,7 @@ fun UserOperationScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - withUserHandle { - val result = Privilege.DPM.startUserInBackground(Privilege.DAR, it) - context.popToast(userOperationResultCode(result)) - } + context.popToast(startUser(input.toInt(), useUserId)) }, enabled = legalInput, modifier = Modifier.fillMaxWidth() @@ -272,7 +272,7 @@ fun UserOperationScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - withUserHandle { context.showOperationResultToast(Privilege.DPM.switchUser(Privilege.DAR, it)) } + if (switchUser(input.toInt(), useUserId)) context.popToast(R.string.user_not_exist) }, enabled = legalInput, modifier = Modifier.fillMaxWidth() @@ -284,10 +284,7 @@ fun UserOperationScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - withUserHandle { - val result = Privilege.DPM.stopUser(Privilege.DAR, it) - context.popToast(userOperationResultCode(result)) - } + context.popToast(stopUser(input.toInt(), useUserId)) }, enabled = legalInput, modifier = Modifier.fillMaxWidth() @@ -299,14 +296,7 @@ fun UserOperationScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - withUserHandle { - if(Privilege.DPM.removeUser(Privilege.DAR, it)) { - context.showOperationResultToast(true) - input = "" - } else { - context.showOperationResultToast(false) - } - } + dialog = true }, enabled = legalInput, modifier = Modifier.fillMaxWidth() @@ -315,21 +305,39 @@ fun UserOperationScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.delete)) } } + if (dialog) AlertDialog( + text = { + Text(stringResource(R.string.delete_user_confirmation, input)) + }, + confirmButton = { + TextButton({ + context.showOperationResultToast(deleteUser(input.toInt(), useUserId)) + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { Text(stringResource(R.string.cancel)) } + }, + onDismissRequest = { dialog = false } + ) } +data class CreateUserResult(val message: Int, val serial: Long = -1) + @Serializable object CreateUser @RequiresApi(24) @Composable -fun CreateUserScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager +fun CreateUserScreen( + createUser: (String, Int, (CreateUserResult) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { + var result by remember { mutableStateOf(null) } 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() + var flags by remember { mutableIntStateOf(0) } MyScaffold(R.string.create_user, onNavigateUp, 0.dp) { OutlinedTextField( userName, { userName= it }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), @@ -340,55 +348,47 @@ fun CreateUserScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 5.dp)) FullWidthCheckBoxItem( R.string.create_user_skip_wizard, - flag and DevicePolicyManager.SKIP_SETUP_WIZARD != 0 - ) { flag = flag xor DevicePolicyManager.SKIP_SETUP_WIZARD } + flags and DevicePolicyManager.SKIP_SETUP_WIZARD != 0 + ) { flags = flags xor DevicePolicyManager.SKIP_SETUP_WIZARD } if(VERSION.SDK_INT >= 28) { FullWidthCheckBoxItem( R.string.create_user_ephemeral_user, - flag and DevicePolicyManager.MAKE_USER_EPHEMERAL != 0 - ) { flag = flag xor DevicePolicyManager.MAKE_USER_EPHEMERAL } + flags and DevicePolicyManager.MAKE_USER_EPHEMERAL != 0 + ) { flags = flags xor DevicePolicyManager.MAKE_USER_EPHEMERAL } FullWidthCheckBoxItem( 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 } + flags and DevicePolicyManager.LEAVE_ALL_SYSTEM_APPS_ENABLED != 0 + ) { flags = flags 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 = Privilege.DPM.createAndManageUser(Privilege.DAR, userName, Privilege.DAR, null, flag) - withContext(Dispatchers.Main) { - createdUserSerialNumber = userManager.getSerialNumberForUser(uh) - } - } catch(e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - if (VERSION.SDK_INT >= 28 && e is UserManager.UserOperationException) { - context.popToast(e.message ?: context.getString(R.string.error)) - } else { - context.showOperationResultToast(false) - } - } - } - withContext(Dispatchers.Main) { creating = false } + createUser(userName, flags) { + creating = false + result = it } }, modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) ) { 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)) } + if (result != null) AlertDialog( + text = { + Column { + Text(stringResource(result!!.message)) + if (result?.serial != -1L) { + Text(stringResource(R.string.serial_number) + ": " + result!!.serial) + } + } }, - onDismissRequest = { createdUserSerialNumber = -1 } + confirmButton = { + TextButton({ result = null }) { Text(stringResource(R.string.confirm)) } + }, + onDismissRequest = { result = null } ) - if(creating) CircularProgressDialog { } + if (creating) CircularProgressDialog { } } } @@ -396,24 +396,21 @@ fun CreateUserScreen(onNavigateUp: () -> Unit) { @RequiresApi(26) @Composable -fun AffiliationIdScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun AffiliationIdScreen( + affiliationIds: StateFlow>, getIds: () -> Unit, setId: (String, Boolean) -> Unit, + onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current var input by remember { mutableStateOf("") } - val list = remember { mutableStateListOf() } - val refreshIds = { - list.clear() - list.addAll(Privilege.DPM.getAffiliationIds(Privilege.DAR)) - } - LaunchedEffect(Unit) { refreshIds() } + val list by affiliationIds.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getIds() } MyScaffold(R.string.affiliation_id, onNavigateUp) { Column(modifier = Modifier.animateContentSize()) { - if(list.isEmpty()) Text(stringResource(R.string.none)) - for(i in list) { - ListItem(i) { list -= i } + if (list.isEmpty()) Text(stringResource(R.string.none)) + for (i in list) { + ListItem(i) { setId(i, false) } } } - Spacer(Modifier.padding(vertical = 5.dp)) OutlinedTextField( value = input, onValueChange = { input = it }, @@ -421,7 +418,7 @@ fun AffiliationIdScreen(onNavigateUp: () -> Unit) { trailingIcon = { IconButton( onClick = { - list += input + setId(input, true) input = "" }, enabled = input.isNotEmpty() @@ -429,22 +426,10 @@ fun AffiliationIdScreen(onNavigateUp: () -> Unit) { Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add)) } }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }) + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - list.removeAll(setOf("")) - Privilege.DPM.setAffiliationIds(Privilege.DAR, list.toSet()) - context.showOperationResultToast(true) - refreshIds() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.apply)) - } Notes(R.string.info_affiliation_id) } } @@ -452,7 +437,7 @@ fun AffiliationIdScreen(onNavigateUp: () -> Unit) { @Serializable object ChangeUsername @Composable -fun ChangeUsernameScreen(onNavigateUp: () -> Unit) { +fun ChangeUsernameScreen(setName: (String) -> Unit, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current var inputUsername by remember { mutableStateOf("") } @@ -468,19 +453,13 @@ fun ChangeUsernameScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - Privilege.DPM.setProfileName(Privilege.DAR, inputUsername) + setName(inputUsername) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.apply)) } - Button( - onClick = { Privilege.DPM.setProfileName(Privilege.DAR, null) }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.reset)) - } } } @@ -488,16 +467,19 @@ fun ChangeUsernameScreen(onNavigateUp: () -> Unit) { @RequiresApi(28) @Composable -fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { +fun UserSessionMessageScreen( + getMessages: () -> Pair, setStartMessage: (String?) -> Unit, + setEndMessage: (String?) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current var start by remember { mutableStateOf("") } var end by remember { mutableStateOf("") } - val refreshMsg = { - start = Privilege.DPM.getStartUserSessionMessage(Privilege.DAR)?.toString() ?: "" - end = Privilege.DPM.getEndUserSessionMessage(Privilege.DAR)?.toString() ?: "" + LaunchedEffect(Unit) { + val messages = getMessages() + start = messages.first + end = messages.second } - LaunchedEffect(Unit) { refreshMsg() } MyScaffold(R.string.user_session_msg, onNavigateUp) { OutlinedTextField( value = start, @@ -510,8 +492,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setStartUserSessionMessage(Privilege.DAR, start) - refreshMsg() + setStartMessage(start) }, modifier = Modifier.fillMaxWidth(0.49F) ) { @@ -519,8 +500,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setStartUserSessionMessage(Privilege.DAR, null) - refreshMsg() + setStartMessage(null) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -540,8 +520,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setEndUserSessionMessage(Privilege.DAR, end) - refreshMsg() + setStartMessage(end) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.49F) @@ -550,8 +529,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setEndUserSessionMessage(Privilege.DAR, null) - refreshMsg() + setEndMessage(null) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -564,8 +542,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { @RequiresApi(23) @Composable -private fun ChangeUserIconDialog(bitmap: Bitmap, onClose: () -> Unit) { - val context = LocalContext.current +private fun ChangeUserIconDialog(bitmap: Bitmap, onSet: () -> Unit, onClose: () -> Unit) { AlertDialog( title = { Text(stringResource(R.string.change_user_icon)) }, text = { @@ -577,11 +554,7 @@ private fun ChangeUserIconDialog(bitmap: Bitmap, onClose: () -> Unit) { } }, confirmButton = { - TextButton({ - Privilege.DPM.setUserIcon(Privilege.DAR, bitmap) - context.showOperationResultToast(true) - onClose() - }) { + TextButton(onSet) { Text(stringResource(R.string.confirm)) } }, @@ -593,13 +566,3 @@ private fun ChangeUserIconDialog(bitmap: Bitmap, onClose: () -> Unit) { onDismissRequest = onClose ) } - -@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 - } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 86d42a7..68a7695 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -482,7 +482,6 @@ Пропустить мастер настройки Временный пользователь Включить все системные приложения - Серийный номер этого пользователя: %1$d Аффилированный идентификатор Изменить значок пользователя Select an image @@ -625,7 +624,7 @@ Указывает, поддерживает ли устройство проверку идентификаторов устройств в дополнение к проверке ключей. Да, если реализация StrongBox Keymaster на устройстве была обеспечена индивидуальным сертификатом аттестации и может использовать его для подписи записей аттестации (индивидуальный сертификат аттестации могут использовать только Keymaster с уровнем безопасности StrongBox). - Устанавливает идентификатор предприятия (Enterprise ID). Это необходимо для создания идентификатора устройства, специфичного для регистрации. + Устанавливает идентификатор предприятия (Enterprise ID). Это необходимо для создания идентификатора устройства, специфичного для регистрации. Идентификатор останется неизменным, даже если рабочий профиль будет удален и создан заново (для той же организации), или если устройство будет сброшено до заводских настроек и перерегистрировано Отобразить краткое сообщение на экране блокировки. Переопределяет любую информацию о владельце, установленную пользователем вручную, и предотвращает ее дальнейшее изменение. Это будет отображено пользователю на экранах настроек, функциональность которых была отключена администратором. Если длина сообщения превышает 200 символов, оно может быть обрезано. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 31f9576..2f06a5a 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -507,7 +507,6 @@ Sihirbazı Atla Geçici Kullanıcı Tüm Sistem Uygulamalarını Etkinleştir - Bu kullanıcının seri numarası: %1$d Bağlılık Kimliği Kullanıcı Simgesini Değiştir Bir görüntü seç @@ -664,7 +663,7 @@ Cihazın, anahtar doğrulamasına ek olarak cihaz kimlik doğrulamalarını destekleyip desteklemediğini belirtir. Evet, eğer cihazdaki StrongBox Keymaster uygulaması bireysel bir doğrulama sertifikasıyla sağlanmışsa ve bunu kullanarak doğrulama kayıtlarını imzalayabiliyorsa (yalnızca StrongBox güvenlik seviyesine sahip Keymaster bireysel doğrulama sertifikası kullanabilir). - Kurumsal Kimliği ayarlar. Bu, cihaz için kayıt özel bir kimlik oluşturmak için bir gerekliliktir. + Kurumsal Kimliği ayarlar. Bu, cihaz için kayıt özel bir kimlik oluşturmak için bir gerekliliktir. Kimlik, iş profili kaldırılsa ve aynı Kurum Kimliği ile yeniden oluşturulsa veya cihaz fabrika ayarlarına sıfırlanıp yeniden kaydedilse bile tutarlı kalır. Kilit ekranında kısa bir mesaj gösterir.\nKullanıcı tarafından manuel olarak ayarlanan herhangi bir sahip bilgisini geçersiz kılar ve kullanıcının bunu daha fazla değiştirmesini engeller. Bu, yönetici tarafından devre dışı bırakılan işlevlerin bulunduğu ayar ekranlarında kullanıcıya gösterilecektir. Mesaj 200 karakterden uzunsa kesilebilir. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e7a5914..35adb2d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -124,6 +124,7 @@ 启用相机 禁止屏幕捕获 禁用状态栏 + 状态栏 自动设置时间 自动设置时区 要求自动时间 @@ -157,6 +158,7 @@ 手动输入 日期 时间 + 使用当前时区 更改时区 时区ID 在设置时区前需要关闭自动时区 @@ -483,12 +485,13 @@ 登出 在后台启动 切换 + 已达到上限 + 你确定要删除用户%1$s吗? 创建用户 用户名 跳过创建用户向导 临时用户 启用所有系统应用 - 新用户的序列号:%1$d 附属用户ID 更换用户头像 选择一个图片 @@ -644,7 +647,7 @@ 指示设备是否除了密钥证明之外还支持设备标识符证明 如果设备上的StrongBox Keymaster可以配置单独的证明证书并且可以使用该证书签署证明记录,则返回true(只有StrongBox安全级别的Keymaster才能使用单独的证明证书进行证明) - 设置组织ID后才能获取设备注册专用ID + 设置组织ID后才能获取设备注册专用ID。ID只能设置一次。 不同组织ID的设备注册专用ID不同,恢复出厂设置或删除工作资料后不变 在锁屏界面上显示的一段简短的消息。将会覆盖用户当前设置的锁屏信息,并且防止用户在系统设置中设置新的锁屏信息 用户试图使用被管理员禁用的功能时会显示此消息。不应多于200字 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 704a87d..1d1236b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -133,6 +133,7 @@ Enable camera Disable screen capture Disable status bar + Status bar Auto time Require auto time Auto timezone @@ -166,6 +167,7 @@ Manually input Date Time + Use current timezone Change timezone Timezone ID Auto timezone should be disabled before set a custom timezone. @@ -516,12 +518,13 @@ Logout Start in background Switch + Limit reached + Are you sure you want to delete user %1$s ? Create user Username Skip wizard Ephemeral user Enable all system app - Serial number of this user: %1$d Affiliation ID Change user icon Select an image @@ -678,7 +681,7 @@ Indicates if the device supports attestation of device identifiers in addition to key attestation. Yes if the StrongBox Keymaster implementation on the device was provisioned with an individual attestation certificate and can sign attestation records using it (only Keymaster with StrongBox security level can use an individual attestation certificate). - Sets the Enterprise ID. This is a requirement for generating an enrollment-specific ID for the device. + Sets the Enterprise ID. This is a requirement for generating an enrollment-specific ID for the device. The ID can only be set once. The identifier would be consistent even if the work profile is removed and create again (to the same Organization ID), or the device is factory reset and re-enrolled. Show a brief message on your lock screen.\nOverrides any owner information manually set by the user and prevents the user from further changing it. This will be displayed to the user in settings screens where functionality has been disabled by the admin. If the message is longer than 200 characters it may be truncated From 59556ae23d0553a4edc9faa78f992e66c419b529 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Mon, 6 Oct 2025 15:10:07 +0800 Subject: [PATCH 10/26] ViewModel refactoring: Network part (#178) --- .../app/admin/IDevicePolicyManager.java | 2 + .../com/bintianqi/owndroid/MainActivity.kt | 73 +- .../com/bintianqi/owndroid/MyViewModel.kt | 399 +++ .../java/com/bintianqi/owndroid/Receiver.kt | 3 + .../main/java/com/bintianqi/owndroid/Utils.kt | 20 - .../java/com/bintianqi/owndroid/dpm/DPM.kt | 16 +- .../com/bintianqi/owndroid/dpm/Network.kt | 2375 ++++++++--------- app/src/main/res/values-ru/strings.xml | 20 +- app/src/main/res/values-tr/strings.xml | 18 - app/src/main/res/values-zh-rCN/strings.xml | 21 +- app/src/main/res/values/strings.xml | 24 +- 11 files changed, 1652 insertions(+), 1319 deletions(-) diff --git a/app/src/main/java/android/app/admin/IDevicePolicyManager.java b/app/src/main/java/android/app/admin/IDevicePolicyManager.java index dd9b3b9..7da5fbb 100644 --- a/app/src/main/java/android/app/admin/IDevicePolicyManager.java +++ b/app/src/main/java/android/app/admin/IDevicePolicyManager.java @@ -1,5 +1,6 @@ package android.app.admin; +import android.content.ComponentName; import android.os.Binder; import android.os.IBinder; import android.os.IInterface; @@ -14,4 +15,5 @@ public interface IDevicePolicyManager extends IInterface { throw new UnsupportedOperationException(); } } + int setGlobalPrivateDns(ComponentName who, int mode, String privateDnsHost); } diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 420acd3..ebebdee 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -62,8 +62,6 @@ import com.bintianqi.owndroid.dpm.AddApnSetting import com.bintianqi.owndroid.dpm.AddApnSettingScreen import com.bintianqi.owndroid.dpm.AddDelegatedAdmin import com.bintianqi.owndroid.dpm.AddDelegatedAdminScreen -import com.bintianqi.owndroid.dpm.AddNetwork -import com.bintianqi.owndroid.dpm.AddNetworkScreen import com.bintianqi.owndroid.dpm.AddPreferentialNetworkServiceConfig import com.bintianqi.owndroid.dpm.AddPreferentialNetworkServiceConfigScreen import com.bintianqi.owndroid.dpm.AffiliationId @@ -164,6 +162,7 @@ import com.bintianqi.owndroid.dpm.PermittedAccessibilityServices import com.bintianqi.owndroid.dpm.PermittedAsAndImPackages import com.bintianqi.owndroid.dpm.PermittedInputMethods import com.bintianqi.owndroid.dpm.PreferentialNetworkService +import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceInfo import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceScreen import com.bintianqi.owndroid.dpm.PrivateDns import com.bintianqi.owndroid.dpm.PrivateDnsScreen @@ -198,6 +197,8 @@ import com.bintianqi.owndroid.dpm.TransferOwnership import com.bintianqi.owndroid.dpm.TransferOwnershipScreen import com.bintianqi.owndroid.dpm.UninstallApp import com.bintianqi.owndroid.dpm.UninstallAppScreen +import com.bintianqi.owndroid.dpm.UpdateNetwork +import com.bintianqi.owndroid.dpm.UpdateNetworkScreen import com.bintianqi.owndroid.dpm.UserInfo import com.bintianqi.owndroid.dpm.UserInfoScreen import com.bintianqi.owndroid.dpm.UserOperation @@ -393,28 +394,62 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { WipeDataScreen(vm::wipeData, ::navigateUp) } composable { NetworkScreen(::navigateUp, ::navigate) } - composable { WifiScreen(::navigateUp, ::navigate) { navController.navigate(AddNetwork, it)} } - composable { NetworkOptionsScreen(::navigateUp) } - composable { AddNetworkScreen(it.arguments!!, ::navigateUp) } - composable { WifiSecurityLevelScreen(::navigateUp) } - composable { WifiSsidPolicyScreen(::navigateUp) } + composable { + WifiScreen(vm, ::navigateUp, ::navigate) { navController.navigate(UpdateNetwork(it)) } + } + composable { + NetworkOptionsScreen(vm::getLanEnabled, vm::setLanEnabled, ::navigateUp) + } + composable { + val info = vm.configuredNetworks.collectAsStateWithLifecycle().value[ + (it.toRoute() as UpdateNetwork).index + ] + UpdateNetworkScreen(info, vm::setWifi, ::navigateUp) + } + composable { + WifiSecurityLevelScreen(vm::getMinimumWifiSecurityLevel, + vm::setMinimumWifiSecurityLevel, ::navigateUp) + } + composable { + WifiSsidPolicyScreen(vm::getSsidPolicy, vm::setSsidPolicy, ::navigateUp) + } composable { - NetworkStatsScreen(vm.chosenPackage, ::choosePackage, ::navigateUp, ::navigate) + NetworkStatsScreen(vm.chosenPackage, ::choosePackage, vm::getPackageUid, + vm::queryNetworkStats, ::navigateUp) { navController.navigate(NetworkStatsViewer) } } - composable(mapOf(serializableNavTypePair>())) { - NetworkStatsViewerScreen(it.toRoute(), ::navigateUp) + composable { + NetworkStatsViewerScreen(vm.networkStatsData, vm::clearNetworkStats, ::navigateUp) + } + composable { + PrivateDnsScreen(vm::getPrivateDns, vm::setPrivateDns, ::navigateUp) } - composable { PrivateDnsScreen(::navigateUp) } composable { - AlwaysOnVpnPackageScreen(vm.chosenPackage, ::choosePackage, ::navigateUp) + AlwaysOnVpnPackageScreen(vm::getAlwaysOnVpnPackage, vm::getAlwaysOnVpnLockdown, + vm::setAlwaysOnVpn, vm.chosenPackage, ::choosePackage, ::navigateUp) + } + composable { + RecommendedGlobalProxyScreen(vm::setRecommendedGlobalProxy, ::navigateUp) } - composable { RecommendedGlobalProxyScreen(::navigateUp) } composable { NetworkLoggingScreen(::navigateUp) } - composable { WifiAuthKeypairScreen(::navigateUp) } - composable { PreferentialNetworkServiceScreen(::navigateUp, ::navigate) } - composable { AddPreferentialNetworkServiceConfigScreen(it.toRoute(), ::navigateUp) } - composable { OverrideApnScreen(::navigateUp) { navController.navigate(AddApnSetting, it) } } - composable { AddApnSettingScreen(it.arguments?.getParcelable("setting"), ::navigateUp) } + //composable { WifiAuthKeypairScreen(::navigateUp) } + composable { + PreferentialNetworkServiceScreen(vm::getPnsEnabled, vm::setPnsEnabled, vm.pnsConfigs, + vm::getPnsConfigs, ::navigateUp, ::navigate) + } + composable { + val info = vm.pnsConfigs.collectAsStateWithLifecycle().value.getOrNull( + it.toRoute().index + ) ?: PreferentialNetworkServiceInfo() + AddPreferentialNetworkServiceConfigScreen(info, vm::setPnsConfig, ::navigateUp) + } + composable { + OverrideApnScreen(vm.apnConfigs, vm::getApnConfigs, vm::getApnEnabled, + vm::setApnEnabled, ::navigateUp) { navController.navigate(AddApnSetting(it)) } + } + composable { + val origin = vm.apnConfigs.collectAsStateWithLifecycle().value.getOrNull((it.toRoute() as AddApnSetting).index) + AddApnSettingScreen(vm::setApnConfig, vm::removeApnConfig, origin, ::navigateUp) + } composable { WorkProfileScreen(::navigateUp, ::navigate) } composable { @@ -542,7 +577,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { UserRestrictionEditorScreen(vm.userRestrictions, vm::setUserRestriction, ::navigateUp) } - composable(mapOf(serializableNavTypePair>())) { + composable { UserRestrictionOptionsScreen(it.toRoute(), vm.userRestrictions, vm::setUserRestriction, ::navigateUp) } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 41c6ce6..a127451 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,6 +1,7 @@ package com.bintianqi.owndroid import android.accounts.Account +import android.annotation.SuppressLint import android.app.ActivityOptions import android.app.Application import android.app.PendingIntent @@ -9,9 +10,14 @@ import android.app.admin.DeviceAdminReceiver import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback import android.app.admin.FactoryResetProtectionPolicy +import android.app.admin.IDevicePolicyManager import android.app.admin.PackagePolicy +import android.app.admin.PreferentialNetworkServiceConfig import android.app.admin.SystemUpdateInfo import android.app.admin.SystemUpdatePolicy +import android.app.admin.WifiSsidPolicy +import android.app.usage.NetworkStats +import android.app.usage.NetworkStatsManager import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context @@ -21,23 +27,36 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.graphics.Bitmap +import android.net.IpConfiguration +import android.net.LinkAddress +import android.net.ProxyInfo +import android.net.StaticIpConfiguration import android.net.Uri +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.net.wifi.WifiSsid import android.os.Binder import android.os.Build.VERSION import android.os.HardwarePropertiesManager import android.os.UserHandle import android.os.UserManager +import android.telephony.data.ApnSetting import androidx.annotation.RequiresApi import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import com.bintianqi.owndroid.Privilege.DAR import com.bintianqi.owndroid.Privilege.DPM import com.bintianqi.owndroid.dpm.ACTIVATE_DEVICE_OWNER_COMMAND +import com.bintianqi.owndroid.dpm.ApnAuthType +import com.bintianqi.owndroid.dpm.ApnConfig +import com.bintianqi.owndroid.dpm.ApnMvnoType +import com.bintianqi.owndroid.dpm.ApnProtocol import com.bintianqi.owndroid.dpm.AppStatus import com.bintianqi.owndroid.dpm.CaCertInfo import com.bintianqi.owndroid.dpm.CreateUserResult @@ -48,10 +67,24 @@ import com.bintianqi.owndroid.dpm.FrpPolicyInfo import com.bintianqi.owndroid.dpm.HardwareProperties import com.bintianqi.owndroid.dpm.IntentFilterDirection import com.bintianqi.owndroid.dpm.IntentFilterOptions +import com.bintianqi.owndroid.dpm.IpMode +import com.bintianqi.owndroid.dpm.NetworkStatsData +import com.bintianqi.owndroid.dpm.NetworkStatsTarget import com.bintianqi.owndroid.dpm.PendingSystemUpdateInfo +import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceInfo +import com.bintianqi.owndroid.dpm.PrivateDnsConfiguration +import com.bintianqi.owndroid.dpm.ProxyMode +import com.bintianqi.owndroid.dpm.ProxyType +import com.bintianqi.owndroid.dpm.QueryNetworkStatsParams +import com.bintianqi.owndroid.dpm.RecommendedProxyConf +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.UserInformation +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.getPackageInstaller @@ -70,6 +103,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.InetAddress import java.security.MessageDigest import java.security.cert.CertificateException import java.security.cert.CertificateFactory @@ -77,6 +112,8 @@ import java.security.cert.X509Certificate import java.time.ZoneId import java.time.ZonedDateTime import java.util.concurrent.Executors +import kotlin.reflect.jvm.jvmErasure +import kotlin.system.measureTimeMillis class MyViewModel(application: Application): AndroidViewModel(application) { val myRepo = getApplication().myRepo @@ -1246,6 +1283,368 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun logoutUser(): Int { return getUserOperationResultText(DPM.logoutUser(DAR)) } + + val WM = application.getSystemService(Context.WIFI_SERVICE) as WifiManager + // Lockdown admin configured networks + @RequiresApi(30) + fun getLanEnabled(): Boolean { + return DPM.hasLockdownAdminConfiguredNetworks(DAR) + } + @RequiresApi(30) + fun setLanEnabled(state: Boolean) { + DPM.setConfiguredNetworksLockdownState(DAR, state) + } + fun setWifiEnabled(enabled: Boolean): Boolean { + return WM.setWifiEnabled(enabled) + } + fun disconnectWifi(): Boolean { + return WM.disconnect() + } + fun reconnectWifi(): Boolean { + return WM.reconnect() + } + @RequiresApi(24) + fun getWifiMac(): String? { + return DPM.getWifiMacAddress(DAR) + } + val configuredNetworks = MutableStateFlow(emptyList()) + fun getConfiguredNetworks() { + configuredNetworks.value = WM.configuredNetworks.distinctBy { it.networkId }.map { conf -> + WifiInfo( + conf.networkId, conf.SSID.removeSurrounding("\""), null, conf.BSSID ?: "", null, + WifiStatus.entries.find { it.id == conf.status }!!, null, "", null, null, null, null + ) + } + } + fun enableNetwork(id: Int): Boolean { + return WM.enableNetwork(id, false) + } + fun disableNetwork(id: Int): Boolean { + return WM.disableNetwork(id) + } + fun removeNetwork(id: Int): Boolean{ + return WM.removeNetwork(id) + } + fun setWifi(info: WifiInfo): Boolean { + val conf = WifiConfiguration() + conf.SSID = "\"" + info.ssid + "\"" + info.hiddenSsid?.let { conf.hiddenSSID = it } + if (VERSION.SDK_INT >= 30) info.security?.let { conf.setSecurityParams(it.id) } + if (info.security == WifiSecurity.Psk) conf.preSharedKey = info.password + if (VERSION.SDK_INT >= 33) info.macRandomization?.let { conf.macRandomizationSetting = it.id } + if (VERSION.SDK_INT >= 33 && info.ipMode != null) { + val ipConf = if (info.ipMode == IpMode.Static && info.ipConf != null) { + val constructor = LinkAddress::class.constructors.find { + it.parameters.size == 1 && it.parameters[0].type.jvmErasure == String::class + } + val address = constructor!!.call(info.ipConf.address) + val staticIpConf = StaticIpConfiguration.Builder() + .setIpAddress(address) + .setGateway(InetAddress.getByName(info.ipConf.gateway)) + .setDnsServers(info.ipConf.dns.map { InetAddress.getByName(it) }) + .build() + IpConfiguration.Builder().setStaticIpConfiguration(staticIpConf).build() + } else null + conf.setIpConfiguration(ipConf) + } + if (VERSION.SDK_INT >= 26 && info.proxyMode != null) { + val proxy = if (info.proxyMode == ProxyMode.Http) { + info.proxyConf?.let { + ProxyInfo.buildDirectProxy(it.host, it.port, it.exclude) + } + } else null + conf.httpProxy = proxy + } + val result = if (info.id != -1) { + conf.networkId = info.id + WM.updateNetwork(conf) + } else { + WM.addNetwork(conf) + } + if (result != -1) { + when (info.status) { + WifiStatus.Current -> WM.enableNetwork(result, true) + WifiStatus.Enabled -> WM.enableNetwork(result, false) + WifiStatus.Disabled -> WM.disableNetwork(result) + } + } + return result != -1 + } + @RequiresApi(33) + fun getMinimumWifiSecurityLevel(): Int { + return DPM.minimumRequiredWifiSecurityLevel + } + @RequiresApi(33) + fun setMinimumWifiSecurityLevel(level: Int) { + DPM.minimumRequiredWifiSecurityLevel = level + } + @RequiresApi(33) + fun getSsidPolicy(): SsidPolicy { + val policy = DPM.wifiSsidPolicy + return SsidPolicy( + SsidPolicyType.entries.find { it.id == policy?.policyType } ?: SsidPolicyType.None, + policy?.ssids?.map { it.bytes.decodeToString() } ?: emptyList() + ) + } + @RequiresApi(33) + fun setSsidPolicy(policy: SsidPolicy) { + val newPolicy = if (policy.type != SsidPolicyType.None) { + WifiSsidPolicy( + policy.type.id, policy.list.map { WifiSsid.fromBytes(it.encodeToByteArray()) }.toSet() + ) + } else null + DPM.wifiSsidPolicy = newPolicy + } + @RequiresApi(24) + fun getPackageUid(name: String): Int { + return PM.getPackageUid(name, 0) + } + var networkStatsData = emptyList() + @RequiresApi(23) + fun readNetworkStats(stats: NetworkStats): List { + val list = mutableListOf() + while (stats.hasNextBucket()) { + val bucket = NetworkStats.Bucket() + stats.getNextBucket(bucket) + list += readNetworkStatsBucket(bucket) + } + stats.close() + return list + } + @RequiresApi(23) + fun readNetworkStatsBucket(bucket: NetworkStats.Bucket): NetworkStatsData { + return NetworkStatsData( + bucket.rxBytes, bucket.rxPackets, bucket.txBytes, bucket.txPackets, + bucket.uid, bucket.state, bucket.startTimeStamp, bucket.endTimeStamp, + if (VERSION.SDK_INT >= 24) bucket.tag else null, + if (VERSION.SDK_INT >= 24) bucket.roaming else null, + if (VERSION.SDK_INT >= 26) bucket.metered else null + ) + } + @Suppress("NewApi") + fun queryNetworkStats(params: QueryNetworkStatsParams, callback: (String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val nsm = application.getSystemService(NetworkStatsManager::class.java) + try { + val data = when (params.target) { + NetworkStatsTarget.Device -> listOf(readNetworkStatsBucket( + nsm.querySummaryForDevice( + params.networkType.type, null, params.startTime, params.endTime + ) + )) + NetworkStatsTarget.User -> listOf(readNetworkStatsBucket( + nsm.querySummaryForUser( + params.networkType.type, null, params.startTime, params.endTime + ) + )) + NetworkStatsTarget.Uid -> readNetworkStats(nsm.queryDetailsForUid( + params.networkType.type, null, params.startTime, params.endTime, params.uid + )) + NetworkStatsTarget.UidTag -> readNetworkStats(nsm.queryDetailsForUidTag( + params.networkType.type, null, params.startTime, params.endTime, + params.uid, params.tag + )) + NetworkStatsTarget.UidTagState -> readNetworkStats( + nsm.queryDetailsForUidTagState( + params.networkType.type, null, params.startTime, params.endTime, + params.uid, params.tag, params.state.id + ) + ) + } + networkStatsData = data + withContext(Dispatchers.Main) { + if (data.isEmpty()) { + callback(application.getString(R.string.no_data)) + } else { + callback(null) + } + } + } catch(e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + callback(e.message ?: "") + } + } + } + } + fun clearNetworkStats() { + networkStatsData = emptyList() + } + @RequiresApi(29) + fun getPrivateDns(): PrivateDnsConfiguration { + return PrivateDnsConfiguration( + DPM.getGlobalPrivateDnsMode(DAR), DPM.getGlobalPrivateDnsHost(DAR) ?: "" + ) + } + @Suppress("PrivateApi") + @RequiresApi(29) + fun setPrivateDns(conf: PrivateDnsConfiguration): Boolean { + return try { + val field = DevicePolicyManager::class.java.getDeclaredField("mService") + field.isAccessible = true + val dpm = field.get(DPM) as IDevicePolicyManager + val result = dpm.setGlobalPrivateDns(DAR, conf.mode, conf.host) + result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR + } catch (e: Exception) { + e.printStackTrace() + false + } + } + @RequiresApi(24) + fun getAlwaysOnVpnPackage(): String { + return DPM.getAlwaysOnVpnPackage(DAR) ?: "" + } + @RequiresApi(29) + fun getAlwaysOnVpnLockdown(): Boolean { + return DPM.isAlwaysOnVpnLockdownEnabled(DAR) + } + @RequiresApi(24) + fun setAlwaysOnVpn(name: String?, lockdown: Boolean): Int { + return try { + DPM.setAlwaysOnVpnPackage(DAR, name, lockdown) + R.string.succeeded + } catch (_: UnsupportedOperationException) { + R.string.unsupported + } catch (_: PackageManager.NameNotFoundException) { + R.string.not_installed + } + } + fun setRecommendedGlobalProxy(conf: RecommendedProxyConf) { + val info = when (conf.type) { + ProxyType.Off -> null + ProxyType.Pac -> { + if (VERSION.SDK_INT >= 30 && conf.specifyPort) { + ProxyInfo.buildPacProxy(conf.url.toUri(), conf.port) + } else { + ProxyInfo.buildPacProxy(conf.url.toUri()) + } + } + ProxyType.Direct -> { + ProxyInfo.buildDirectProxy(conf.host, conf.port, conf.exclude) + } + } + DPM.setRecommendedGlobalProxy(DAR, info) + } + // PNS: preferential network service + @RequiresApi(31) + fun getPnsEnabled(): Boolean { + return DPM.isPreferentialNetworkServiceEnabled + } + @RequiresApi(31) + fun setPnsEnabled(enabled: Boolean) { + DPM.isPreferentialNetworkServiceEnabled = enabled + } + val pnsConfigs = MutableStateFlow(emptyList()) + @RequiresApi(33) + fun getPnsConfigs() { + pnsConfigs.value = DPM.preferentialNetworkServiceConfigs.map { + PreferentialNetworkServiceInfo( + it.isEnabled, it.networkId, it.isFallbackToDefaultConnectionAllowed, + if (VERSION.SDK_INT >= 34) it.shouldBlockNonMatchingNetworks() else false, + it.excludedUids.toList(), it.includedUids.toList() + ) + } + } + @RequiresApi(33) + fun buildPnsConfig( + info: PreferentialNetworkServiceInfo + ): PreferentialNetworkServiceConfig { + return PreferentialNetworkServiceConfig.Builder().apply { + setEnabled(info.enabled) + @Suppress("WrongConstant") + setNetworkId(info.id) + setFallbackToDefaultConnectionAllowed(info.allowFallback) + if (VERSION.SDK_INT >= 34) setShouldBlockNonMatchingNetworks(info.blockNonMatching) + setIncludedUids(info.includedUids.toIntArray()) + setExcludedUids(info.excludedUids.toIntArray()) + }.build() + } + @RequiresApi(33) + fun setPnsConfig(info: PreferentialNetworkServiceInfo, state: Boolean) { + val configs = pnsConfigs.value.run { + if (state) plus(info) else minus(info) + }.map { buildPnsConfig(it) } + DPM.preferentialNetworkServiceConfigs = configs + } + val apnConfigs = MutableStateFlow(listOf()) + @RequiresApi(28) + fun getApnEnabled(): Boolean { + return DPM.isOverrideApnEnabled(DAR) + } + @RequiresApi(28) + fun setApnEnabled(enabled: Boolean) { + DPM.setOverrideApnsEnabled(DAR, enabled) + } + @RequiresApi(28) + fun getApnConfigs() { + apnConfigs.value = DPM.getOverrideApns(DAR).map { + val proxy = if (VERSION.SDK_INT >= 29) it.proxyAddressAsString else it.proxyAddress.hostName + val mmsProxy = if (VERSION.SDK_INT >= 29) it.mmsProxyAddressAsString else it.mmsProxyAddress.hostName + ApnConfig( + it.isEnabled, it.entryName, it.apnName, proxy, it.proxyPort, + it.user, it.password, it.apnTypeBitmask, it.mmsc.toString(), + mmsProxy, it.mmsProxyPort, + ApnAuthType.entries.find { type -> type.id == it.authType }!!, + ApnProtocol.entries.find { protocol -> protocol.id == it.protocol }!!, + ApnProtocol.entries.find { protocol -> protocol.id == it.roamingProtocol }!!, + it.networkTypeBitmask, + if (VERSION.SDK_INT >= 33) it.profileId else 0, + if (VERSION.SDK_INT >= 29) it.carrierId else 0, + if (VERSION.SDK_INT >= 33) it.mtuV4 else 0, + if (VERSION.SDK_INT >= 33) it.mtuV6 else 0, + ApnMvnoType.entries.find { type -> type.id == it.mvnoType }!!, + it.operatorNumeric, + if (VERSION.SDK_INT >= 33) it.isPersistent else true, + if (VERSION.SDK_INT >= 35) it.isAlwaysOn else true, + it.id + ) + } + } + @RequiresApi(28) + fun buildApnSetting(config: ApnConfig): ApnSetting? { + val builder = ApnSetting.Builder() + builder.setCarrierEnabled(config.enabled) + builder.setEntryName(config.name) + builder.setApnName(config.apn) + if (VERSION.SDK_INT >= 29) builder.setProxyAddress(config.proxy) + else builder.setProxyAddress(InetAddress.getByName(config.proxy)) + config.port?.let { builder.setProxyPort(it) } + builder.setUser(config.username) + builder.setPassword(config.password) + builder.setApnTypeBitmask(config.apnType) + builder.setMmsc(config.mmsc.toUri()) + if (VERSION.SDK_INT >= 29) builder.setMmsProxyAddress(config.mmsProxy) + else builder.setMmsProxyAddress(InetAddress.getByName(config.mmsProxy)) + builder.setAuthType(config.authType.id) + builder.setProtocol(config.protocol.id) + builder.setRoamingProtocol(config.roamingProtocol.id) + builder.setNetworkTypeBitmask(config.networkType) + if (VERSION.SDK_INT >= 33) config.profileId?.let { builder.setProfileId(it) } + if (VERSION.SDK_INT >= 29) config.carrierId?.let { builder.setCarrierId(it) } + if (VERSION.SDK_INT >= 33) { + config.mtuV4?.let { builder.setMtuV4(it) } + config.mtuV6?.let { builder.setMtuV6(it) } + } + builder.setMvnoType(config.mvno.id) + builder.setOperatorNumeric(config.operatorNumeric) + if (VERSION.SDK_INT >= 33) builder.setPersistent(config.persistent) + if (VERSION.SDK_INT >= 35) builder.setAlwaysOn(config.alwaysOn) + return builder.build() + } + @RequiresApi(28) + fun setApnConfig(config: ApnConfig): Boolean { + val settings = buildApnSetting(config) + if (settings == null) return false + return if (config.id == -1) { + DPM.addOverrideApn(DAR, settings) != -1 + } else { + DPM.updateOverrideApn(DAR, config.id, settings) + } + } + @RequiresApi(28) + fun removeApnConfig(id: Int): Boolean { + return DPM.removeOverrideApn(DAR, id) + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index 362a873..bcd078c 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -6,11 +6,13 @@ import android.app.admin.DeviceAdminReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.Binder import android.os.Build.VERSION import android.os.UserHandle import android.os.UserManager import androidx.core.app.NotificationCompat import com.bintianqi.owndroid.dpm.handleNetworkLogs +import com.bintianqi.owndroid.dpm.handlePrivilegeChange import com.bintianqi.owndroid.dpm.processSecurityLogs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -34,6 +36,7 @@ class Receiver : DeviceAdminReceiver() { override fun onEnabled(context: Context, intent: Intent) { super.onEnabled(context, intent) Privilege.updateStatus() + if (Binder.getCallingUid() / 100000 != 0) handlePrivilegeChange(context) } override fun onDisabled(context: Context, intent: Intent) { diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index f6a9947..88a3e3d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -7,14 +7,11 @@ import android.content.Context import android.content.pm.PackageInfo import android.net.Uri import android.os.Build -import android.os.Bundle import android.widget.Toast import androidx.annotation.StringRes import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import androidx.navigation.NavType import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import java.io.FileNotFoundException @@ -25,7 +22,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit -import kotlin.reflect.typeOf var zhCN = true @@ -81,18 +77,6 @@ fun Context.showOperationResultToast(success: Boolean) { const val APK_MIME = "application/vnd.android.package-archive" -inline fun serializableNavTypePair() = - typeOf() to object : NavType(false) { - override fun get(bundle: Bundle, key: String): T? = - bundle.getString(key)?.let { parseValue(it) } - override fun put(bundle: Bundle, key: String, value: T) = - bundle.putString(key, serializeAsValue(value)) - override fun parseValue(value: String): T = - Json.decodeFromString(value) - override fun serializeAsValue(value: T): String = - Json.encodeToString(value) -} - fun exportLogs(context: Context, uri: Uri) { context.contentResolver.openOutputStream(uri)?.use { output -> val proc = Runtime.getRuntime().exec("logcat -d") @@ -103,10 +87,6 @@ fun exportLogs(context: Context, uri: Uri) { } } -fun NavHostController.navigate(route: T, args: Bundle) { - navigate(graph.findNode(route)!!.id, args) -} - val HorizontalPadding = 16.dp @OptIn(ExperimentalStdlibApi::class) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index 9eca2c1..ba81245 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -391,19 +391,13 @@ fun parseSecurityEventData(event: SecurityLog.SecurityEvent): JsonElement? { } fun setDefaultAffiliationID() { - if(VERSION.SDK_INT < 26) return - val privilege = Privilege.status.value + if (VERSION.SDK_INT < 26) return if(!SP.isDefaultAffiliationIdSet) { try { - if(privilege.device || (!privilege.primary && privilege.profile)) { - val affiliationIDs = Privilege.DPM.getAffiliationIds(Privilege.DAR) - if(affiliationIDs.isEmpty()) { - Privilege.DPM.setAffiliationIds(Privilege.DAR, setOf("OwnDroid_default_affiliation_id")) - SP.isDefaultAffiliationIdSet = true - Log.d("DPM", "Default affiliation id set") - } - } - } catch(e: Exception) { + Privilege.DPM.setAffiliationIds(Privilege.DAR, setOf("OwnDroid_default_affiliation_id")) + SP.isDefaultAffiliationIdSet = true + Log.d("DPM", "Default affiliation id set") + } catch (e: Exception) { e.printStackTrace() } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index aef2ab6..c5c2e41 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -2,52 +2,33 @@ package com.bintianqi.owndroid.dpm import android.Manifest import android.annotation.SuppressLint -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_OFF -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_OPPORTUNISTIC -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_UNKNOWN -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_SET_ERROR_FAILURE_SETTING -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_SET_ERROR_HOST_NOT_SERVING -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR +import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_192 import android.app.admin.DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_EAP import android.app.admin.DevicePolicyManager.WIFI_SECURITY_OPEN import android.app.admin.DevicePolicyManager.WIFI_SECURITY_PERSONAL -import android.app.admin.PreferentialNetworkServiceConfig import android.app.admin.WifiSsidPolicy -import android.app.admin.WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_ALLOWLIST -import android.app.admin.WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_DENYLIST import android.app.usage.NetworkStats -import android.app.usage.NetworkStatsManager -import android.content.Context -import android.content.pm.PackageManager.NameNotFoundException import android.net.ConnectivityManager -import android.net.IpConfiguration -import android.net.LinkAddress -import android.net.ProxyInfo -import android.net.StaticIpConfiguration import android.net.wifi.WifiConfiguration -import android.net.wifi.WifiManager -import android.net.wifi.WifiSsid import android.os.Build.VERSION -import android.os.Bundle +import android.provider.Telephony +import android.telephony.TelephonyManager import android.telephony.data.ApnSetting import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.Keep import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -56,6 +37,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -68,30 +51,29 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -101,6 +83,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -113,10 +96,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource @@ -125,19 +109,18 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.core.os.bundleOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.formatFileSize import com.bintianqi.owndroid.formatTime import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.CheckBoxItem import com.bintianqi.owndroid.ui.ErrorDialog import com.bintianqi.owndroid.ui.ExpandExposedTextFieldIcon +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.ListItem @@ -145,19 +128,16 @@ import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.MySmallTitleScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes -import com.bintianqi.owndroid.ui.RadioButtonItem import com.bintianqi.owndroid.ui.SwitchItem import com.bintianqi.owndroid.writeClipBoard +import com.bintianqi.owndroid.yesOrNo import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import java.net.InetAddress -import kotlin.reflect.jvm.jvmErasure @Serializable object Network @@ -169,7 +149,7 @@ fun NetworkScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(VERSION.SDK_INT >= 30) { FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(NetworkOptions) } } - if(VERSION.SDK_INT >= 23 && !privilege.dhizuku && (privilege.device || privilege.profile)) + if (VERSION.SDK_INT >= 23 && !privilege.dhizuku) FunctionItem(R.string.network_stats, icon = R.drawable.query_stats_fill0) { onNavigate(QueryNetworkStats) } if(VERSION.SDK_INT >= 29 && privilege.device) { FunctionItem(R.string.private_dns, icon = R.drawable.dns_fill0) { onNavigate(PrivateDns) } @@ -183,9 +163,9 @@ fun NetworkScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(VERSION.SDK_INT >= 26 && !privilege.dhizuku && (privilege.device || privilege.work)) { FunctionItem(R.string.network_logging, icon = R.drawable.description_fill0) { onNavigate(NetworkLogging) } } - if(VERSION.SDK_INT >= 31) { + /*if(VERSION.SDK_INT >= 31) { FunctionItem(R.string.wifi_auth_keypair, icon = R.drawable.key_fill0) { onNavigate(WifiAuthKeypair) } - } + }*/ if (VERSION.SDK_INT >= 33 && (privilege.work || privilege.device)) { FunctionItem(R.string.preferential_network_service, icon = R.drawable.globe_fill0) { onNavigate(PreferentialNetworkService) } } @@ -198,14 +178,20 @@ fun NetworkScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { @Serializable object NetworkOptions @Composable -fun NetworkOptionsScreen(onNavigateUp: () -> Unit) { +fun NetworkOptionsScreen( + getLanEnabled: () -> Boolean, setLanEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() var dialog by remember { mutableIntStateOf(0) } + var lanEnabled by remember { mutableStateOf(getLanEnabled()) } MyScaffold(R.string.options, onNavigateUp, 0.dp) { if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { SwitchItem(R.string.lockdown_admin_configured_network, icon = R.drawable.wifi_password_fill0, - getState = { Privilege.DPM.hasLockdownAdminConfiguredNetworks(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setConfiguredNetworksLockdownState(Privilege.DAR, it) }, + state = lanEnabled, + onCheckedChange = { + setLanEnabled(it) + lanEnabled = it + }, onClickBlank = { dialog = 1 } ) } @@ -223,8 +209,10 @@ fun NetworkOptionsScreen(onNavigateUp: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun WifiScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit, onNavigateToUpdateNetwork: (Bundle) -> Unit) { - val context = LocalContext.current +fun WifiScreen( + vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit, + editNetwork: (Int) -> Unit +) { val coroutine = rememberCoroutineScope() val pagerState = rememberPagerState { 3 } var tabIndex by rememberSaveable { mutableIntStateOf(0) } @@ -239,133 +227,187 @@ fun WifiScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit, onNavigateTo }, contentWindowInsets = WindowInsets.ime ) { paddingValues -> - var wifiMacDialog by remember { mutableStateOf(false) } Column( modifier = Modifier.fillMaxSize().padding(paddingValues) ) { TabRow(tabIndex) { Tab( - selected = tabIndex == 0, onClick = { tabIndex = 0; coroutine.launch { pagerState.animateScrollToPage(tabIndex) } }, + tabIndex == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, text = { Text(stringResource(R.string.overview)) } ) Tab( - selected = tabIndex == 1, onClick = { tabIndex = 1; coroutine.launch { pagerState.animateScrollToPage(tabIndex) } }, + tabIndex == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, text = { Text(stringResource(R.string.saved_networks)) } ) Tab( - selected = tabIndex == 2, onClick = { tabIndex = 2; coroutine.launch { pagerState.animateScrollToPage(tabIndex) } }, + tabIndex == 2, { coroutine.launch { pagerState.animateScrollToPage(2) } }, text = { Text(stringResource(R.string.add_network)) } ) } HorizontalPager(state = pagerState, verticalAlignment = Alignment.Top) { page -> - if(page == 0) { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val privilege by Privilege.status.collectAsStateWithLifecycle() - @Suppress("DEPRECATION") Column( - modifier = Modifier.fillMaxSize().padding(top = 12.dp) - ) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Button( - onClick = { context.showOperationResultToast(wm.setWifiEnabled(true)) }, - modifier = Modifier.padding(end = 8.dp) - ) { - Text(stringResource(R.string.enable)) - } - Button(onClick = { context.showOperationResultToast(wm.setWifiEnabled(false)) }) { - Text(stringResource(R.string.disable)) - } - } - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) - ) { - Button( - onClick = { context.showOperationResultToast(wm.disconnect()) }, - modifier = Modifier.padding(end = 8.dp) - ) { - Text(stringResource(R.string.disconnect)) - } - Button(onClick = { context.showOperationResultToast(wm.reconnect()) }) { - Text(stringResource(R.string.reconnect)) - } - } - if(VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { - FunctionItem(R.string.wifi_mac_address) { wifiMacDialog = true } - } - if(VERSION.SDK_INT >= 33 && (privilege.device || privilege.org)) { - FunctionItem(R.string.min_wifi_security_level) { onNavigate(WifiSecurityLevel) } - FunctionItem(R.string.wifi_ssid_policy) { onNavigate(WifiSsidPolicyScreen) } - } + @Suppress("NewApi") + when (page) { + 0 -> WifiOverviewScreen(vm::setWifiEnabled, vm::disconnectWifi, + vm::reconnectWifi, vm::getWifiMac, onNavigate) + 1 -> SavedNetworks(vm.configuredNetworks, vm::getConfiguredNetworks, + vm::enableNetwork, vm::disableNetwork, vm::removeNetwork, editNetwork) + 2 -> AddNetworkScreen(null, vm::setWifi) { + coroutine.launch { pagerState.animateScrollToPage(1) } } - } else if(page == 1) { - SavedNetworks(onNavigateToUpdateNetwork) - } else { - AddNetworkScreen(null) {} } } } - if(wifiMacDialog && VERSION.SDK_INT >= 24) { - AlertDialog( - onDismissRequest = { wifiMacDialog = false }, - confirmButton = { TextButton(onClick = { wifiMacDialog = false }) { Text(stringResource(R.string.confirm)) } }, - text = { - val mac = Privilege.DPM.getWifiMacAddress(Privilege.DAR) - OutlinedTextField( - value = mac ?: stringResource(R.string.none), label = { Text(stringResource(R.string.wifi_mac_address)) }, - onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), textStyle = typography.bodyLarge, - trailingIcon = { - if(mac != null) IconButton(onClick = { writeClipBoard(context, mac) }) { - Icon(painter = painterResource(R.drawable.content_copy_fill0), contentDescription = stringResource(R.string.copy)) - } - } - ) - }, - modifier = Modifier.fillMaxWidth() - ) + } +} + +@Composable +fun WifiOverviewScreen( + setWifiEnabled: (Boolean) -> Boolean, disconnect: () -> Boolean, reconnect: () -> Boolean, + getMac: () -> String?, navigate: (Any) -> Unit +) { + val context = LocalContext.current + val privilege by Privilege.status.collectAsStateWithLifecycle() + var macDialog by remember { mutableStateOf(false) } + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(10.dp)) + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { context.showOperationResultToast(setWifiEnabled(true)) }, + modifier = Modifier.padding(end = 8.dp) + ) { + Text(stringResource(R.string.enable)) + } + Button(onClick = { context.showOperationResultToast(setWifiEnabled(false)) }) { + Text(stringResource(R.string.disable)) + } + } + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Button( + onClick = { context.showOperationResultToast(disconnect()) }, + modifier = Modifier.padding(end = 8.dp) + ) { + Text(stringResource(R.string.disconnect)) + } + Button(onClick = { context.showOperationResultToast(reconnect()) }) { + Text(stringResource(R.string.reconnect)) + } + } + if(VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { + FunctionItem(R.string.wifi_mac_address) { macDialog = true } + } + if(VERSION.SDK_INT >= 33 && (privilege.device || privilege.org)) { + FunctionItem(R.string.min_wifi_security_level) { navigate(WifiSecurityLevel) } + FunctionItem(R.string.wifi_ssid_policy) { navigate(WifiSsidPolicyScreen) } } } + if (macDialog && VERSION.SDK_INT >= 24) { + AlertDialog( + title = { Text(stringResource(R.string.wifi_mac_address)) }, + text = { + val mac = getMac() + OutlinedTextField( + value = mac ?: stringResource(R.string.none), onValueChange = {}, + readOnly = true, modifier = Modifier.fillMaxWidth(), textStyle = MaterialTheme.typography.bodyLarge, + trailingIcon = { + if (mac != null) IconButton({ writeClipBoard(context, mac) }) { + Icon(painterResource(R.drawable.content_copy_fill0), null) + } + } + ) + }, + onDismissRequest = { macDialog = false }, + confirmButton = { + TextButton({ macDialog = false }) { Text(stringResource(R.string.confirm)) } + } + ) + } +} + +@Serializable +data class WifiInfo( + val id: Int, val ssid: String, val hiddenSsid: Boolean?, val bssid: String, + val macRandomization: WifiMacRandomization?, val status: WifiStatus, + val security: WifiSecurity?, val password: String, val ipMode: IpMode?, val ipConf: IpConf?, + val proxyMode: ProxyMode?, val proxyConf: ProxyConf? +) + +@Keep +@Suppress("InlinedApi", "DEPRECATION") +enum class WifiMacRandomization(val id: Int, val text: Int) { + None(WifiConfiguration.RANDOMIZATION_NONE, R.string.none), + Persistent(WifiConfiguration.RANDOMIZATION_PERSISTENT, R.string.persistent), + NonPersistent(WifiConfiguration.RANDOMIZATION_NON_PERSISTENT, R.string.non_persistent), + Auto(WifiConfiguration.RANDOMIZATION_AUTO, R.string.auto) +} + +@Keep +@Suppress("InlinedApi", "DEPRECATION") +enum class WifiSecurity(val id: Int, val text: Int) { + Open(WifiConfiguration.SECURITY_TYPE_OPEN, R.string.wifi_security_open), + Psk(WifiConfiguration.SECURITY_TYPE_PSK, R.string.wifi_security_psk) +} + +@Keep +@Suppress("DEPRECATION") +enum class WifiStatus(val id: Int, val text: Int) { + Current(WifiConfiguration.Status.CURRENT, R.string.current), + Enabled(WifiConfiguration.Status.ENABLED, R.string.enabled), + Disabled(WifiConfiguration.Status.DISABLED, R.string.disabled) +} + +@Serializable +data class IpConf(val address: String, val gateway: String, val dns: List) + +@Serializable +data class ProxyConf(val host: String, val port: Int, val exclude: List) + +@Keep +enum class IpMode(val text: Int) { + Dhcp(R.string.wifi_mode_dhcp), Static(R.string.static_str) +} +@Keep +enum class ProxyMode(val text: Int) { + None(R.string.none), Http(R.string.http) } @Suppress("DEPRECATION") @OptIn(ExperimentalPermissionsApi::class) @Composable -private fun SavedNetworks(onNavigateToUpdateNetwork: (Bundle) -> Unit) { +private fun SavedNetworks( + configuredNetworks: StateFlow>, getConfiguredNetworks: () -> Unit, + enableNetwork: (Int) -> Boolean, disableNetwork: (Int) -> Boolean, + removeNetwork: (Int) -> Boolean, editNetwork: (Int) -> Unit +) { val context = LocalContext.current - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val configuredNetworks = remember { mutableStateListOf() } - var networkDetailsDialog by remember { mutableIntStateOf(-1) } // -1:Hidden, 0+:Index of configuredNetworks - val coroutine = rememberCoroutineScope() - fun refresh() { - configuredNetworks.clear() - coroutine.launch(Dispatchers.IO) { - val list = wm.configuredNetworks.distinctBy { it.networkId } - withContext(Dispatchers.Main) { configuredNetworks.addAll(list) } - } + var dialog by remember { mutableIntStateOf(-1) } + val list by configuredNetworks.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + getConfiguredNetworks() } - LaunchedEffect(Unit) { refresh() } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 8.dp, end = 8.dp, bottom = 60.dp) - ) { - val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) - val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - if(it) refresh() - } - if(!locationPermission.status.isGranted) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier + val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) getConfiguredNetworks() + } + LazyColumn { + item { + if (!locationPermission.status.isGranted) Row( + Modifier + .padding(10.dp) .fillMaxWidth() .padding(12.dp) .clip(RoundedCornerShape(15)) .background(MaterialTheme.colorScheme.primaryContainer) - .clickable { requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } + .clickable { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }, + Arrangement.SpaceBetween, Alignment.CenterVertically ) { Icon( - imageVector = Icons.Outlined.LocationOn, contentDescription = null, + Icons.Outlined.LocationOn, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.padding(start = 8.dp, end = 4.dp)) Text( @@ -375,253 +417,305 @@ private fun SavedNetworks(onNavigateToUpdateNetwork: (Bundle) -> Unit) { ) } } - configuredNetworks.forEachIndexed { index, network -> + itemsIndexed(list) { index, network -> Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(start = 8.dp, top = 8.dp) + modifier = Modifier.fillMaxWidth().padding(12.dp, 4.dp) ) { - Text(text = network.SSID.removeSurrounding("\""), style = typography.titleLarge) - IconButton(onClick = { networkDetailsDialog = index }) { - Icon(painter = painterResource(R.drawable.more_horiz_fill0), contentDescription = null) + Text(network.ssid) + IconButton({ dialog = index }) { + Icon(painterResource(R.drawable.more_horiz_fill0), null) } } } } - if(networkDetailsDialog != -1) AlertDialog( + if (dialog != -1) AlertDialog( text = { - val network = configuredNetworks[networkDetailsDialog] - val statusText = when(network.status) { - WifiConfiguration.Status.CURRENT -> R.string.current - WifiConfiguration.Status.DISABLED -> R.string.disabled - WifiConfiguration.Status.ENABLED -> R.string.enabled - else -> R.string.place_holder - } + val network = list[dialog] Column { - Text(stringResource(R.string.network_id) + ": " + network.networkId.toString()) + Text(stringResource(R.string.network_id) + ": " + network.id.toString()) + Spacer(Modifier.height(4.dp)) + Text("SSID", style = MaterialTheme.typography.titleMedium) SelectionContainer { - Text("SSID: " + network.SSID) - if(network.BSSID != null) Text("BSSID: " + network.BSSID) + Text(network.ssid) + } + Spacer(Modifier.height(4.dp)) + if (network.bssid.isNotEmpty()) { + Text("BSSID", style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text(network.bssid) + } + Spacer(Modifier.height(4.dp)) + } + Text(stringResource(R.string.status), style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text(stringResource(network.status.text)) } - Text(stringResource(R.string.status) + ": " + stringResource(statusText)) Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp) + Modifier.fillMaxWidth().padding(top = 8.dp), Arrangement.SpaceBetween ) { - Button( - onClick = { - context.showOperationResultToast(wm.enableNetwork(network.networkId, false)) - networkDetailsDialog = -1 - refresh() - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.enable)) + FilledTonalButton({ + val result = if (network.status == WifiStatus.Disabled) { + enableNetwork(network.id) + } else { + disableNetwork(network.id) + } + context.showOperationResultToast(result) + dialog = -1 + getConfiguredNetworks() + }) { + if (network.status == WifiStatus.Disabled) { + Text(stringResource(R.string.enable)) + } else { + Text(stringResource(R.string.disable)) + } } - Button( - onClick = { - context.showOperationResultToast(wm.disableNetwork(network.networkId)) - networkDetailsDialog = -1 - refresh() - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.disable)) + Row { + FilledTonalIconButton({ + editNetwork(dialog) + dialog = -1 + }) { + Icon(Icons.Outlined.Edit, stringResource(R.string.edit)) + } + FilledTonalIconButton({ + val result = removeNetwork(network.id) + context.showOperationResultToast(result) + if (result) { + dialog = -1 + getConfiguredNetworks() + } + }) { + Icon(Icons.Outlined.Delete, stringResource(R.string.delete)) + } } } - Button( - onClick = { - networkDetailsDialog = -1 - onNavigateToUpdateNetwork(bundleOf("wifi_configuration" to network)) - }, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Edit, null) - Text(stringResource(R.string.edit)) - } - TextButton( - onClick = { - context.showOperationResultToast(wm.removeNetwork(network.networkId)) - networkDetailsDialog = -1 - refresh() - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Outlined.Delete, null) - Text(stringResource(R.string.remove)) - } } }, confirmButton = { - TextButton(onClick = { networkDetailsDialog = -1 }) { + TextButton({ dialog = -1 }) { Text(stringResource(R.string.confirm)) } }, - onDismissRequest = { networkDetailsDialog = -1 } + onDismissRequest = { dialog = -1 } ) } @Serializable -object AddNetwork +data class UpdateNetwork(val index: Int) +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AddNetworkScreen(data: Bundle, onNavigateUp: () -> Unit) { - MySmallTitleScaffold(R.string.update_network, onNavigateUp, 0.dp) { - AddNetworkScreen(data.getParcelable("wifi_configuration"), onNavigateUp) +fun UpdateNetworkScreen(info: WifiInfo, setNetwork: (WifiInfo) -> Boolean, onNavigateUp: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.update_network)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) + ) + }, + contentWindowInsets = WindowInsets.ime + ) { paddingValues -> + Column( + modifier = Modifier.fillMaxSize().padding(paddingValues) + ) { + AddNetworkScreen(info, setNetwork, onNavigateUp) + } } } -@Suppress("DEPRECATION") +@Composable +fun UnchangedMenuItem(onClick: () -> Unit) { + DropdownMenuItem({ Text(stringResource(R.string.unchanged)) }, onClick) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AddNetworkScreen(wifiConfig: WifiConfiguration? = null, onNavigateUp: () -> Unit) { +private fun AddNetworkScreen( + wifiInfo: WifiInfo?, setNetwork: (WifiInfo) -> Boolean, onNavigateUp: () -> Unit +) { + val updating = wifiInfo != null val context = LocalContext.current val fm = LocalFocusManager.current - var resultDialog by remember { mutableStateOf(false) } - var createdNetworkId by remember { mutableIntStateOf(-1) } - var createNetworkResult by remember { mutableIntStateOf(0) } - var dropdownMenu by remember { mutableIntStateOf(0) } // 0: None, 1:Status, 2:Security, 3:MAC randomization, 4:Static IP, 5:Proxy - var status by remember { mutableIntStateOf(WifiConfiguration.Status.ENABLED) } + /** 0: None, 1:Status, 2:Security, 3:MAC randomization, 4:Static IP, 5:Proxy, 6:Hidden SSID */ + var menu by remember { mutableIntStateOf(0) } + var status by remember { mutableStateOf(WifiStatus.Enabled) } var ssid by remember { mutableStateOf("") } - var hiddenSsid by remember { mutableStateOf(false) } - var securityType by remember { mutableIntStateOf(WifiConfiguration.SECURITY_TYPE_OPEN) } + var hiddenSsid by remember { mutableStateOf(false) } + var security by remember { mutableStateOf(WifiSecurity.Open) } var password by remember { mutableStateOf("") } - var macRandomizationSetting by remember { mutableIntStateOf(WifiConfiguration.RANDOMIZATION_AUTO) } - var useStaticIp by remember { mutableStateOf(false) } + var macRandomization by remember { mutableStateOf(WifiMacRandomization.None) } + var ipMode by remember { mutableStateOf(IpMode.Dhcp) } var ipAddress by remember { mutableStateOf("") } var gatewayAddress by remember { mutableStateOf("") } var dnsServers by remember { mutableStateOf("") } - var useHttpProxy by remember { mutableStateOf(false) } + var proxyMode by remember { mutableStateOf(ProxyMode.None) } var httpProxyHost by remember { mutableStateOf("") } var httpProxyPort by remember { mutableStateOf("") } var httpProxyExclList by remember { mutableStateOf("") } LaunchedEffect(Unit) { - if(wifiConfig != null) { - status = wifiConfig.status - if(wifiConfig.status == WifiConfiguration.Status.CURRENT) status = WifiConfiguration.Status.ENABLED - ssid = wifiConfig.SSID.removeSurrounding("\"") + if (updating) { + hiddenSsid = null + security = null + macRandomization = null + ipMode = null + proxyMode = null + status = wifiInfo.status + ssid = wifiInfo.ssid } } - var errorMessage by remember { mutableStateOf(null) } Column( - modifier = (if(wifiConfig == null) Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 60.dp) else Modifier) - .padding(start = 8.dp, end = 8.dp, top = 12.dp) + Modifier.verticalScroll(rememberScrollState()).padding(horizontal = HorizontalPadding) ) { - ExposedDropdownMenuBox(dropdownMenu == 1, { dropdownMenu = if(it) 1 else 0 }) { - val statusText = when(status) { - WifiConfiguration.Status.DISABLED -> R.string.disabled - WifiConfiguration.Status.ENABLED -> R.string.enabled - else -> R.string.place_holder - } + Spacer(Modifier.height(4.dp)) + ExposedDropdownMenuBox( + menu == 1, { menu = if(it) 1 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = stringResource(statusText), onValueChange = {}, readOnly = true, + stringResource(status.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.status)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 1) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 16.dp) + trailingIcon = { ExpandExposedTextFieldIcon(menu == 1) }, ) - ExposedDropdownMenu(dropdownMenu == 1, { dropdownMenu = 0 }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.disabled)) }, - onClick = { - status = WifiConfiguration.Status.DISABLED - dropdownMenu = 0 - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.enabled)) }, - onClick = { - status = WifiConfiguration.Status.ENABLED - dropdownMenu = 0 - } - ) + ExposedDropdownMenu(menu == 1, { menu = 0 }) { + WifiStatus.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + status = it + menu = 0 + } + ) + } } } OutlinedTextField( - value = ssid, onValueChange = { ssid = it }, label = { Text("SSID") }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + ssid, { ssid = it }, Modifier.fillMaxWidth().padding(bottom = 8.dp), + label = { Text("SSID") } ) - CheckBoxItem(R.string.hidden_ssid, hiddenSsid) { hiddenSsid = it } - if(VERSION.SDK_INT >= 30) { - // TODO: more protocols - val securityTypeTextMap = mutableMapOf( - WifiConfiguration.SECURITY_TYPE_OPEN to stringResource(R.string.wifi_security_open), - WifiConfiguration.SECURITY_TYPE_PSK to "PSK" + ExposedDropdownMenuBox( + menu == 6, { menu = if (it) 6 else 0 }, Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(hiddenSsid?.yesOrNo ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.hidden_ssid)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == 1) } ) - ExposedDropdownMenuBox(dropdownMenu == 2, { dropdownMenu = if(it) 2 else 0 }) { - OutlinedTextField( - value = securityTypeTextMap[securityType] ?: "", onValueChange = {}, label = { Text(stringResource(R.string.security)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 1) }, readOnly = true, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(vertical = 4.dp) - ) - ExposedDropdownMenu(dropdownMenu == 2, { dropdownMenu = 0 }) { - securityTypeTextMap.forEach { - DropdownMenuItem(text = { Text(it.value) }, onClick = { securityType = it.key; dropdownMenu = 0 }) + DropdownMenu(menu == 6, { menu = 0 }) { + if (updating) DropdownMenuItem( + { Text(stringResource(R.string.unchanged)) }, + { + hiddenSsid = null + menu = 0 + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.yes)) }, + { + hiddenSsid = true + menu = 0 + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.no)) }, + { + hiddenSsid = false + menu = 0 } - } - } - AnimatedVisibility(securityType == WifiConfiguration.SECURITY_TYPE_PSK) { - OutlinedTextField( - value = password, onValueChange = { password = it }, label = { Text(stringResource(R.string.password)) }, - modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) ) } } - if(VERSION.SDK_INT >= 33) { - val macRandomizationSettingTextMap = mapOf( - WifiConfiguration.RANDOMIZATION_NONE to R.string.none, - WifiConfiguration.RANDOMIZATION_PERSISTENT to R.string.persistent, - WifiConfiguration.RANDOMIZATION_NON_PERSISTENT to R.string.non_persistent, - WifiConfiguration.RANDOMIZATION_AUTO to R.string.auto + ExposedDropdownMenuBox( + menu == 2, { menu = if(it) 2 else 0 }, Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(security?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.security)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == 1) } ) - ExposedDropdownMenuBox(dropdownMenu == 3, { dropdownMenu = if(it) 3 else 0 }) { + ExposedDropdownMenu(menu == 2, { menu = 0 }) { + if (updating) UnchangedMenuItem { security = null } + WifiSecurity.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + security = it + menu = 0 + } + ) + } + } + } + AnimatedVisibility(security == WifiSecurity.Psk) { + OutlinedTextField( + password, { password = it }, Modifier.fillMaxWidth().padding(bottom = 8.dp), + label = { Text(stringResource(R.string.password)) } + ) + } + if (VERSION.SDK_INT >= 33) { + ExposedDropdownMenuBox( + menu == 3, { menu = if(it) 3 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = stringResource(macRandomizationSettingTextMap[macRandomizationSetting] ?: R.string.place_holder), - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.mac_randomization)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 3) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 8.dp) + stringResource(macRandomization?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.mac_randomization)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == 3) }, ) - ExposedDropdownMenu(dropdownMenu == 3, { dropdownMenu = 0 }) { - macRandomizationSettingTextMap.forEach { + ExposedDropdownMenu(menu == 3, { menu = 0 }) { + if (updating) UnchangedMenuItem { macRandomization = null } + WifiMacRandomization.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.value)) }, - onClick = { - macRandomizationSetting = it.key - dropdownMenu = 0 + { Text(stringResource(it.text)) }, + { + macRandomization = it + menu = 0 } ) } } } } - if(VERSION.SDK_INT >= 33) { - ExposedDropdownMenuBox(dropdownMenu == 4, { dropdownMenu = if(it) 4 else 0 }) { + if (VERSION.SDK_INT >= 33) { + ExposedDropdownMenuBox( + menu == 4, { menu = if(it) 4 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = if(useStaticIp) stringResource(R.string.static_str) else "DHCP", - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.ip_settings)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 4) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(ipMode?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.ip_settings)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == 4) }, ) - ExposedDropdownMenu(dropdownMenu == 4, { dropdownMenu = 0 }) { - DropdownMenuItem(text = { Text("DHCP") }, onClick = { useStaticIp = false; dropdownMenu = 0 }) - DropdownMenuItem(text = { Text(stringResource(R.string.static_str)) }, onClick = { useStaticIp = true; dropdownMenu = 0 }) + ExposedDropdownMenu(menu == 4, { menu = 0 }) { + if (updating) UnchangedMenuItem { ipMode = null } + IpMode.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + ipMode = it + menu = 0 + } + ) + } } } - AnimatedVisibility(visible = useStaticIp, modifier = Modifier.padding(bottom = 8.dp)) { + AnimatedVisibility(ipMode == IpMode.Static) { val gatewayFr = FocusRequester() val dnsFr = FocusRequester() Column { OutlinedTextField( value = ipAddress, onValueChange = { ipAddress = it }, - placeholder = { Text("192.168.1.2/24") }, label = { Text(stringResource(R.string.ip_address)) }, + label = { Text(stringResource(R.string.ip_address)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { gatewayFr.requestFocus() }, + //keyboardActions = KeyboardActions { gatewayFr.requestFocus() }, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) ) OutlinedTextField( value = gatewayAddress, onValueChange = { gatewayAddress = it }, - placeholder = { Text("192.168.1.1") }, label = { Text(stringResource(R.string.gateway_address)) }, + label = { Text(stringResource(R.string.gateway_address)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions { dnsFr.requestFocus() }, modifier = Modifier.focusRequester(gatewayFr).fillMaxWidth().padding(bottom = 4.dp) @@ -637,138 +731,95 @@ private fun AddNetworkScreen(wifiConfig: WifiConfiguration? = null, onNavigateUp } } if(VERSION.SDK_INT >= 26) { - ExposedDropdownMenuBox(dropdownMenu == 5, { dropdownMenu = if(it) 5 else 0 }) { + ExposedDropdownMenuBox( + menu == 5, { menu = if(it) 5 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = if(useHttpProxy) "HTTP" else stringResource(R.string.none), - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.proxy)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 5) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(proxyMode?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.proxy)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == 5) }, ) - ExposedDropdownMenu(dropdownMenu == 5, { dropdownMenu = 0 }) { - DropdownMenuItem(text = { Text(stringResource(R.string.none)) }, onClick = { useHttpProxy = false; dropdownMenu = 0 }) - DropdownMenuItem(text = { Text("HTTP") }, onClick = { useHttpProxy = true; dropdownMenu = 0 }) + ExposedDropdownMenu(menu == 5, { menu = 0 }) { + if (updating) UnchangedMenuItem { proxyMode = null } + ProxyMode.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + proxyMode = it + menu = 0 + } + ) + } } } - AnimatedVisibility(visible = useHttpProxy, modifier = Modifier.padding(bottom = 8.dp)) { + AnimatedVisibility(proxyMode == ProxyMode.Http) { val portFr = FocusRequester() val exclListFr = FocusRequester() Column { OutlinedTextField( - value = httpProxyHost, onValueChange = { httpProxyHost = it }, label = { Text(stringResource(R.string.host)) }, + httpProxyHost, { httpProxyHost = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.host)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { portFr.requestFocus() }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + keyboardActions = KeyboardActions { portFr.requestFocus() } ) OutlinedTextField( - value = httpProxyPort, onValueChange = { httpProxyPort = it }, label = { Text(stringResource(R.string.port)) }, + httpProxyPort, { httpProxyPort = it }, + Modifier.focusRequester(portFr).fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.port)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), - keyboardActions = KeyboardActions { exclListFr.requestFocus() }, - modifier = Modifier.focusRequester(portFr).fillMaxWidth().padding(bottom = 4.dp) + keyboardActions = KeyboardActions { exclListFr.requestFocus() } ) OutlinedTextField( - value = httpProxyExclList, onValueChange = { httpProxyExclList = it }, label = { Text(stringResource(R.string.excluded_hosts)) }, - minLines = 2, placeholder = { Text("example.com\n*.example.com") }, + httpProxyExclList, { httpProxyExclList = it }, + Modifier.focusRequester(exclListFr).fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.excluded_hosts)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions { fm.clearFocus() }, - modifier = Modifier.focusRequester(exclListFr).fillMaxWidth().padding(bottom = 4.dp) + minLines = 2 ) } } } Button( onClick = { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - try { - val config = WifiConfiguration() - config.status = status - config.SSID = '"' + ssid + '"' - config.hiddenSSID = hiddenSsid - if(VERSION.SDK_INT >= 30) config.setSecurityParams(securityType) - if(securityType == WifiConfiguration.SECURITY_TYPE_PSK) config.preSharedKey = '"' + password + '"' - if(VERSION.SDK_INT >= 33) config.macRandomizationSetting = macRandomizationSetting - if(VERSION.SDK_INT >= 33 && useStaticIp) { - val ipConf = IpConfiguration.Builder() - val staticIpConf = StaticIpConfiguration.Builder() - val la: LinkAddress - val con = LinkAddress::class.constructors.find { it.parameters.size == 1 && it.parameters[0].type.jvmErasure == String::class } - la = con!!.call(ipAddress) - staticIpConf.setIpAddress(la) - staticIpConf.setGateway(InetAddress.getByName(gatewayAddress)) - staticIpConf.setDnsServers(dnsServers.lines().map { InetAddress.getByName(it) }) - ipConf.setStaticIpConfiguration(staticIpConf.build()) - config.setIpConfiguration(ipConf.build()) - } - if(VERSION.SDK_INT >= 26 && useHttpProxy) { - config.httpProxy = ProxyInfo.buildDirectProxy(httpProxyHost, httpProxyPort.toInt(), httpProxyExclList.lines()) - } - if(wifiConfig != null) { - config.networkId = wifiConfig.networkId - createdNetworkId = wm.updateNetwork(config) - } else { - if(VERSION.SDK_INT >= 31) { - val result = wm.addNetworkPrivileged(config) - createdNetworkId = result.networkId - createNetworkResult = result.statusCode - } else { - createdNetworkId = wm.addNetwork(config) - } - } - resultDialog = true - } catch(e: Exception) { - e.printStackTrace() - errorMessage = e.message - } + val result = setNetwork(WifiInfo( + -1, ssid, hiddenSsid, "", macRandomization, status, security, password, ipMode, + IpConf(ipAddress, gatewayAddress, dnsServers.lines().filter { it.isNotBlank() }), + proxyMode, ProxyConf(httpProxyHost, httpProxyPort.toInt(), httpProxyExclList.lines().filter { it.isNotBlank() }) + )) + context.showOperationResultToast(result) + if (result) onNavigateUp() }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) ) { - Text(stringResource(if(wifiConfig != null) R.string.update else R.string.add)) + Text(stringResource(if (updating) R.string.update else R.string.add)) } - if(resultDialog) AlertDialog( - text = { - val statusText = when(createNetworkResult) { - WifiManager.AddNetworkResult.STATUS_SUCCESS -> R.string.success - //WifiManager.AddNetworkResult.STATUS_ADD_WIFI_CONFIG_FAILURE -> R.string.failed - WifiManager.AddNetworkResult.STATUS_INVALID_CONFIGURATION -> R.string.add_network_result_invalid_configuration - else -> R.string.failed - } - Text(stringResource(statusText) + "\n" + stringResource(R.string.network_id) + ": " + createdNetworkId) - }, - confirmButton = { - TextButton( - onClick = { - resultDialog = false - if(createdNetworkId != -1) onNavigateUp() - } - ) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { resultDialog = false } - ) + Spacer(Modifier.height(40.dp)) } - ErrorDialog(errorMessage) { errorMessage = null } } @Serializable object WifiSecurityLevel @RequiresApi(33) @Composable -fun WifiSecurityLevelScreen(onNavigateUp: () -> Unit) { +fun WifiSecurityLevelScreen( + getLevel: () -> Int, setLevel: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var selectedWifiSecLevel by remember { mutableIntStateOf(0) } - LaunchedEffect(Unit) { selectedWifiSecLevel = Privilege.DPM.minimumRequiredWifiSecurityLevel } + var level by remember { mutableIntStateOf(getLevel()) } MyScaffold(R.string.min_wifi_security_level, onNavigateUp, 0.dp) { - FullWidthRadioButtonItem(R.string.wifi_security_open, selectedWifiSecLevel == WIFI_SECURITY_OPEN) { selectedWifiSecLevel = WIFI_SECURITY_OPEN } - FullWidthRadioButtonItem("WEP, WPA(2)-PSK", selectedWifiSecLevel == WIFI_SECURITY_PERSONAL) { selectedWifiSecLevel = WIFI_SECURITY_PERSONAL } - FullWidthRadioButtonItem("WPA-EAP", selectedWifiSecLevel == WIFI_SECURITY_ENTERPRISE_EAP) { selectedWifiSecLevel = WIFI_SECURITY_ENTERPRISE_EAP } - FullWidthRadioButtonItem("WPA3-192bit", selectedWifiSecLevel == WIFI_SECURITY_ENTERPRISE_192) { selectedWifiSecLevel = WIFI_SECURITY_ENTERPRISE_192 } + FullWidthRadioButtonItem(R.string.wifi_security_open, level == WIFI_SECURITY_OPEN) { level = WIFI_SECURITY_OPEN } + FullWidthRadioButtonItem("WEP, WPA(2)-PSK", level == WIFI_SECURITY_PERSONAL) { level = WIFI_SECURITY_PERSONAL } + FullWidthRadioButtonItem("WPA-EAP", level == WIFI_SECURITY_ENTERPRISE_EAP) { level = WIFI_SECURITY_ENTERPRISE_EAP } + FullWidthRadioButtonItem("WPA3-192bit", level == WIFI_SECURITY_ENTERPRISE_192) { level = WIFI_SECURITY_ENTERPRISE_192 } Button( onClick = { - Privilege.DPM.minimumRequiredWifiSecurityLevel = selectedWifiSecLevel + setLevel(level) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) ) { Text(stringResource(R.string.apply)) } @@ -776,107 +827,114 @@ fun WifiSecurityLevelScreen(onNavigateUp: () -> Unit) { } } +data class SsidPolicy(val type: SsidPolicyType, val list: List) + +@Suppress("InlinedApi") +enum class SsidPolicyType(val id: Int, val text: Int) { + None(-1, R.string.none), + Whitelist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_ALLOWLIST, R.string.whitelist), + Blacklist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_DENYLIST, R.string.blacklist) +} + @Serializable object WifiSsidPolicyScreen @RequiresApi(33) @Composable -fun WifiSsidPolicyScreen(onNavigateUp: () -> Unit) { +fun WifiSsidPolicyScreen( + getPolicy: () -> SsidPolicy, setPolicy: (SsidPolicy) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current MyScaffold(R.string.wifi_ssid_policy, onNavigateUp, 0.dp) { - var selectedPolicyType by remember { mutableIntStateOf(-1) } - val ssidList = remember { mutableStateListOf() } - fun refreshPolicy() { - val policy = Privilege.DPM.wifiSsidPolicy - ssidList.clear() - selectedPolicyType = policy?.policyType ?: -1 - ssidList.addAll(policy?.ssids ?: mutableSetOf()) + var type by remember { mutableStateOf(SsidPolicyType.None) } + val list = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + getPolicy().let { + type = it.type + list.addAll(it.list) + } } - LaunchedEffect(Unit) { refreshPolicy() } - FullWidthRadioButtonItem(R.string.none, selectedPolicyType == -1) { selectedPolicyType = -1 } - FullWidthRadioButtonItem(R.string.whitelist, selectedPolicyType == WIFI_SSID_POLICY_TYPE_ALLOWLIST) { - selectedPolicyType = WIFI_SSID_POLICY_TYPE_ALLOWLIST + SsidPolicyType.entries.forEach { + FullWidthRadioButtonItem(it.text, type == it) { type = it } } - FullWidthRadioButtonItem(R.string.blacklist, selectedPolicyType == WIFI_SSID_POLICY_TYPE_DENYLIST) { - selectedPolicyType = WIFI_SSID_POLICY_TYPE_DENYLIST - } - AnimatedVisibility(selectedPolicyType != -1) { + AnimatedVisibility(type != SsidPolicyType.None) { var inputSsid by remember { mutableStateOf("") } Column(Modifier.padding(horizontal = HorizontalPadding)) { Column(modifier = Modifier.animateContentSize()) { - for(i in ssidList) { - ListItem(i.bytes.decodeToString()) { ssidList -= i } + for(i in list) { + ListItem(i) { list -= i } } } Spacer(Modifier.padding(vertical = 5.dp)) OutlinedTextField( - value = inputSsid, + inputSsid, { inputSsid = it }, Modifier.fillMaxWidth(), label = { Text("SSID") }, - onValueChange = { inputSsid = it }, trailingIcon = { IconButton( onClick = { - ssidList += WifiSsid.fromBytes(inputSsid.encodeToByteArray()) + list += inputSsid inputSsid = "" }, - enabled = inputSsid != "" + enabled = inputSsid.isNotEmpty() ) { - Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add)) + Icon(Icons.Default.Add, stringResource(R.string.add)) } }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) } } Button( onClick = { focusMgr.clearFocus() - Privilege.DPM.wifiSsidPolicy = if(selectedPolicyType == -1 || ssidList.isEmpty()) { - null - } else { - WifiSsidPolicy(selectedPolicyType, ssidList.toSet()) - } - refreshPolicy() + setPolicy(SsidPolicy(type, list)) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + enabled = type == SsidPolicyType.None || list.isNotEmpty() ) { Text(stringResource(R.string.apply)) } } } -private enum class NetworkStatsActiveTextField { None, Type, Target, NetworkType, SubscriberId, StartTime, EndTime, Uid, Tag, State } +private enum class NetworkStatsMenu { + None, Type, Target, NetworkType, StartTime, EndTime, Uid, Tag, State +} +enum class NetworkStatsType(val text: Int) { Summary(R.string.summary), Details(R.string.details) } @Suppress("DEPRECATION") -private enum class NetworkType(val type: Int, @StringRes val strRes: Int) { +enum class NetworkType(val type: Int, val text: Int) { Mobile(ConnectivityManager.TYPE_MOBILE, R.string.mobile), Wifi(ConnectivityManager.TYPE_WIFI, R.string.wifi), Bluetooth(ConnectivityManager.TYPE_BLUETOOTH, R.string.bluetooth), Ethernet(ConnectivityManager.TYPE_ETHERNET, R.string.ethernet), Vpn(ConnectivityManager.TYPE_VPN, R.string.vpn), } -private enum class NetworkStatsTarget(@StringRes val strRes: Int, val minApi: Int) { - Device(R.string.device, 23), User(R.string.user, 23), - Uid(R.string.uid, 23), UidTag(R.string.uid_tag, 24), UidTagState(R.string.uid_tag_state, 28) +enum class NetworkStatsTarget(val text: Int, val type: NetworkStatsType, val minApi: Int = 23) { + Device(R.string.device, NetworkStatsType.Summary), + User(R.string.user, NetworkStatsType.Summary), + Uid(R.string.uid, NetworkStatsType.Details), + UidTag(R.string.uid_tag, NetworkStatsType.Details, 24), + UidTagState(R.string.uid_tag_state, NetworkStatsType.Details, 28) +} +@Suppress("InlinedApi") +enum class NetworkStatsState(val id: Int, val text: Int) { + All(NetworkStats.Bucket.STATE_ALL, R.string.all), + Default(NetworkStats.Bucket.STATE_DEFAULT, R.string.default_str), + Foreground(NetworkStats.Bucket.STATE_FOREGROUND, R.string.foreground) } @RequiresApi(23) -private enum class NetworkStatsUID(val uid: Int, @StringRes val strRes: Int) { +enum class NetworkStatsUID(val uid: Int, val text: Int) { All(NetworkStats.Bucket.UID_ALL, R.string.all), Removed(NetworkStats.Bucket.UID_REMOVED, R.string.uninstalled), Tethering(NetworkStats.Bucket.UID_TETHERING, R.string.tethering) } -@RequiresApi(23) -fun NetworkStats.toBucketList(): List { - val list = mutableListOf() - while(hasNextBucket()) { - val bucket = NetworkStats.Bucket() - if(getNextBucket(bucket)) list += bucket - } - close() - return list -} + +data class QueryNetworkStatsParams( + val type: NetworkStatsType, val target: NetworkStatsTarget, val networkType: NetworkType, + val startTime: Long, val endTime: Long, val uid: Int, val tag: Int, val state: NetworkStatsState +) @Serializable object QueryNetworkStats @@ -884,205 +942,160 @@ fun NetworkStats.toBucketList(): List { @RequiresApi(23) @Composable fun NetworkStatsScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, - onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkStatsViewer) -> Unit + chosenPackage: Channel, onChoosePackage: () -> Unit, getUid: (String) -> Int, + queryStats: (QueryNetworkStatsParams, (String?) -> Unit) -> Unit, onNavigateUp: () -> Unit, + onNavigateToViewer: () -> Unit ) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - val fm = LocalFocusManager.current - val nsm = context.getSystemService(NetworkStatsManager::class.java) - val coroutine = rememberCoroutineScope() - var activeTextField by remember { mutableStateOf(NetworkStatsActiveTextField.None) } //0:None, 1:Network type, 2:Start time, 3:End time - var queryType by rememberSaveable { mutableIntStateOf(1) } //1:Summary, 2:Details - var target by rememberSaveable { mutableStateOf(NetworkStatsTarget.Device) } + fun getDefaultSummaryTarget(): NetworkStatsTarget { + return if (privilege.device) NetworkStatsTarget.Device else NetworkStatsTarget.User + } + var menu by remember { mutableStateOf(NetworkStatsMenu.None) } + var type by rememberSaveable { mutableStateOf(NetworkStatsType.Summary) } + var target by rememberSaveable { mutableStateOf(getDefaultSummaryTarget()) } var networkType by rememberSaveable { mutableStateOf(NetworkType.Mobile) } - var subscriberId by rememberSaveable { mutableStateOf(null) } var startTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis() - 7*24*60*60*1000) } var endTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) } var uid by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.UID_ALL) } var tag by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.TAG_NONE) } - var state by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.STATE_ALL) } - val startTimeTextFieldInteractionSource = remember { MutableInteractionSource() } - val endTimeTextFieldInteractionSource = remember { MutableInteractionSource() } - if(startTimeTextFieldInteractionSource.collectIsPressedAsState().value) activeTextField = NetworkStatsActiveTextField.StartTime - if(endTimeTextFieldInteractionSource.collectIsPressedAsState().value) activeTextField = NetworkStatsActiveTextField.EndTime + var state by rememberSaveable { mutableStateOf(NetworkStatsState.All) } + val startTimeIs = remember { MutableInteractionSource() } + val endTimeIs = remember { MutableInteractionSource() } + if (startTimeIs.collectIsPressedAsState().value) menu = NetworkStatsMenu.StartTime + if (endTimeIs.collectIsPressedAsState().value) menu = NetworkStatsMenu.EndTime var errorMessage by remember { mutableStateOf(null) } MyScaffold(R.string.network_stats, onNavigateUp) { ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Type, - { activeTextField = if(it) NetworkStatsActiveTextField.Type else NetworkStatsActiveTextField.Type }, + menu == NetworkStatsMenu.Type, + { menu = if (it) NetworkStatsMenu.Type else NetworkStatsMenu.None }, Modifier.padding(top = 8.dp, bottom = 4.dp) ) { - val typeTextMap = mapOf( - 1 to R.string.summary, - 2 to R.string.details - ) OutlinedTextField( - value = stringResource(typeTextMap[queryType]!!), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.type)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Type) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth() + stringResource(type.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.type)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Type) } ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Type, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Type, { menu = NetworkStatsMenu.None } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.summary)) }, - onClick = { - queryType = 1 - target = NetworkStatsTarget.Device - activeTextField = NetworkStatsActiveTextField.None - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.details)) }, - onClick = { - queryType = 2 - target = NetworkStatsTarget.Uid - activeTextField = NetworkStatsActiveTextField.None - } - ) - } - } - ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Target, - { activeTextField = if(it) NetworkStatsActiveTextField.Target else NetworkStatsActiveTextField.None } - ) { - OutlinedTextField( - value = stringResource(target.strRes), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.target)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Target) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) - ) - ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Target, { activeTextField = NetworkStatsActiveTextField.None } - ) { - NetworkStatsTarget.entries.forEach { - if( - VERSION.SDK_INT >= it.minApi && - (privilege.device || it != NetworkStatsTarget.Device) && - ((queryType == 1 && (it == NetworkStatsTarget.Device || it == NetworkStatsTarget.User)) || - (queryType == 2 && (it == NetworkStatsTarget.Uid || it == NetworkStatsTarget.UidTag || it == NetworkStatsTarget.UidTagState))) - ) DropdownMenuItem( - text = { Text(stringResource(it.strRes)) }, - onClick = { - target = it - activeTextField = NetworkStatsActiveTextField.None + NetworkStatsType.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + type = it + target = if (it == NetworkStatsType.Summary) getDefaultSummaryTarget() + else NetworkStatsTarget.Uid + menu = NetworkStatsMenu.None } ) } } } ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.NetworkType, - { activeTextField = if(it) NetworkStatsActiveTextField.NetworkType else NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Target, + { menu = if(it) NetworkStatsMenu.Target else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) ) { OutlinedTextField( - value = stringResource(networkType.strRes), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.network_type)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.NetworkType) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(target.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.target)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Target) } ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.NetworkType, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Target, { menu = NetworkStatsMenu.None } + ) { + NetworkStatsTarget.entries.filter { + VERSION.SDK_INT >= it.minApi && type == it.type + }.forEach { + DropdownMenuItem( + text = { Text(stringResource(it.text)) }, + onClick = { + target = it + menu = NetworkStatsMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.NetworkType, + { menu = if(it) NetworkStatsMenu.NetworkType else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) + ) { + OutlinedTextField( + stringResource(networkType.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.network_type)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.NetworkType) } + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.NetworkType, { menu = NetworkStatsMenu.None } ) { NetworkType.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.strRes)) }, + text = { Text(stringResource(it.text)) }, onClick = { networkType = it - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) } } } - ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.SubscriberId, - { activeTextField = if(it) NetworkStatsActiveTextField.SubscriberId else NetworkStatsActiveTextField.None } - ) { - var readOnly by rememberSaveable { mutableStateOf(true) } - OutlinedTextField( - value = subscriberId ?: "null", onValueChange = { if(!readOnly) subscriberId = it }, readOnly = readOnly, - label = { Text(stringResource(R.string.subscriber_id)) }, - isError = !readOnly && subscriberId.isNullOrBlank(), - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.SubscriberId) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) - ) - ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.SubscriberId, { activeTextField = NetworkStatsActiveTextField.None } - ) { - DropdownMenuItem( - text = { Text("null") }, - onClick = { - readOnly = true - subscriberId = null - activeTextField = NetworkStatsActiveTextField.None - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.input)) }, - onClick = { - readOnly = false - subscriberId = "" - activeTextField = NetworkStatsActiveTextField.None - } - ) - } - } OutlinedTextField( value = startTime.let { if(it == -1L) "" else formatTime(it) }, onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.start_time)) }, - interactionSource = startTimeTextFieldInteractionSource, + interactionSource = startTimeIs, isError = startTime >= endTime, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) ) OutlinedTextField( value = formatTime(endTime), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.end_time)) }, - interactionSource = endTimeTextFieldInteractionSource, + interactionSource = endTimeIs, isError = startTime >= endTime, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) ) if(target == NetworkStatsTarget.Uid || target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState) ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Uid, - { activeTextField = if(it) NetworkStatsActiveTextField.Uid else NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Uid, + { menu = if(it) NetworkStatsMenu.Uid else NetworkStatsMenu.None } ) { - var uidText by rememberSaveable { mutableStateOf(context.getString(NetworkStatsUID.All.strRes)) } + var uidText by rememberSaveable { mutableStateOf(context.getString(NetworkStatsUID.All.text)) } var readOnly by rememberSaveable { mutableStateOf(true) } - if (!readOnly && uidText.toIntOrNull() != null) uid = uidText.toInt() if (VERSION.SDK_INT >= 24) LaunchedEffect(Unit) { val pkg = chosenPackage.receive() - try { - uid = context.packageManager.getPackageUid(pkg, 0) - uidText = "$uid ($pkg)" - } catch(_: NameNotFoundException) { - context.showOperationResultToast(false) - } + uid = getUid(pkg) + uidText = "$uid ($pkg)" } OutlinedTextField( - value = uidText, onValueChange = { if(!readOnly) uidText = it }, readOnly = readOnly, - label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Uid) }, + uidText, + { + uidText = it + it.toIntOrNull()?.let { num -> uid = num } + }, + readOnly = readOnly, label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Uid) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = !readOnly && uidText.toIntOrNull() == null, modifier = Modifier .menuAnchor(if(readOnly) MenuAnchorType.PrimaryNotEditable else MenuAnchorType.PrimaryEditable) .fillMaxWidth().padding(bottom = 4.dp) ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Uid, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Uid, { menu = NetworkStatsMenu.None } ) { NetworkStatsUID.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.strRes)) }, + text = { Text(stringResource(it.text)) }, onClick = { uid = it.uid readOnly = true - uidText = context.getString(it.strRes) - activeTextField = NetworkStatsActiveTextField.None + uidText = context.getString(it.text) + menu = NetworkStatsMenu.None } ) } @@ -1090,7 +1103,7 @@ fun NetworkStatsScreen( text = { Text(stringResource(R.string.choose_an_app)) }, onClick = { readOnly = true - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None onChoosePackage() } ) @@ -1099,30 +1112,34 @@ fun NetworkStatsScreen( onClick = { readOnly = false uidText = "" - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) } } - if(VERSION.SDK_INT >= 24 && (target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState)) + if (VERSION.SDK_INT >= 24 && (target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState)) ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Tag, - { activeTextField = if(it) NetworkStatsActiveTextField.Tag else NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Tag, + { menu = if(it) NetworkStatsMenu.Tag else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) ) { var tagText by rememberSaveable { mutableStateOf(context.getString(R.string.all)) } var readOnly by rememberSaveable { mutableStateOf(true) } - if(!readOnly && tagText.toIntOrNull() != null) tag = tagText.toInt() OutlinedTextField( - value = tagText, onValueChange = { if(!readOnly) tagText = it }, readOnly = readOnly, - label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Tag) }, + tagText, + { + tagText = it + it.toIntOrNull()?.let { num -> tag = num } + }, + readOnly = readOnly, label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Tag) }, isError = !readOnly && tagText.toIntOrNull() == null, modifier = Modifier .menuAnchor(if(readOnly) MenuAnchorType.PrimaryNotEditable else MenuAnchorType.PrimaryEditable) .fillMaxWidth().padding(bottom = 4.dp) ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Tag, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Tag, { menu = NetworkStatsMenu.None } ) { DropdownMenuItem( text = { Text(stringResource(R.string.all)) }, @@ -1130,7 +1147,7 @@ fun NetworkStatsScreen( tag = NetworkStats.Bucket.TAG_NONE tagText = context.getString(R.string.all) readOnly = true - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) DropdownMenuItem( @@ -1138,36 +1155,31 @@ fun NetworkStatsScreen( onClick = { tagText = "" readOnly = false - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) } } - if(VERSION.SDK_INT >= 28 && target == NetworkStatsTarget.UidTagState) - ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.State, - { activeTextField = if(it) NetworkStatsActiveTextField.State else NetworkStatsActiveTextField.None } + if (VERSION.SDK_INT >= 28 && target == NetworkStatsTarget.UidTagState) ExposedDropdownMenuBox( + menu == NetworkStatsMenu.State, + { menu = if(it) NetworkStatsMenu.State else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) ) { - val textMap = mapOf( - NetworkStats.Bucket.STATE_ALL to R.string.all, - NetworkStats.Bucket.STATE_DEFAULT to R.string.default_str, - NetworkStats.Bucket.STATE_FOREGROUND to R.string.foreground - ) OutlinedTextField( - value = stringResource(textMap[state]!!), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.State) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(state.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.State) } ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.State, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.State, { menu = NetworkStatsMenu.None } ) { - textMap.forEach { + NetworkStatsState.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.value)) }, - onClick = { - state = it.key - activeTextField = NetworkStatsActiveTextField.None + { Text(stringResource(it.text)) }, + { + state = it + menu = NetworkStatsMenu.None } ) } @@ -1177,47 +1189,12 @@ fun NetworkStatsScreen( Button( onClick = { querying = true - coroutine.launch(Dispatchers.IO) { - val buckets = try { - @Suppress("NewApi") if(queryType == 1) { - if(target == NetworkStatsTarget.Device) - listOf(nsm.querySummaryForDevice(networkType.type, subscriberId, startTime, endTime)) - else listOf(nsm.querySummaryForUser(networkType.type, subscriberId, startTime, endTime)) - } else { - if(target == NetworkStatsTarget.Uid) - nsm.queryDetailsForUid(networkType.type, subscriberId, startTime, endTime, uid).toBucketList() - else if(target == NetworkStatsTarget.UidTag) - nsm.queryDetailsForUidTag(networkType.type, subscriberId, startTime, endTime, uid, tag).toBucketList() - else nsm.queryDetailsForUidTagState(networkType.type, subscriberId, startTime, endTime, uid, tag, state).toBucketList() - } - } catch(e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - querying = false - errorMessage = e.message - } - return@launch - }.filterNot { it == null } - if(buckets.isEmpty()) { - withContext(Dispatchers.Main) { - querying = false - context.showOperationResultToast(false) - } - } else { - val stats = buckets.map { - NetworkStatsViewer.Data( - it.rxBytes, it.rxPackets, it.txBytes, it.txPackets, - it.uid, it.state, it.startTimeStamp, it.endTimeStamp, - if(VERSION.SDK_INT >= 24) it.tag else null, - if(VERSION.SDK_INT >= 24) it.roaming else null, - if(VERSION.SDK_INT >= 26) it.metered else null - ) - } - withContext(Dispatchers.Main) { - querying = false - onNavigateToViewer(NetworkStatsViewer(stats)) - } - } + queryStats(QueryNetworkStatsParams( + type, target, networkType, startTime, endTime, uid, tag, state + )) { + querying = false + errorMessage = it + if (it == null) onNavigateToViewer() } }, enabled = !querying, @@ -1225,21 +1202,21 @@ fun NetworkStatsScreen( ) { Text(stringResource(R.string.query)) } - if(activeTextField == NetworkStatsActiveTextField.StartTime || activeTextField == NetworkStatsActiveTextField.EndTime) { - val datePickerState = rememberDatePickerState(if(activeTextField == NetworkStatsActiveTextField.StartTime) startTime else endTime) + if (menu == NetworkStatsMenu.StartTime || menu == NetworkStatsMenu.EndTime) { + val datePickerState = rememberDatePickerState(if (menu == NetworkStatsMenu.StartTime) startTime else endTime) DatePickerDialog( - onDismissRequest = { activeTextField = NetworkStatsActiveTextField.None }, + onDismissRequest = { menu = NetworkStatsMenu.None }, dismissButton = { - TextButton(onClick = { activeTextField = NetworkStatsActiveTextField.None }) { + TextButton(onClick = { menu = NetworkStatsMenu.None }) { Text(stringResource(R.string.cancel)) } }, confirmButton = { TextButton( onClick = { - if(activeTextField == NetworkStatsActiveTextField.StartTime) startTime = datePickerState.selectedDateMillis!! + if (menu == NetworkStatsMenu.StartTime) startTime = datePickerState.selectedDateMillis!! else endTime = datePickerState.selectedDateMillis!! - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None }, enabled = datePickerState.selectedDateMillis != null ) { @@ -1254,38 +1231,41 @@ fun NetworkStatsScreen( ErrorDialog(errorMessage) { errorMessage = null } } -@Serializable -data class NetworkStatsViewer( - val stats: List -) { - @Serializable - data class Data( - val rxBytes: Long, - val rxPackets: Long, - val txBytes: Long, - val txPackets: Long, - val uid: Int, - val state: Int, - val startTime: Long, - val endTime: Long, - val tag: Int?, - val roaming: Int?, - val metered: Int? - ) -} +data class NetworkStatsData( + val rxBytes: Long, + val rxPackets: Long, + val txBytes: Long, + val txPackets: Long, + val uid: Int, + val state: Int, + val startTime: Long, + val endTime: Long, + val tag: Int?, + val roaming: Int?, + val metered: Int? +) + +@Serializable object NetworkStatsViewer @RequiresApi(23) @Composable -fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) { +fun NetworkStatsViewerScreen( + data: List, clearData: () -> Unit, onNavigateUp: () -> Unit +) { var index by remember { mutableIntStateOf(0) } - val size = nsv.stats.size + val size = data.size val ps = rememberPagerState { size } index = ps.currentPage val coroutine = rememberCoroutineScope() + DisposableEffect(Unit) { + onDispose { + clearData() + } + } MySmallTitleScaffold(R.string.network_stats, onNavigateUp, 0.dp) { if(size > 1) Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp) + Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { IconButton( onClick = { @@ -1295,7 +1275,7 @@ fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) }, enabled = index > 0 ) { - Icon(imageVector = Icons.AutoMirrored.Default.KeyboardArrowLeft, contentDescription = null) + Icon(Icons.AutoMirrored.Default.KeyboardArrowLeft, null) } Text("${index + 1} / $size", modifier = Modifier.padding(horizontal = 8.dp)) IconButton( @@ -1306,62 +1286,57 @@ fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) }, enabled = index < size - 1 ) { - Icon(imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, contentDescription = null) + Icon(Icons.AutoMirrored.Default.KeyboardArrowRight, null) } } HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page -> - val data = nsv.stats[page] + val item = data[index] Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { - Text(formatTime(data.startTime) + "\n~\n" + formatTime(data.endTime), + Text(formatTime(item.startTime) + "\n~\n" + formatTime(item.endTime), Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) Spacer(Modifier.height(5.dp)) - val txBytes = data.txBytes - Text(stringResource(R.string.transmitted), style = typography.titleLarge) + val txBytes = item.txBytes + Text(stringResource(R.string.transmitted), style = MaterialTheme.typography.titleMedium) Column(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) { Text("$txBytes bytes (${formatFileSize(txBytes)})") - Text(data.txPackets.toString() + " packets") + Text(item.txPackets.toString() + " packets") } - val rxBytes = data.rxBytes - Text(stringResource(R.string.received), style = typography.titleLarge) + val rxBytes = item.rxBytes + Text(stringResource(R.string.received), style = MaterialTheme.typography.titleMedium) Column(modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) { Text("$rxBytes bytes (${formatFileSize(rxBytes)})") - Text(data.rxPackets.toString() + " packets") + Text(item.rxPackets.toString() + " packets") } Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(data.state) { - NetworkStats.Bucket.STATE_ALL -> R.string.all - NetworkStats.Bucket.STATE_DEFAULT -> R.string.default_str - NetworkStats.Bucket.STATE_FOREGROUND -> R.string.foreground - else -> R.string.unknown - } - Text(stringResource(R.string.state), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + val text = NetworkStatsState.entries.find { it.id == item.state }!!.text + Text(stringResource(R.string.state), Modifier.padding(end = 8.dp), style = MaterialTheme.typography.titleMedium) Text(stringResource(text)) } if(VERSION.SDK_INT >= 24) { Row(verticalAlignment = Alignment.CenterVertically) { - val tag = data.tag - Text(stringResource(R.string.tag), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + val tag = item.tag + Text(stringResource(R.string.tag), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) Text(if(tag == NetworkStats.Bucket.TAG_NONE) stringResource(R.string.all) else tag.toString()) } Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(data.roaming) { + val text = when(item.roaming) { NetworkStats.Bucket.ROAMING_ALL -> R.string.all NetworkStats.Bucket.ROAMING_YES -> R.string.yes NetworkStats.Bucket.ROAMING_NO -> R.string.no else -> R.string.unknown } - Text(stringResource(R.string.roaming), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + Text(stringResource(R.string.roaming), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) Text(stringResource(text)) } } if(VERSION.SDK_INT >= 26) Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(data.metered) { + val text = when(item.metered) { NetworkStats.Bucket.METERED_ALL -> R.string.all NetworkStats.Bucket.METERED_YES -> R.string.yes NetworkStats.Bucket.METERED_NO -> R.string.no else -> R.string.unknown } - Text(stringResource(R.string.metered), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + Text(stringResource(R.string.metered), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) Text(stringResource(text)) } } @@ -1369,76 +1344,52 @@ fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) } } +@RequiresApi(29) +enum class PrivateDnsMode(val id: Int, val text: Int) { + Off(DevicePolicyManager.PRIVATE_DNS_MODE_OFF, R.string.off), + Opportunistic(DevicePolicyManager.PRIVATE_DNS_MODE_OPPORTUNISTIC, R.string.automatic), + Host(DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME, R.string.enabled) +} + +data class PrivateDnsConfiguration(val mode: Int, val host: String) + @Serializable object PrivateDns @RequiresApi(29) @Composable -fun PrivateDnsScreen(onNavigateUp: () -> Unit) { +fun PrivateDnsScreen( + getPrivateDns: () -> PrivateDnsConfiguration, + setPrivateDns: (PrivateDnsConfiguration) -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - MyScaffold(R.string.private_dns, onNavigateUp) { - fun getDnsStatus(code: Int) = when (code) { - PRIVATE_DNS_MODE_UNKNOWN -> R.string.unknown - PRIVATE_DNS_MODE_OFF -> R.string.disabled - PRIVATE_DNS_MODE_OPPORTUNISTIC -> R.string.auto - PRIVATE_DNS_MODE_PROVIDER_HOSTNAME -> R.string.dns_provide_hostname - else -> R.string.place_holder + var mode by remember { mutableStateOf(PrivateDnsMode.Off) } + var inputHost by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + val conf = getPrivateDns() + mode = PrivateDnsMode.entries.find { it.id == conf.mode } ?: PrivateDnsMode.Off + inputHost = conf.host + } + MyScaffold(R.string.private_dns, onNavigateUp, 0.dp) { + PrivateDnsMode.entries.forEach { + FullWidthRadioButtonItem(it.text, mode == it) { mode = it } } - fun getOperationResult(code: Int) = when (code) { - PRIVATE_DNS_SET_NO_ERROR -> R.string.success - PRIVATE_DNS_SET_ERROR_HOST_NOT_SERVING -> R.string.host_not_serving_dns_tls - PRIVATE_DNS_SET_ERROR_FAILURE_SETTING -> R.string.failed - else -> R.string.place_holder - } - var dnsMode by remember { mutableIntStateOf(Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR)) } - Spacer(Modifier.padding(vertical = 5.dp)) - Text(stringResource(R.string.current_state, stringResource(getDnsStatus(dnsMode)))) - AnimatedVisibility(Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR) != PRIVATE_DNS_MODE_OPPORTUNISTIC) { - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - val result = Privilege.DPM.setGlobalPrivateDnsModeOpportunistic(Privilege.DAR) - context.popToast(getOperationResult(result)) - dnsMode = Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.set_to_opportunistic)) - } - } - Notes(R.string.info_private_dns_mode_oppertunistic) - Spacer(Modifier.padding(vertical = 10.dp)) - var inputHost by remember { mutableStateOf(Privilege.DPM.getGlobalPrivateDnsHost(Privilege.DAR) ?: "") } - OutlinedTextField( - value = inputHost, - onValueChange = { inputHost=it }, + if (mode == PrivateDnsMode.Host) OutlinedTextField( + inputHost, { inputHost=it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), label = { Text(stringResource(R.string.dns_hostname)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) - Spacer(Modifier.padding(vertical = 3.dp)) Button( onClick = { focusMgr.clearFocus() - try { - val result = Privilege.DPM.setGlobalPrivateDnsModeSpecifiedHost(Privilege.DAR, inputHost) - context.popToast(getOperationResult(result)) - } catch(e: IllegalArgumentException) { - e.printStackTrace() - context.popToast(R.string.invalid_hostname) - } catch(e: SecurityException) { - e.printStackTrace() - context.popToast(R.string.security_exception) - } finally { - dnsMode = Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR) - } + val result = setPrivateDns(PrivateDnsConfiguration(mode.id, inputHost)) + context.showOperationResultToast(result) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) ) { - Text(stringResource(R.string.set_dns_host)) + Text(stringResource(R.string.apply)) } - Notes(R.string.info_set_private_dns_host) } } @@ -1447,47 +1398,35 @@ fun PrivateDnsScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable fun AlwaysOnVpnPackageScreen( + getPackage: () -> String, getLockdown: () -> Boolean, setConf: (String?, Boolean) -> Int, chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var lockdown by rememberSaveable { mutableStateOf(false) } - var pkgName by rememberSaveable { mutableStateOf("") } - fun refresh() { - pkgName = Privilege.DPM.getAlwaysOnVpnPackage(Privilege.DAR) ?: "" - } + var lockdown by rememberSaveable { mutableStateOf(getLockdown()) } + var pkgName by rememberSaveable { mutableStateOf(getPackage()) } LaunchedEffect(Unit) { - refresh() pkgName = chosenPackage.receive() } - val setAlwaysOnVpn: (String?, Boolean)->Boolean = { vpnPkg: String?, lockdownEnabled: Boolean -> - try { - Privilege.DPM.setAlwaysOnVpnPackage(Privilege.DAR, vpnPkg, lockdownEnabled) - context.showOperationResultToast(true) - true - } catch(e: UnsupportedOperationException) { - e.printStackTrace() - context.popToast(R.string.unsupported) - false - } catch(e: NameNotFoundException) { - e.printStackTrace() - context.popToast(R.string.not_installed) - false - } - } MyScaffold(R.string.always_on_vpn, onNavigateUp) { PackageNameTextField(pkgName, onChoosePackage, Modifier.padding(vertical = 4.dp)) { pkgName = it } SwitchItem(R.string.enable_lockdown, state = lockdown, onCheckedChange = { lockdown = it }, padding = false) Spacer(Modifier.padding(vertical = 5.dp)) Button( - onClick = { if(setAlwaysOnVpn(pkgName, lockdown)) refresh() }, + onClick = { + context.popToast(setConf(pkgName, lockdown)) + }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.apply)) } Spacer(Modifier.padding(vertical = 5.dp)) Button( - onClick = { if(setAlwaysOnVpn(null, false)) refresh() }, + onClick = { + context.popToast(setConf(null, false)) + pkgName = "" + lockdown = false + }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.clear_current_config)) @@ -1496,100 +1435,86 @@ fun AlwaysOnVpnPackageScreen( } } +enum class ProxyType(val text: Int) { + Off(R.string.proxy_type_off), Pac(R.string.proxy_type_pac), Direct(R.string.proxy_type_direct) +} + +data class RecommendedProxyConf( + val type: ProxyType, val url: String, val host: String, val specifyPort: Boolean, + val port: Int, val exclude: List +) + @Serializable object RecommendedGlobalProxy @Composable -fun RecommendedGlobalProxyScreen(onNavigateUp: () -> Unit) { +fun RecommendedGlobalProxyScreen( + setProxy: (RecommendedProxyConf) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var proxyType by remember { mutableIntStateOf(0) } - var proxyUri by remember { mutableStateOf("") } + var type by remember { mutableStateOf(ProxyType.Off) } + var pacUrl by remember { mutableStateOf("") } var specifyPort by remember { mutableStateOf(false) } - var proxyPort by remember { mutableStateOf("") } + var host by remember { mutableStateOf("") } + var port by remember { mutableStateOf("") } var exclList by remember { mutableStateOf("") } - MyScaffold(R.string.recommended_global_proxy, onNavigateUp) { - RadioButtonItem(R.string.proxy_type_off, proxyType == 0) { proxyType = 0 } - RadioButtonItem(R.string.proxy_type_pac, proxyType == 1) { proxyType = 1 } - RadioButtonItem(R.string.proxy_type_direct, proxyType == 2) { proxyType = 2 } - AnimatedVisibility(proxyType != 0) { + MyScaffold(R.string.recommended_global_proxy, onNavigateUp, 0.dp) { + ProxyType.entries.forEach { + FullWidthRadioButtonItem(it.text, type == it) { type = it } + } + AnimatedVisibility(type == ProxyType.Pac) { OutlinedTextField( - value = proxyUri, - onValueChange = { proxyUri = it }, - label = { Text(if(proxyType == 1) "URL" else "Host") }, + pacUrl, { pacUrl = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), + label = { Text("URL") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp) + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) } - AnimatedVisibility(proxyType == 1 && VERSION.SDK_INT >= 30) { - Box(modifier = Modifier.padding(top = 2.dp)) { - CheckBoxItem(R.string.specify_port, specifyPort) { specifyPort = it } - } - } - AnimatedVisibility((proxyType == 1 && specifyPort && VERSION.SDK_INT >= 30) || proxyType == 2) { + AnimatedVisibility(type == ProxyType.Direct) { OutlinedTextField( - value = proxyPort, - onValueChange = { proxyPort = it }, + host, { host = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), + label = { Text("Host") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) + ) + } + AnimatedVisibility(type == ProxyType.Pac && VERSION.SDK_INT >= 30) { + FullWidthCheckBoxItem(R.string.specify_port, specifyPort) { specifyPort = it } + } + AnimatedVisibility((specifyPort && VERSION.SDK_INT >= 30) || type == ProxyType.Direct) { + OutlinedTextField( + port, { port = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), label = { Text(stringResource(R.string.port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp) + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) } - AnimatedVisibility(proxyType == 2) { + AnimatedVisibility(type == ProxyType.Direct) { OutlinedTextField( - value = exclList, - onValueChange = { exclList = it }, + exclList, { exclList = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), label = { Text(stringResource(R.string.excluded_hosts)) }, maxLines = 5, minLines = 2, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusMgr.clearFocus() }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp) + keyboardActions = KeyboardActions { focusMgr.clearFocus() } ) } Button( onClick = { - if(proxyType == 0) { - Privilege.DPM.setRecommendedGlobalProxy(Privilege.DAR, null) - context.showOperationResultToast(true) - return@Button - } - if(proxyUri == "") { - context.popToast(R.string.invalid_config) - return@Button - } - val uri = proxyUri.toUri() - val port: Int - try { - port = proxyPort.toInt() - } catch(e: NumberFormatException) { - e.printStackTrace() - context.popToast(R.string.invalid_config) - return@Button - } - val proxyInfo = - if(proxyType == 1) { - if(specifyPort && VERSION.SDK_INT >= 30) { - ProxyInfo.buildPacProxy(uri, port) - } else { - ProxyInfo.buildPacProxy(uri) - } - } else { - ProxyInfo.buildDirectProxy(proxyUri, port, exclList.lines()) - } - if(VERSION.SDK_INT >= 30 && !proxyInfo.isValid) { - context.popToast(R.string.invalid_config) - return@Button - } - Privilege.DPM.setRecommendedGlobalProxy(Privilege.DAR, proxyInfo) + setProxy(RecommendedProxyConf( + type, pacUrl, host, specifyPort, port.toIntOrNull() ?: 0, + exclList.lines().filter { it.isNotBlank() } + )) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), + enabled = type == ProxyType.Off || + (type == ProxyType.Pac && pacUrl.isNotBlank() && (!specifyPort || port.toIntOrNull() != null)) || + (type == ProxyType.Direct && port.toIntOrNull() != null) ) { Text(stringResource(R.string.apply)) } - Notes(R.string.info_recommended_global_proxy) + Notes(R.string.info_recommended_global_proxy, HorizontalPadding) } } @@ -1690,19 +1615,20 @@ fun WifiAuthKeypairScreen(onNavigateUp: () -> Unit) { @RequiresApi(33) @Composable -fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddPreferentialNetworkServiceConfig) -> Unit) { - var masterEnabled by remember { mutableStateOf(false) } - val configs = remember { mutableStateListOf() } - fun refresh() { - masterEnabled = Privilege.DPM.isPreferentialNetworkServiceEnabled - configs.clear() - configs.addAll(Privilege.DPM.preferentialNetworkServiceConfigs) +fun PreferentialNetworkServiceScreen( + getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, + pnsConfigs: StateFlow>, getConfigs: () -> Unit, + onNavigateUp: () -> Unit, onNavigate: (AddPreferentialNetworkServiceConfig) -> Unit +) { + var masterEnabled by remember { mutableStateOf(getEnabled()) } + val configs by pnsConfigs.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + getConfigs() } - LaunchedEffect(Unit) { refresh() } MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp, 0.dp) { SwitchItem(R.string.enabled, state = masterEnabled, onCheckedChange = { - Privilege.DPM.isPreferentialNetworkServiceEnabled = it - refresh() + setEnabled(it) + masterEnabled = it }) Spacer(Modifier.padding(vertical = 4.dp)) configs.forEachIndexed { index, config -> @@ -1710,19 +1636,9 @@ fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddP Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Column { - Text(index.toString()) - } + Text(config.id.toString()) IconButton({ - onNavigate(AddPreferentialNetworkServiceConfig( - enabled = config.isEnabled, - id = config.networkId, - allowFallback = config.isFallbackToDefaultConnectionAllowed, - blockNonMatching = if(VERSION.SDK_INT >= 34) config.shouldBlockNonMatchingNetworks() else false, - excludedUids = config.excludedUids.toList(), - includedUids = config.includedUids.toList(), - index = index - )) + onNavigate(AddPreferentialNetworkServiceConfig(index)) }) { Icon(Icons.Default.Edit, stringResource(R.string.edit)) } @@ -1731,7 +1647,7 @@ fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddP Row( Modifier.fillMaxWidth() .padding(top = 4.dp) - .clickable { onNavigate(AddPreferentialNetworkServiceConfig()) } + .clickable { onNavigate(AddPreferentialNetworkServiceConfig(-1)) } .padding(horizontal = 8.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -1741,38 +1657,51 @@ fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddP } } -@Serializable data class AddPreferentialNetworkServiceConfig( +data class PreferentialNetworkServiceInfo( val enabled: Boolean = true, val id: Int = -1, val allowFallback: Boolean = false, val blockNonMatching: Boolean = false, val excludedUids: List = emptyList(), - val includedUids: List = emptyList(), - val index: Int = -1 + val includedUids: List = emptyList() ) +@Serializable +data class AddPreferentialNetworkServiceConfig(val index: Int) + +@OptIn(ExperimentalMaterial3Api::class) @RequiresApi(33) @Composable -fun AddPreferentialNetworkServiceConfigScreen(route: AddPreferentialNetworkServiceConfig,onNavigateUp: () -> Unit) { - val updateMode = route.index != -1 - val context = LocalContext.current - var enabled by remember { mutableStateOf(route.enabled) } - var id by remember { mutableIntStateOf(route.id) } - var allowFallback by remember { mutableStateOf(route.allowFallback) } - var blockNonMatching by remember { mutableStateOf(route.blockNonMatching) } - var excludedUids by remember { mutableStateOf(route.excludedUids.joinToString("\n")) } - var includedUids by remember { mutableStateOf(route.includedUids.joinToString("\n")) } +fun AddPreferentialNetworkServiceConfigScreen( + origin: PreferentialNetworkServiceInfo, + setConfig: (PreferentialNetworkServiceInfo, Boolean) -> Unit, onNavigateUp: () -> Unit +) { + val updateMode = origin.id != -1 + var enabled by remember { mutableStateOf(origin.enabled) } + var id by remember { mutableIntStateOf(origin.id) } + var allowFallback by remember { mutableStateOf(origin.allowFallback) } + var blockNonMatching by remember { mutableStateOf(origin.blockNonMatching) } + var excludedUids by remember { mutableStateOf(origin.excludedUids.joinToString("\n")) } + var includedUids by remember { mutableStateOf(origin.includedUids.joinToString("\n")) } + var dropdown by remember { mutableStateOf(false) } MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp) { SwitchItem(title = R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false) - AnimatedVisibility(enabled) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("ID", Modifier.padding(end = 8.dp), style = typography.titleLarge) - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - for(i in 1..5) { - SegmentedButton(id == i, { id = i }, SegmentedButtonDefaults.itemShape(i - 1, 5)) { - Text(i.toString()) + ExposedDropdownMenuBox(dropdown, { dropdown = it }) { + OutlinedTextField( + if (id == -1) "" else id.toString(), {}, + Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("id") }, + trailingIcon = { ExpandExposedTextFieldIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + for (i in 1..5) { + DropdownMenuItem( + { Text(i.toString()) }, + { + id = i + dropdown = false } - } + ) } } } @@ -1806,39 +1735,22 @@ fun AddPreferentialNetworkServiceConfigScreen(route: AddPreferentialNetworkServi ) Button( onClick = { - try { - val config = PreferentialNetworkServiceConfig.Builder().apply { - setEnabled(enabled) - if(enabled) setNetworkId(id) - setFallbackToDefaultConnectionAllowed(allowFallback) - setExcludedUids(excludedUids.lines().filter { it.isNotBlank() }.map { it.toInt() }.toIntArray()) - setIncludedUids(includedUids.lines().filter { it.isNotBlank() }.map { it.toInt() }.toIntArray()) - if(VERSION.SDK_INT >= 34) setShouldBlockNonMatchingNetworks(blockNonMatching) - }.build() - val configs = Privilege.DPM.preferentialNetworkServiceConfigs - if(updateMode) configs[route.index] = config - else configs += config - Privilege.DPM.preferentialNetworkServiceConfigs = configs - onNavigateUp() - } catch(e: Exception) { - context.showOperationResultToast(false) - e.printStackTrace() - } + setConfig(PreferentialNetworkServiceInfo( + enabled, id, allowFallback, blockNonMatching, + excludedUids.lines().mapNotNull { it.toIntOrNull() }, + includedUids.lines().mapNotNull { it.toIntOrNull() } + ), true) + onNavigateUp() }, - enabled = includedUidsLegal && excludedUidsLegal, + enabled = includedUidsLegal && excludedUidsLegal && id in 1..5, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) ) { Text(stringResource(if(updateMode) R.string.update else R.string.add)) } if(updateMode) Button( onClick = { - try { - Privilege.DPM.preferentialNetworkServiceConfigs = Privilege.DPM.preferentialNetworkServiceConfigs.drop(route.index) - onNavigateUp() - } catch(e: Exception) { - context.showOperationResultToast(false) - e.printStackTrace() - } + setConfig(origin, false) + onNavigateUp() }, colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError), modifier = Modifier.fillMaxWidth() @@ -1852,384 +1764,403 @@ fun AddPreferentialNetworkServiceConfigScreen(route: AddPreferentialNetworkServi @RequiresApi(28) @Composable -fun OverrideApnScreen(onNavigateUp: () -> Unit, onNavigateToAddSetting: (Bundle) -> Unit) { - var enabled by remember { mutableStateOf(false) } - val settings = remember { mutableStateListOf() } - fun refresh() { - enabled = Privilege.DPM.isOverrideApnEnabled(Privilege.DAR) - settings.clear() - settings.addAll(Privilege.DPM.getOverrideApns(Privilege.DAR)) - } - LaunchedEffect(Unit) { refresh() } +fun OverrideApnScreen( + apnConfigs: StateFlow>, getConfigs: () -> Unit, getEnabled: () -> Boolean, + setEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit, onNavigateToAddSetting: (Int) -> Unit +) { + var enabled by remember { mutableStateOf(getEnabled()) } + val configs by apnConfigs.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getConfigs() } MyScaffold(R.string.override_apn, onNavigateUp, 0.dp) { SwitchItem( - R.string.enable, state = enabled, - onCheckedChange = { - Privilege.DPM.setOverrideApnsEnabled(Privilege.DAR, it) - refresh() + R.string.enable, enabled, + { + setEnabled(it) + enabled = it } ) - settings.forEach { + configs.forEach { Row( Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Column { - Text(it.id.toString()) - Text(it.apnName.toString(), color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium) - Text(it.entryName.toString(), color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium) + Row { + Text(it.id.toString(), Modifier.padding(end = 8.dp)) + Column { + Text(it.name) + Text(it.apn, Modifier.alpha(0.7F), style = MaterialTheme.typography.bodyMedium) + } } IconButton({ - onNavigateToAddSetting(bundleOf("setting" to it)) + onNavigateToAddSetting(it.id) }) { - Icon(Icons.Default.Edit, null) + Icon(Icons.Outlined.Edit, null) } } } Row( Modifier.fillMaxWidth().clickable { - onNavigateToAddSetting(Bundle()) + onNavigateToAddSetting(-1) }.padding(horizontal = 8.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Default.Add, null, Modifier.padding(horizontal = 8.dp)) - Text(stringResource(R.string.add_config), style = typography.labelLarge) + Text(stringResource(R.string.add_config), style = MaterialTheme.typography.labelLarge) } } } -private data class ApnType(val id: Int, val name: String, val requiresApi: Int = 0) +enum class ApnMenu { + None, ApnType, AuthType, Protocol, RoamingProtocol, NetworkType, MvnoType, OperatorNumeric +} + +data class ApnType(val id: Int, val name: String, val requiresApi: Int = 0) @SuppressLint("InlinedApi") -private val apnTypes = listOf( - ApnType(ApnSetting.TYPE_DEFAULT, "Default"), ApnType(ApnSetting.TYPE_MMS, "MMS"), ApnType(ApnSetting.TYPE_SUPL, "SUPL"), - ApnType(ApnSetting.TYPE_DUN, "DUN"), ApnType(ApnSetting.TYPE_HIPRI, "HiPri"), ApnType(ApnSetting.TYPE_FOTA, "FOTA"), - ApnType(ApnSetting.TYPE_IMS, "IMS"), ApnType(ApnSetting.TYPE_CBS, "CBS"), ApnType(ApnSetting.TYPE_IA, "IA"), - ApnType(ApnSetting.TYPE_EMERGENCY, "Emergency"), ApnType(ApnSetting.TYPE_MCX, "MCX", 29), ApnType(ApnSetting.TYPE_XCAP, "XCAP", 30), - ApnType(ApnSetting.TYPE_BIP, "BIP", 31), ApnType(ApnSetting.TYPE_VSIM, "VSIM", 31), ApnType(ApnSetting.TYPE_ENTERPRISE, "Enterprise", 33), - ApnType(ApnSetting.TYPE_RCS, "RCS", 35) // TODO: Adapt A16 later +val apnTypes = listOf( + ApnType(ApnSetting.TYPE_DEFAULT, "Default"), + ApnType(ApnSetting.TYPE_MMS, "MMS"), + ApnType(ApnSetting.TYPE_SUPL, "SUPL"), + ApnType(ApnSetting.TYPE_DUN, "DUN"), + ApnType(ApnSetting.TYPE_HIPRI, "HiPri"), + ApnType(ApnSetting.TYPE_FOTA, "FOTA"), + ApnType(ApnSetting.TYPE_IMS, "IMS"), + ApnType(ApnSetting.TYPE_CBS, "CBS"), + ApnType(ApnSetting.TYPE_IA, "IA"), + ApnType(ApnSetting.TYPE_EMERGENCY, "Emergency"), + ApnType(ApnSetting.TYPE_MCX, "MCX", 29), + ApnType(ApnSetting.TYPE_XCAP, "XCAP", 30), + ApnType(ApnSetting.TYPE_VSIM, "VSIM", 31), + ApnType(ApnSetting.TYPE_BIP, "BIP", 31), + ApnType(ApnSetting.TYPE_ENTERPRISE, "Enterprise", 33), + ApnType(ApnSetting.TYPE_RCS, "RCS", 35), + ApnType(ApnSetting.TYPE_OEM_PAID, "OEM paid"), + ApnType(ApnSetting.TYPE_OEM_PRIVATE, "OEM private") +).filter { VERSION.SDK_INT >= it.requiresApi } + +@Suppress("InlinedApi") +enum class ApnProtocol(val id: Int, val text: String, val requiresApi: Int = 28) { + Ip(ApnSetting.PROTOCOL_IP, "IPv4"), + Ipv6(ApnSetting.PROTOCOL_IPV6, "IPv6"), + Ipv4v6(ApnSetting.PROTOCOL_IPV4V6, "IPv4/IPv6"), + Ppp(ApnSetting.PROTOCOL_PPP, "PPP"), + NonIp(ApnSetting.PROTOCOL_NON_IP, "Non-IP", 29), + Unstructured(ApnSetting.PROTOCOL_UNSTRUCTURED, "Unstructured", 29) +} + +@Suppress("InlinedApi") +enum class ApnAuthType(val id: Int, val text: String) { + None(ApnSetting.AUTH_TYPE_NONE, "None"), + Pap(ApnSetting.AUTH_TYPE_PAP, "PAP"), + Chap(ApnSetting.AUTH_TYPE_CHAP, "CHAP"), + PapChap(ApnSetting.AUTH_TYPE_PAP_OR_CHAP, "PAP/CHAP") +} + +data class ApnNetworkType(val id: Int, val text: String, val requiresApi: Int = 0) +@Suppress("InlinedApi", "DEPRECATION") +val apnNetworkTypes = listOf( + ApnNetworkType(TelephonyManager.NETWORK_TYPE_LTE, "LTE"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPAP, "HSPA+"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPA, "HSPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSUPA, "HSUPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSDPA, "HSDPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_UMTS, "UMTS"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EDGE, "EDGE"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_GPRS, "GPRS"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EHRPD, "CDMA - eHRPD"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_B, "CDMA - EvDo rev. B"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_A, "CDMA - EvDo rev. A"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_0, "CDMA - EvDo rev. 0"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_1xRTT, "CDMA - 1xRTT"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_CDMA, "CDMA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_NR, "NR", 29) +).filter { VERSION.SDK_INT >= it.requiresApi } + +@Suppress("InlinedApi") +enum class ApnMvnoType(val id: Int, val text: String) { + SPN(ApnSetting.MVNO_TYPE_SPN, "SPN"), + IMSI(ApnSetting.MVNO_TYPE_IMSI, "IMSI"), + GID(ApnSetting.MVNO_TYPE_GID, "GID"), + ICCID(ApnSetting.MVNO_TYPE_ICCID, "ICCID") +} + +data class ApnConfig( + val enabled: Boolean, val name: String, val apn: String, val proxy: String, val port: Int?, + val username: String, val password: String, val apnType: Int, val mmsc: String, + val mmsProxy: String, val mmsPort: Int?, val authType: ApnAuthType, val protocol: ApnProtocol, + val roamingProtocol: ApnProtocol, val networkType: Int, val profileId: Int?, val carrierId: Int?, + val mtuV4: Int?, val mtuV6: Int?, val mvno: ApnMvnoType, val operatorNumeric: String, + val persistent: Boolean, val alwaysOn: Boolean, val id: Int = -1 ) -@Serializable object AddApnSetting +@Serializable data class AddApnSetting(val index: Int) -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @RequiresApi(28) @Composable -fun AddApnSettingScreen(origin: ApnSetting?, onNavigateUp: () -> Unit) { - val fm = LocalFocusManager.current - var dropdown by remember { mutableIntStateOf(0) } // 1:Auth type, 2:MVNO type, 3:Protocol, 4:Roaming protocol - var dialog by remember { mutableIntStateOf(0) } // 1:Proxy, 2:MMS proxy +fun AddApnSettingScreen( + setApn: (ApnConfig) -> Boolean, deleteApn: (Int) -> Boolean, origin: ApnConfig?, + onNavigateUp: () -> Unit +) { + val context = LocalContext.current + var menu by remember { mutableStateOf(ApnMenu.None) } var enabled by remember { mutableStateOf(true) } - var apnName by remember { mutableStateOf(origin?.apnName ?: "") } - var entryName by remember { mutableStateOf(origin?.entryName ?: "") } - var apnType by remember { mutableIntStateOf(origin?.apnTypeBitmask ?: 0) } - var profileId by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.profileId?.toString() ?: "" else "") } - var carrierId by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.carrierId?.toString() ?: "" else "") } - var authType by remember { mutableIntStateOf(origin?.authType ?: ApnSetting.AUTH_TYPE_NONE) } - var user by remember { mutableStateOf(origin?.user ?: "") } + var entryName by remember { mutableStateOf(origin?.name ?: "") } + var apnName by remember { mutableStateOf(origin?.apn ?: "") } + var apnType by remember { mutableIntStateOf(origin?.apnType ?: 0) } + var profileId by remember { mutableStateOf(origin?.profileId?.toString() ?: "") } + var carrierId by remember { mutableStateOf(origin?.carrierId?.toString() ?: "") } + var authType by remember { mutableStateOf(ApnAuthType.None) } + var user by remember { mutableStateOf(origin?.username ?: "") } var password by remember { mutableStateOf(origin?.password ?: "") } - var proxyAddress by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.proxyAddressAsString ?: "" else "") } - var proxyPort by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.proxyPort?.toString() ?: "" else "") } - var mmsProxyAddress by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.mmsProxyAddressAsString ?: "" else "") } - var mmsProxyPort by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.mmsProxyPort?.toString() ?: "" else "") } - var mmsc by remember { mutableStateOf(origin?.mmsc?.toString() ?: "") } - var mtuV4 by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.mtuV4?.toString() ?: "" else "") } - var mtuV6 by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.mtuV6?.toString() ?: "" else "") } - var mvnoType by remember { mutableIntStateOf(origin?.mvnoType ?: ApnSetting.MVNO_TYPE_SPN) } - var networkTypeBitmask by remember { mutableStateOf(origin?.networkTypeBitmask?.toString() ?: "") } + var proxy by remember { mutableStateOf(origin?.proxy ?: "") } + var port by remember { mutableStateOf(origin?.port?.toString() ?: "") } + var mmsProxy by remember { mutableStateOf(origin?.mmsProxy ?: "") } + var mmsPort by remember { mutableStateOf(origin?.mmsPort?.toString() ?: "") } + var mmsc by remember { mutableStateOf(origin?.mmsc ?: "") } + var mtuV4 by remember { mutableStateOf(origin?.mtuV4?.toString() ?: "") } + var mtuV6 by remember { mutableStateOf(origin?.mtuV6?.toString() ?: "") } + var mvnoType by remember { mutableStateOf(origin?.mvno ?: ApnMvnoType.SPN) } + var networkType by remember { mutableIntStateOf(origin?.networkType ?: 0) } var operatorNumeric by remember { mutableStateOf(origin?.operatorNumeric ?: "") } - var protocol by remember { mutableIntStateOf(origin?.protocol ?: ApnSetting.PROTOCOL_IP) } - var roamingProtocol by remember { mutableIntStateOf(origin?.roamingProtocol ?: ApnSetting.PROTOCOL_IP) } - var persistent by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.isPersistent == true else false) } - var alwaysOn by remember { mutableStateOf(VERSION.SDK_INT >= 35 && origin?.isAlwaysOn == true) } + var protocol by remember { mutableStateOf(origin?.protocol ?: ApnProtocol.Ip) } + var roamingProtocol by remember { mutableStateOf(origin?.roamingProtocol ?: ApnProtocol.Ip) } + var persistent by remember { mutableStateOf(origin?.persistent == true) } + var alwaysOn by remember { mutableStateOf(origin?.alwaysOn == true) } var errorMessage: String? by remember { mutableStateOf(null) } MySmallTitleScaffold(R.string.apn_setting, onNavigateUp) { - val protocolMap = mapOf( - ApnSetting.PROTOCOL_IP to "IPv4", ApnSetting.PROTOCOL_IPV6 to "IPv6", - ApnSetting.PROTOCOL_IPV4V6 to "IPv4/v6", ApnSetting.PROTOCOL_PPP to "PPP" - ).let { - if(VERSION.SDK_INT >= 29) { - it.plus(listOf(ApnSetting.PROTOCOL_NON_IP to "Non-IP", ApnSetting.PROTOCOL_UNSTRUCTURED to "Unstructured")) - } else it - } SwitchItem(R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false) OutlinedTextField( - apnName, { apnName = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.apn_name) + " (*)") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + entryName, { entryName = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + label = { Text("Name") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) OutlinedTextField( - entryName, { entryName = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text(stringResource(R.string.entry_name) + " (*)") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + apnName, { apnName = it }, Modifier.fillMaxWidth(), + label = { Text("APN") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - Text(stringResource(R.string.type) + " (*)", Modifier.padding(vertical = 4.dp), style = typography.titleLarge) - FlowRow(Modifier.padding(bottom = 4.dp)) { - apnTypes.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { - FilterChip( - apnType and it.id == it.id, { - apnType = if(apnType and it.id == it.id) apnType and (apnType xor it.id) else apnType or it.id - }, - { Text(it.name) }, Modifier.padding(horizontal = 4.dp) - ) - } - } - if(VERSION.SDK_INT >= 33) OutlinedTextField( - profileId, { profileId = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.profile_id)) }, isError = profileId.isNotEmpty() && profileId.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + OutlinedTextField( + proxy, { proxy = it }, Modifier.fillMaxWidth(), + label = { Text("Proxy") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - if(VERSION.SDK_INT >= 29) OutlinedTextField( - carrierId, { carrierId = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text(stringResource(R.string.carrier_id)) }, - isError = carrierId.isNotEmpty() && carrierId.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + OutlinedTextField( + port, { port = it }, Modifier.fillMaxWidth(), + label = { Text("Port") }, + isError = port.isNotEmpty() && port.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) ) - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 1) 180F else 0F) - val authTypeMap = mapOf( - ApnSetting.AUTH_TYPE_NONE to stringResource(R.string.none), ApnSetting.AUTH_TYPE_PAP to "PAP", - ApnSetting.AUTH_TYPE_CHAP to "CHAP", ApnSetting.AUTH_TYPE_PAP_OR_CHAP to "PAP/CHAP" - ) - Text(stringResource(R.string.auth_type)) - Row(Modifier.clickable { dropdown = 1 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(authTypeMap[authType]!!, Modifier.padding(2.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 1, { dropdown = 0 }) { - authTypeMap.forEach { - DropdownMenuItem({ Text(it.value) }, { authType = it.key; dropdown = 0 }) - } - } - } - } OutlinedTextField( user, { user = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.user)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + label = { Text("Username") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) OutlinedTextField( password, { password = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.password)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + label = { Text("Password") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - if(VERSION.SDK_INT >= 29) { - Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Column { - Text(stringResource(R.string.proxy), Modifier.padding(end = 8.dp)) - Text( - if(proxyAddress.isEmpty()) stringResource(R.string.none) else "$proxyAddress:$proxyPort", - color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium - ) + Box { + OutlinedTextField( + apnTypes.filter { apnType and it.id == it.id }.joinToString { it.name }, {}, + Modifier.fillMaxWidth(), + readOnly = true, label = { Text("APN type") } + ) + Box( + Modifier.matchParentSize().pointerInput(Unit) { + detectTapGestures(onTap = { menu = ApnMenu.ApnType }) } - TextButton({ dialog = 1 }) { Text(stringResource(R.string.edit)) } - } - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column { - Text(stringResource(R.string.mms_proxy), Modifier.padding(end = 8.dp)) - Text( - if(mmsProxyAddress.isEmpty()) stringResource(R.string.none) else "$mmsProxyAddress:$mmsProxyPort", - color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium - ) - } - TextButton({ dialog = 2 }) { Text(stringResource(R.string.edit)) } - } + ) } OutlinedTextField( mmsc, { mmsc = it }, Modifier.fillMaxWidth(), label = { Text("MMSC") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - if(VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween) { - val fr = remember { FocusRequester() } + OutlinedTextField( + mmsProxy, { mmsProxy = it }, Modifier.fillMaxWidth(), + label = { Text("MMS proxy") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + mmsPort, { mmsPort = it }, Modifier.fillMaxWidth(), + label = { Text("MMS port") }, + isError = mmsPort.isNotEmpty() && mmsPort.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + ) + ExposedDropdownMenuBox( + menu == ApnMenu.AuthType, { menu = if (it) ApnMenu.AuthType else ApnMenu.None } + ) { + OutlinedTextField( + authType.text, {}, Modifier.fillMaxWidth(), + label = { Text("Authentication type") }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.AuthType) } + ) + ExposedDropdownMenu(menu == ApnMenu.AuthType, { menu = ApnMenu.None }) { + ApnAuthType.entries.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + authType = it + menu = ApnMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == ApnMenu.Protocol, { menu = if (it) ApnMenu.Protocol else ApnMenu.None } + ) { + OutlinedTextField( + protocol.text, {}, Modifier.fillMaxWidth(), + label = { Text("APN protocol") }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.Protocol) } + ) + ExposedDropdownMenu(menu == ApnMenu.Protocol, { menu = ApnMenu.None }) { + ApnProtocol.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + protocol = it + menu = ApnMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == ApnMenu.RoamingProtocol, + { menu = if (it) ApnMenu.RoamingProtocol else ApnMenu.None } + ) { + OutlinedTextField( + roamingProtocol.text, {}, Modifier.fillMaxWidth(), + label = { Text("APN roaming protocol") }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.RoamingProtocol) } + ) + ExposedDropdownMenu(menu == ApnMenu.RoamingProtocol, { menu = ApnMenu.None }) { + ApnProtocol.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + roamingProtocol = it + menu = ApnMenu.None + } + ) + } + } + } + Box { + OutlinedTextField( + apnNetworkTypes.filter { networkType and it.id == it.id }.joinToString { it.text }, {}, + Modifier.fillMaxWidth(), + readOnly = true, label = { Text("Network type") } + ) + Box( + Modifier.matchParentSize().pointerInput(Unit) { + detectTapGestures(onTap = { menu = ApnMenu.NetworkType }) + } + ) + } + if (VERSION.SDK_INT >= 33) OutlinedTextField( + profileId, { profileId = it }, Modifier.fillMaxWidth(), + label = { Text("Profile id") }, + isError = profileId.isNotEmpty() && profileId.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 29) OutlinedTextField( + carrierId, { carrierId = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + label = { Text("Carrier id") }, + isError = carrierId.isNotEmpty() && carrierId.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween) { OutlinedTextField( mtuV4, { mtuV4 = it }, Modifier.fillMaxWidth(0.49F), label = { Text("MTU (IPv4)") }, isError = mtuV4.isNotEmpty() && mtuV4.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() } + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) ) OutlinedTextField( - mtuV6, { mtuV6 = it }, Modifier.focusRequester(fr).fillMaxWidth(0.96F), + mtuV6, { mtuV6 = it }, Modifier.fillMaxWidth(0.96F), label = { Text("MTU (IPv6)") }, isError = mtuV6.isNotEmpty() && mtuV6.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) ) } - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 2) 180F else 0F) - val mvnoTypeMap = mapOf( - ApnSetting.MVNO_TYPE_SPN to "SPM", ApnSetting.MVNO_TYPE_IMSI to "IMSI", - ApnSetting.MVNO_TYPE_GID to "GID", ApnSetting.MVNO_TYPE_ICCID to "ICCID" + ExposedDropdownMenuBox( + menu == ApnMenu.MvnoType, { menu = if (it) ApnMenu.MvnoType else ApnMenu.None } + ) { + OutlinedTextField( + mvnoType.text, {}, + Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("MVNO type") }, + trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.RoamingProtocol) } ) - Text(stringResource(R.string.mvno_type)) - Row(Modifier.clickable { dropdown = 2 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(mvnoTypeMap[mvnoType]!!, Modifier.padding(4.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 2, { dropdown = 0 }) { - mvnoTypeMap.forEach { - DropdownMenuItem({ Text(it.value) }, { mvnoType = it.key; dropdown = 0 }) - } + ExposedDropdownMenu(menu == ApnMenu.MvnoType, { menu = ApnMenu.None }) { + ApnMvnoType.entries.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + mvnoType = it + menu = ApnMenu.None + } + ) } } } - OutlinedTextField( - networkTypeBitmask, { networkTypeBitmask = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.network_type_bitmask)) }, - isError = networkTypeBitmask.isNotEmpty() && networkTypeBitmask.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) - OutlinedTextField( - operatorNumeric, { operatorNumeric = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text("Numeric operator ID") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 3) 180F else 0F) - Text(stringResource(R.string.protocol)) - Row(Modifier.clickable { dropdown = 3 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(protocolMap[protocol]!!, Modifier.padding(2.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 3, { dropdown = 0 }) { - protocolMap.forEach { - DropdownMenuItem({ Text(it.value) }, { protocol = it.key; dropdown = 0 }) - } + ExposedDropdownMenuBox( + menu == ApnMenu.OperatorNumeric, + { menu = if (it) ApnMenu.OperatorNumeric else ApnMenu.None } + ) { + OutlinedTextField( + operatorNumeric, {}, + Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("Numeric operator ID") } + ) + ExposedDropdownMenu(menu == ApnMenu.OperatorNumeric, { menu = ApnMenu.None }) { + listOf(Telephony.Carriers.MCC, Telephony.Carriers.MNC).forEach { + DropdownMenuItem({ Text(it) }, { + operatorNumeric = it + menu = ApnMenu.None + }) } } } - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 4) 180F else 0F) - Text(stringResource(R.string.roaming_protocol)) - Row(Modifier.clickable { dropdown = 4 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(protocolMap[roamingProtocol]!!, Modifier.padding(2.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 4, { dropdown = 0 }) { - protocolMap.forEach { - DropdownMenuItem({ Text(it.value) }, { roamingProtocol = it.key; dropdown = 0 }) - } - } - } - } - if(VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.persistent)) + if (VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Persistent") Switch(persistent, { persistent = it }) } Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.always_on)) + Text("Always on") Switch(alwaysOn, { alwaysOn = it }) } Button( { - try { - val setting = ApnSetting.Builder().apply { - setCarrierEnabled(enabled) - setApnName(apnName) - setEntryName(entryName) - setApnTypeBitmask(apnType) - setAuthType(authType) - setUser(user) - setPassword(password) - if(VERSION.SDK_INT >= 33) profileId.toIntOrNull()?.let { setProfileId(it) } - if(VERSION.SDK_INT >= 29) { - carrierId.toIntOrNull()?.let { setCarrierId(it) } - setProxyAddress(proxyAddress) - proxyPort.toIntOrNull()?.let { setProxyPort(it) } - setMmsProxyAddress(mmsProxyAddress) - mmsProxyPort.toIntOrNull()?.let { setMmsProxyPort(it) } - } - setMmsc(mmsc.toUri()) - if(VERSION.SDK_INT >= 33) { - mtuV4.toIntOrNull()?.let { setMtuV4(it) } - mtuV6.toIntOrNull()?.let { setMtuV6(it) } - } - setMvnoType(mvnoType) - networkTypeBitmask.toIntOrNull()?.let { setNetworkTypeBitmask(it) } - setOperatorNumeric(operatorNumeric) - setProtocol(protocol) - setRoamingProtocol(roamingProtocol) - if(VERSION.SDK_INT >= 33) setPersistent(persistent) - if(VERSION.SDK_INT >= 35) setAlwaysOn(alwaysOn) - }.build() - if(origin == null) { - Privilege.DPM.addOverrideApn(Privilege.DAR, setting) - } else { - Privilege.DPM.updateOverrideApn(Privilege.DAR, origin.id, setting) - } - onNavigateUp() - } catch(e: Exception) { - errorMessage = (e::class.qualifiedName ?: "") + "\n" + (e.message ?: "") - } + val result = setApn(ApnConfig( + enabled, entryName, apnName, proxy, port.toIntOrNull(), user, password, apnType, + mmsc, mmsProxy, mmsPort.toIntOrNull(), authType, protocol, roamingProtocol, + networkType, profileId.toIntOrNull(), carrierId.toIntOrNull(), + mtuV4.toIntOrNull(), mtuV6.toIntOrNull(), mvnoType, + operatorNumeric, persistent, alwaysOn + )) + context.showOperationResultToast(result) + if (result) onNavigateUp() }, Modifier.fillMaxWidth().padding(vertical = 4.dp) ) { Text(stringResource(if(origin != null) R.string.update else R.string.add)) } - if(origin != null) Button( + if (origin != null) Button( { - Privilege.DPM.removeOverrideApn(Privilege.DAR, origin.id) - onNavigateUp() + val result = deleteApn(origin.id) + context.showOperationResultToast(result) + if (result) onNavigateUp() }, Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError) ) { Text(stringResource(R.string.delete)) } - if(dialog != 0) { - var address by remember { mutableStateOf((if(dialog == 1) proxyAddress else mmsProxyAddress)) } - var port by remember { mutableStateOf((if(dialog == 1) proxyPort else mmsProxyPort)) } - val fr = remember { FocusRequester() } - AlertDialog( - title = { Text(if(dialog == 1) "Proxy" else "MMS proxy") }, - text = { - val focusManager = LocalFocusManager.current - Column { - OutlinedTextField( - address, { address = it }, Modifier.fillMaxWidth().padding(bottom = 4.dp), - textStyle = typography.bodyLarge, - label = { Text(stringResource(R.string.address)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() } - ) - OutlinedTextField( - port, { port = it }, Modifier.fillMaxWidth().focusRequester(fr), - textStyle = typography.bodyLarge, - isError = port.isNotEmpty() && port.toIntOrNull() == null, - label = { Text(stringResource(R.string.port)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusManager.clearFocus() } - ) - } - }, - confirmButton = { - TextButton( - { - if(dialog == 1) { - proxyAddress = address - proxyPort = port - } else { - mmsProxyAddress = address - mmsProxyPort = port - } - dialog = 0 - } - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } - }, - onDismissRequest = { dialog = 0 } - ) - } if(errorMessage != null) AlertDialog( title = { Text(stringResource(R.string.error)) }, text = { Text(errorMessage ?: "") }, @@ -2239,4 +2170,56 @@ fun AddApnSettingScreen(origin: ApnSetting?, onNavigateUp: () -> Unit) { onDismissRequest = { errorMessage = null } ) } + if (menu == ApnMenu.ApnType) AlertDialog( + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + apnTypes.forEach { type -> + val checked = apnType and type.id == type.id + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { + apnType = if (checked) apnType and type.id.inv() + else apnType or type.id + }.padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked, null) + Text(type.name, Modifier.padding(start = 8.dp), style = MaterialTheme.typography.bodyLarge) + } + } + } + }, + confirmButton = { + TextButton({ menu = ApnMenu.None }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { menu = ApnMenu.None } + ) + if (menu == ApnMenu.NetworkType) AlertDialog( + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + apnNetworkTypes.forEach { type -> + val checked = type.id and networkType == type.id + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { + networkType = if (checked) networkType and type.id.inv() + else networkType or type.id + }.padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked, null) + Text(type.text, Modifier.padding(start = 6.dp), style = MaterialTheme.typography.bodyLarge) + } + } + } + }, + confirmButton = { + TextButton({ menu = ApnMenu.None }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { + menu = ApnMenu.None + } + ) } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 68a7695..bbafff7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -164,7 +164,6 @@ Политика потоковой передачи уведомлений Nearby Только для одной управляемой учетной записи Режим закрепления задачи - Указать Acitvity Приложение не разрешено Отключить все @@ -254,7 +253,6 @@ Тип сети Мобильный Ethernet - ID подписчика Всё Удалённый Режим модема @@ -269,13 +267,9 @@ Роуминг Измеренный Частный DNS - Укажите имя хоста - Хост не обслуживает DNS через TLS - Установить в оппортунистический режим Имя хоста DNS Неверное имя хоста Исключение безопасности - Установить хост DNS Рекомендуемый глобальный прокси Без прокси PAC-прокси @@ -296,27 +290,15 @@ Включенные UID Исключенные UID Один UID на строку - + Override APN APN setting - APN name - Entry name Имя - Идентификатор профиля - Тип аутентификации Описание - MMS-прокси Адрес Порт Прокси Постоянный - Протокол - Протокол роуминга - - Carrier ID - MVNO type - Network type bitmask - Always on Обновить diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2f06a5a..48ea208 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -195,7 +195,6 @@ Yakındaki Bildirim Akışı Yalnızca aynı yönetilen hesap Görev Kilitleme Modu - Etkinliği Belirt Uygulamaya izin verilmiyor Hepsini Devre Dışı Bırak @@ -283,7 +282,6 @@ Ağ Türü Mobil Ethernet - Abone Kimliği Tümü Kaldırılmış Bağlantı Paylaşımı @@ -298,13 +296,9 @@ Dolaşım Ölçülü Özel DNS - Ana bilgisayar adı sağla - Ana bilgisayar DNS TLS sunmuyor - Fırsatçı olarak ayarla DNS Ana Bilgisayar Adı Geçersiz ana bilgisayar adı Güvenlik İstisnası - DNS Ana Bilgisayarı Ayarla Önerilen küresel vekil Vekil yok PAC vekil @@ -327,22 +321,12 @@ Satır başına bir UID APN\'yi Geçersiz Kıl APN Ayarı - APN Adı - Giriş Adı Ad - Profil Kimliği - Kimlik Doğrulama Türü Açıklama - MMS Vekil Adres Port Vekil Kalıcı - Protokol - Dolaşım Protokolü - Operatör Kimliği - MVNO Türü - Ağ Türü Bitmask Her Zaman Açık Güncelle @@ -688,8 +672,6 @@ Bu kullanıcının tüm verileri silinecek, ancak kullanıcı kaldırılmayacak. Kullanıcının yönetici tarafından yapılandırılan ağları değiştirip değiştiremeyeceğini kontrol eder.\nBu kilit etkinleştirildiğinde, kullanıcı hala diğer Wi-Fi ağlarını yapılandırabilir ve bağlanabilir veya bağlantı paylaşımı gibi diğer Wi-Fi özelliklerini kullanabilir. Wi-Fi ağları için gereken minimum güvenlik seviyesini belirtir. Cihaz, minimum güvenlik seviyesini karşılamayan ağlara bağlanmayabilir. Mevcut ağ minimum güvenlik seviyesini karşılamıyorsa bağlantısı kesilecektir. - Bu modda, DNS alt sistemi, açık metinle ad çözümlemesi yapmadan önce ağ tarafından sağlanan çözücüye TLS el sıkışması yapmaya çalışır. - Çözücünün geçerli olduğunu doğrulamak için bir bağlantı kontrolü gerçekleştirir.\nÖzel DNS çözücüsü ile birlikte bir VPN kullanıldığında, Özel DNS çözücüsüne hem VPN içinden hem de dışından erişilebilir olmalıdır. Aksi takdirde, sistem trafiği VPN üzerinden çözücüye gitmeyebilir ve cihaz ana bilgisayar adlarını çözümleme yeteneğini kaybedebilir. Geçerli kullanıcı için belirli bir uygulama aracılığıyla her zaman açık bir VPN bağlantısı yapılandırır. Bu bağlantı otomatik olarak verilir ve yeniden başlatma sonrası kalıcıdır.\nKilitlemeyi etkinleştir: VPN bağlı değilken ağ kullanımını engelle. Bu vekil yalnızca bir öneridir ve bazı uygulamalar bunu yok sayabilir. Ağ kayıtları, DNS aramalarını ve connect() kütüphane çağrı olaylarını içerir.\nBu işlev iş profilinde kullanıldığında yalnızca iş profilindeki ağ kayıtlarını alır.\nCihaz sahibi tarafından kullanılıyorsa, cihazda bağlı olmayan kullanıcı olmamalıdır. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 35adb2d..c041c00 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -175,7 +175,6 @@ 附近通知传输 在足够安全时启用 锁定任务模式 - 指定Activity 应用未被允许 禁用全部 允许状态栏信息 @@ -248,6 +247,7 @@ Invalid configuration 最低Wi-Fi安全等级 开放 + 不更改 锁定由管理员配置的网络 Wi-Fi SSID策略 已经存在 @@ -263,13 +263,13 @@ 网络类型 移动 以太网 - 订阅者ID 全部 已卸载 热点 选择一个app... 输入 查询 + 无数据 发送 接收 状态 @@ -278,13 +278,10 @@ 漫游 按量计费 私人DNS - 指定主机名 - 主机不支持 - 设为自动 + 自动 DNS主机名 无效主机名 安全错误 - 设置DNS主机 建议的全局代理 无代理 PAC代理 @@ -307,22 +304,12 @@ 每行一个UID 覆盖APN APN设置 - APN名称 - 条目名称 名称 - 资料ID - 认证类型 描述 - MMS代理 地址 端口 代理 持久化 - 协议 - 漫游协议 - 运营商ID - MVNO类型 - 网络类型位掩码 总是开启 更新 @@ -672,8 +659,6 @@ 此用户的所有数据将会被清除,但是用户不会被删除。 控制用户是否可以更改管理员配置的网络。启用此锁定后,用户仍然可以配置和连接到其他Wi-Fi,或使用其他Wi-Fi功能(如网络共享)。 指定Wi-Fi网络所需的最低安全等级。设备将无法连接到低于最低安全等级的网络。如果当前网络不满足要求,则会断开连接。 - 在此模式下,DNS子系统将在尝试以明文形式进行域名解析之前,尝试与网络提供的DNS服务器进行TLS握手。 - 将对DNS服务器执行连接检查,以确保其有效。\n如果将VPN与私人DNS结合使用,则私人DNS必须可从VPN内部和外部访问。否则设备可能会失去解析域名的能力,因为到DNS服务器的系统流量可能不会通过VPN。 通过一个指定的app,为当前用户设置一个保持打开的VPN连接。自动授权连接并在重启后保留。\n启用锁定:如果VPN未连接,则禁止使用网络。 这个代理只是一个建议,一些app有可能忽略它。 网络日志包含DNS查询和connect()库调用记录 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d1236b..be8e8da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,7 +203,6 @@ Nearby notification streaming policy Same managed account only Lock task mode - Specify Activity App is not allowed Disable all @@ -277,6 +276,10 @@ Invalid configuration Minimum Wi-Fi security level Open + PSK + DHCP + HTTP + Unchanged Lockdown admin configured network Wi-Fi SSID policy Already exist @@ -294,13 +297,13 @@ Mobile Ethernet VPN - Subscriber ID All Uninstalled Tethering Choose an app... Input Query + No data Transmitted Received State @@ -309,13 +312,10 @@ Roaming Metered Private DNS - Provide hostname - Host not serving - Set to opportunistic + Automatic DNS hostname Invalid hostname Security Exception - Set DNS host Recommended global proxy No proxy PAC proxy @@ -338,22 +338,12 @@ One UID per line Override APN APN setting - APN name - Entry name Name - Profile ID - Auth type Description - MMS proxy Address Port Proxy Persistent - Protocol - Roaming protocol - Carrier ID - MVNO type - Network type bitmask Always on Update @@ -706,8 +696,6 @@ All data of this user will be wiped, but that user won\'t be removed. Control whether the user can change networks configured by the admin.\nWhen this lockdown is enabled, the user can still configure and connect to other Wi-Fi networks, or use other Wi-Fi capabilities such as tethering. Specify the minimum security level required for Wi-Fi networks. The device may not connect to networks that do not meet the minimum security level. If the current network does not meet the minimum security level set, it will be disconnected. - In this mode, the DNS subsystem will attempt a TLS handshake to the network-supplied resolver prior to attempting name resolution in cleartext. - It will perform a connectivity check to the resolver, to ensure it is valid.\nIn case a VPN is used in conjunction with Private DNS resolver, the Private DNS resolver must be reachable both from within and outside the VPN. Otherwise, the device may lose the ability to resolve hostnames as system traffic to the resolver may not go through the VPN. Configure an always-on VPN connection through a specific application for the current user. This connection is automatically granted and persisted after a reboot.\nEnable lockdown: Disallow networking when the VPN is not connected. This proxy is only a recommendation and it is possible that some apps will ignore it. Network logs contain DNS lookup and connect() library call events.\nUse this function in work profile will only retrieve network logs in work profile.\nThere shouldn\'t be unaffiliated user on this device if used by Device owner. From 44aad188141acb7e31aed0f8806e39d9c9af34a2 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Thu, 9 Oct 2025 18:27:40 +0800 Subject: [PATCH 11/26] ViewModel refactoring: Password part Fix bugs (#178, #179) --- .../com/bintianqi/owndroid/MainActivity.kt | 27 +- .../com/bintianqi/owndroid/MyViewModel.kt | 115 ++++- .../owndroid/UserRestrictionsRepository.kt | 16 +- .../com/bintianqi/owndroid/dpm/Network.kt | 9 +- .../com/bintianqi/owndroid/dpm/Password.kt | 393 +++++++----------- .../java/com/bintianqi/owndroid/dpm/System.kt | 11 +- .../bintianqi/owndroid/dpm/UserRestriction.kt | 2 +- app/src/main/res/values-ru/strings.xml | 5 +- app/src/main/res/values-tr/strings.xml | 3 +- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- 11 files changed, 310 insertions(+), 283 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index ebebdee..b060f42 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -177,7 +177,6 @@ import com.bintianqi.owndroid.dpm.ResetPassword import com.bintianqi.owndroid.dpm.ResetPasswordScreen import com.bintianqi.owndroid.dpm.ResetPasswordToken import com.bintianqi.owndroid.dpm.ResetPasswordTokenScreen -import com.bintianqi.owndroid.dpm.Restriction import com.bintianqi.owndroid.dpm.SecurityLogging import com.bintianqi.owndroid.dpm.SecurityLoggingScreen import com.bintianqi.owndroid.dpm.SetDefaultDialer @@ -216,8 +215,6 @@ import com.bintianqi.owndroid.dpm.UsersOptions import com.bintianqi.owndroid.dpm.UsersOptionsScreen import com.bintianqi.owndroid.dpm.UsersScreen import com.bintianqi.owndroid.dpm.WiFi -import com.bintianqi.owndroid.dpm.WifiAuthKeypair -import com.bintianqi.owndroid.dpm.WifiAuthKeypairScreen import com.bintianqi.owndroid.dpm.WifiScreen import com.bintianqi.owndroid.dpm.WifiSecurityLevel import com.bintianqi.owndroid.dpm.WifiSecurityLevelScreen @@ -601,12 +598,24 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { ::navigateUp) } - composable { PasswordScreen(::navigateUp, ::navigate) } - composable { PasswordInfoScreen(::navigateUp) } - composable { ResetPasswordTokenScreen(::navigateUp) } - composable { ResetPasswordScreen(::navigateUp) } - composable { RequiredPasswordComplexityScreen(::navigateUp) } - composable { KeyguardDisabledFeaturesScreen(::navigateUp) } + composable { PasswordScreen(vm, ::navigateUp, ::navigate) } + composable { + PasswordInfoScreen(vm::getPasswordComplexity, vm::isPasswordComplexitySufficient, + vm::isUsingUnifiedPassword, ::navigateUp) + } + composable { + ResetPasswordTokenScreen(vm::getRpTokenState, vm::setRpToken, + vm::createActivateRpTokenIntent, vm::clearRpToken, ::navigateUp) + } + composable { ResetPasswordScreen(vm::resetPassword, ::navigateUp) } + composable { + RequiredPasswordComplexityScreen(vm::getRequiredPasswordComplexity, + vm::setRequiredPasswordComplexity, ::navigateUp) + } + composable { + KeyguardDisabledFeaturesScreen(vm::getKeyguardDisableConfig, + vm::setKeyguardDisableConfig, ::navigateUp) + } composable { RequiredPasswordQualityScreen(::navigateUp) } composable { SettingsScreen(::navigateUp, ::navigate) } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index a127451..3ab7410 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,9 +1,9 @@ package com.bintianqi.owndroid import android.accounts.Account -import android.annotation.SuppressLint import android.app.ActivityOptions import android.app.Application +import android.app.KeyguardManager import android.app.PendingIntent import android.app.admin.DeviceAdminInfo import android.app.admin.DeviceAdminReceiver @@ -68,15 +68,20 @@ import com.bintianqi.owndroid.dpm.HardwareProperties import com.bintianqi.owndroid.dpm.IntentFilterDirection import com.bintianqi.owndroid.dpm.IntentFilterOptions import com.bintianqi.owndroid.dpm.IpMode +import com.bintianqi.owndroid.dpm.KeyguardDisableConfig +import com.bintianqi.owndroid.dpm.KeyguardDisableMode import com.bintianqi.owndroid.dpm.NetworkStatsData import com.bintianqi.owndroid.dpm.NetworkStatsTarget +import com.bintianqi.owndroid.dpm.PasswordComplexity import com.bintianqi.owndroid.dpm.PendingSystemUpdateInfo import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceInfo import com.bintianqi.owndroid.dpm.PrivateDnsConfiguration +import com.bintianqi.owndroid.dpm.PrivateDnsMode import com.bintianqi.owndroid.dpm.ProxyMode import com.bintianqi.owndroid.dpm.ProxyType import com.bintianqi.owndroid.dpm.QueryNetworkStatsParams import com.bintianqi.owndroid.dpm.RecommendedProxyConf +import com.bintianqi.owndroid.dpm.RpTokenState import com.bintianqi.owndroid.dpm.SsidPolicy import com.bintianqi.owndroid.dpm.SsidPolicyType import com.bintianqi.owndroid.dpm.SystemOptionsStatus @@ -113,7 +118,6 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.util.concurrent.Executors import kotlin.reflect.jvm.jvmErasure -import kotlin.system.measureTimeMillis class MyViewModel(application: Application): AndroidViewModel(application) { val myRepo = getApplication().myRepo @@ -1472,8 +1476,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } @RequiresApi(29) fun getPrivateDns(): PrivateDnsConfiguration { + val mode = DPM.getGlobalPrivateDnsMode(DAR) return PrivateDnsConfiguration( - DPM.getGlobalPrivateDnsMode(DAR), DPM.getGlobalPrivateDnsHost(DAR) ?: "" + PrivateDnsMode.entries.find { it.id == mode }!!, DPM.getGlobalPrivateDnsHost(DAR) ?: "" ) } @Suppress("PrivateApi") @@ -1483,7 +1488,8 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val field = DevicePolicyManager::class.java.getDeclaredField("mService") field.isAccessible = true val dpm = field.get(DPM) as IDevicePolicyManager - val result = dpm.setGlobalPrivateDns(DAR, conf.mode, conf.host) + val host = if (conf.mode == PrivateDnsMode.Host) conf.host else null + val result = dpm.setGlobalPrivateDns(DAR, conf.mode.id, host) result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR } catch (e: Exception) { e.printStackTrace() @@ -1645,6 +1651,107 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun removeApnConfig(id: Int): Boolean { return DPM.removeOverrideApn(DAR, id) } + + @RequiresApi(29) + fun getPasswordComplexity(): PasswordComplexity { + val complexity = DPM.passwordComplexity + return PasswordComplexity.entries.find { it.id == complexity }!! + } + fun isPasswordComplexitySufficient(): Boolean { + return DPM.isActivePasswordSufficient + } + @RequiresApi(28) + fun isUsingUnifiedPassword(): Boolean { + return DPM.isUsingUnifiedPassword(DAR) + } + // Reset password token + @RequiresApi(26) + fun getRpTokenState(): RpTokenState { + return try { + RpTokenState(true, DPM.isResetPasswordTokenActive(DAR)) + } catch (_: IllegalArgumentException) { + RpTokenState(false, false) + } + } + @RequiresApi(26) + fun setRpToken(token: String): Boolean { + return DPM.setResetPasswordToken(DAR, token.encodeToByteArray()) + } + @RequiresApi(26) + fun clearRpToken(): Boolean { + return DPM.clearResetPasswordToken(DAR) + } + @RequiresApi(26) + fun createActivateRpTokenIntent(): Intent? { + val km = application.getSystemService(KeyguardManager::class.java) + val title = application.getString(R.string.activate_reset_password_token) + return km.createConfirmDeviceCredentialIntent(title, "") + } + fun resetPassword(password: String, token: String, flags: Int): Boolean { + return if (VERSION.SDK_INT >= 26) { + DPM.resetPasswordWithToken(DAR, password, token.encodeToByteArray(), flags) + } else { + DPM.resetPassword(password, flags) + } + } + @RequiresApi(31) + fun getRequiredPasswordComplexity(): PasswordComplexity { + val complexity = DPM.requiredPasswordComplexity + return PasswordComplexity.entries.find { it.id == complexity }!! + } + @RequiresApi(31) + fun setRequiredPasswordComplexity(complexity: PasswordComplexity) { + DPM.requiredPasswordComplexity = complexity.id + } + fun getKeyguardDisableConfig(): KeyguardDisableConfig { + val flags = DPM.getKeyguardDisabledFeatures(DAR) + val mode = when (flags) { + DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -> KeyguardDisableMode.None + DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -> KeyguardDisableMode.All + else -> KeyguardDisableMode.Custom + } + return KeyguardDisableConfig(mode, flags) + } + fun setKeyguardDisableConfig(config: KeyguardDisableConfig) { + val flags = when (config.mode) { + KeyguardDisableMode.None -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE + KeyguardDisableMode.All -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL + else -> config.flags + } + DPM.setKeyguardDisabledFeatures(DAR, flags) + } + fun getMaxTimeToLock(): Long { + return DPM.getMaximumTimeToLock(DAR) + } + @RequiresApi(26) + fun getRequiredStrongAuthTimeout(): Long { + return DPM.getRequiredStrongAuthTimeout(DAR) + } + fun getPasswordExpirationTimeout(): Long { + return DPM.getPasswordExpirationTimeout(DAR) + } + fun getMaxFailedPasswordsForWipe(): Int { + return DPM.getMaximumFailedPasswordsForWipe(DAR) + } + fun getPasswordHistoryLength(): Int { + return DPM.getPasswordHistoryLength(DAR) + } + fun setMaxTimeToLock(time: Long) { + DPM.setMaximumTimeToLock(DAR, time) + } + @RequiresApi(26) + fun setRequiredStrongAuthTimeout(time: Long) { + DPM.setRequiredStrongAuthTimeout(DAR, time) + } + fun setPasswordExpirationTimeout(time: Long) { + DPM.setPasswordExpirationTimeout(DAR, time) + } + fun setMaxFailedPasswordsForWipe(times: Int) { + DPM.setMaximumFailedPasswordsForWipe(DAR, times) + } + fun setPasswordHistoryLength(length: Int) { + DPM.setPasswordHistoryLength(DAR, length) + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt index 7caee10..8f18f52 100644 --- a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt @@ -88,7 +88,7 @@ object UserRestrictionsRepository { ) fun getData(id: String): Pair> { - val category = UserRestrictionCategory.entries.find { it.id == id }!! + val category = UserRestrictionCategory.valueOf(id) return category.title to when (category) { UserRestrictionCategory.Network -> network UserRestrictionCategory.Connectivity -> connectivity @@ -100,11 +100,11 @@ object UserRestrictionsRepository { } } -enum class UserRestrictionCategory(val id: String, val title: Int, val icon: Int) { - Network("network", R.string.network, R.drawable.language_fill0), - Connectivity("connectivity", R.string.connectivity, R.drawable.devices_other_fill0), - Applications("applications", R.string.applications, R.drawable.apps_fill0), - Media("media", R.string.media, R.drawable.volume_up_fill0), - Users("users", R.string.users, R.drawable.manage_accounts_fill0), - Other("other", R.string.other, R.drawable.more_horiz_fill0) +enum class UserRestrictionCategory(val title: Int, val icon: Int) { + Network(R.string.network, R.drawable.language_fill0), + Connectivity(R.string.connectivity, R.drawable.devices_other_fill0), + Applications(R.string.applications, R.drawable.apps_fill0), + Media(R.string.media, R.drawable.volume_up_fill0), + Users(R.string.users, R.drawable.manage_accounts_fill0), + Other(R.string.other, R.drawable.more_horiz_fill0) } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index c5c2e41..1f4cef1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -1346,12 +1346,11 @@ fun NetworkStatsViewerScreen( @RequiresApi(29) enum class PrivateDnsMode(val id: Int, val text: Int) { - Off(DevicePolicyManager.PRIVATE_DNS_MODE_OFF, R.string.off), Opportunistic(DevicePolicyManager.PRIVATE_DNS_MODE_OPPORTUNISTIC, R.string.automatic), Host(DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME, R.string.enabled) } -data class PrivateDnsConfiguration(val mode: Int, val host: String) +data class PrivateDnsConfiguration(val mode: PrivateDnsMode, val host: String) @Serializable object PrivateDns @@ -1363,11 +1362,11 @@ fun PrivateDnsScreen( ) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var mode by remember { mutableStateOf(PrivateDnsMode.Off) } + var mode by remember { mutableStateOf(PrivateDnsMode.Opportunistic) } var inputHost by remember { mutableStateOf("") } LaunchedEffect(Unit) { val conf = getPrivateDns() - mode = PrivateDnsMode.entries.find { it.id == conf.mode } ?: PrivateDnsMode.Off + mode = conf.mode inputHost = conf.host } MyScaffold(R.string.private_dns, onNavigateUp, 0.dp) { @@ -1383,7 +1382,7 @@ fun PrivateDnsScreen( Button( onClick = { focusMgr.clearFocus() - val result = setPrivateDns(PrivateDnsConfiguration(mode.id, inputHost)) + val result = setPrivateDns(PrivateDnsConfiguration(mode, inputHost)) context.showOperationResultToast(result) }, modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt index 42d0e78..6f957cd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -1,23 +1,8 @@ package com.bintianqi.owndroid.dpm import android.annotation.SuppressLint -import android.app.KeyguardManager -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_BIOMETRICS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FACE -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FINGERPRINT -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_IRIS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_SECURE_CAMERA -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_SECURE_NOTIFICATIONS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_SHORTCUTS_ALL -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_TRUST_AGENTS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_WIDGETS_ALL -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_LOW -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_NONE +import android.app.Activity +import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK @@ -28,15 +13,17 @@ import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED import android.app.admin.DevicePolicyManager.RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT import android.app.admin.DevicePolicyManager.RESET_PASSWORD_REQUIRE_ENTRY import android.content.Context +import android.content.Intent import android.os.Build.VERSION import android.os.UserManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility -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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -63,9 +50,9 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat.startActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP @@ -86,13 +73,13 @@ import kotlinx.serialization.Serializable @SuppressLint("NewApi") @Composable -fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun PasswordScreen(vm: MyViewModel,onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() var dialog by remember { mutableIntStateOf(0) } MyScaffold(R.string.password_and_keyguard, onNavigateUp, 0.dp) { FunctionItem(R.string.password_info, icon = R.drawable.info_fill0) { onNavigate(PasswordInfo) } - if(SP.displayDangerousFeatures) { + if (SP.displayDangerousFeatures) { if(VERSION.SDK_INT >= 26) { FunctionItem(R.string.reset_password_token, icon = R.drawable.key_vertical_fill0) { onNavigate(ResetPasswordToken) } } @@ -118,14 +105,14 @@ fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(dialog != 0) { var input by remember { mutableStateOf("") } LaunchedEffect(Unit) { - input = when(dialog) { - 1 -> Privilege.DPM.getMaximumTimeToLock(Privilege.DAR).toString() - 2 -> Privilege.DPM.getRequiredStrongAuthTimeout(Privilege.DAR).toString() - 3 -> Privilege.DPM.getPasswordExpirationTimeout(Privilege.DAR).toString() - 4 -> Privilege.DPM.getMaximumFailedPasswordsForWipe(Privilege.DAR).toString() - 5 -> Privilege.DPM.getPasswordHistoryLength(Privilege.DAR).toString() - else -> "" - } + input = when (dialog) { + 1 -> vm.getMaxTimeToLock() + 2 -> vm.getRequiredStrongAuthTimeout() + 3 -> vm.getPasswordExpirationTimeout() + 4 -> vm.getMaxFailedPasswordsForWipe() + 5 -> vm.getPasswordHistoryLength() + else -> 0 + }.toString() } AlertDialog( title = { @@ -178,14 +165,15 @@ fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { TextButton( onClick = { when(dialog) { - 1 -> Privilege.DPM.setMaximumTimeToLock(Privilege.DAR, input.toLong()) - 2 -> Privilege.DPM.setRequiredStrongAuthTimeout(Privilege.DAR, input.toLong()) - 3 -> Privilege.DPM.setPasswordExpirationTimeout(Privilege.DAR, input.toLong()) - 4 -> Privilege.DPM.setMaximumFailedPasswordsForWipe(Privilege.DAR, input.toInt()) - 5 -> Privilege.DPM.setPasswordHistoryLength(Privilege.DAR, input.toInt()) + 1 -> vm.setMaxTimeToLock(input.toLong()) + 2 -> vm.setRequiredStrongAuthTimeout(input.toLong()) + 3 -> vm.setPasswordExpirationTimeout(input.toLong()) + 4 -> vm.setMaxFailedPasswordsForWipe(input.toInt()) + 5 -> vm.setPasswordHistoryLength(input.toInt()) } dialog = 0 - } + }, + enabled = input.toLongOrNull() != null ) { Text(stringResource(R.string.apply)) } @@ -202,26 +190,30 @@ fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } } +@RequiresApi(29) +enum class PasswordComplexity(val id: Int, val text: Int) { + None(DevicePolicyManager.PASSWORD_COMPLEXITY_NONE, R.string.none), + Low(DevicePolicyManager.PASSWORD_COMPLEXITY_LOW, R.string.low), + Medium(DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM, R.string.medium), + High(DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH, R.string.high) +} + @Serializable object PasswordInfo @Composable -fun PasswordInfoScreen(onNavigateUp: () -> Unit) { +fun PasswordInfoScreen( + getComplexity: () -> PasswordComplexity, isSufficient: () -> Boolean, isUnified: () -> Boolean, + onNavigateUp: () -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() var dialog by remember { mutableIntStateOf(0) } // 0:none, 1:password complexity MyScaffold(R.string.password_info, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 29) { - val text = when(Privilege.DPM.passwordComplexity) { - PASSWORD_COMPLEXITY_NONE -> R.string.none - PASSWORD_COMPLEXITY_LOW -> R.string.low - PASSWORD_COMPLEXITY_MEDIUM -> R.string.medium - PASSWORD_COMPLEXITY_HIGH -> R.string.high - else -> R.string.unknown - } - InfoItem(R.string.current_password_complexity, text, true) { dialog = 1 } + if (VERSION.SDK_INT >= 29) { + InfoItem(R.string.current_password_complexity, getComplexity().text, true) { dialog = 1 } } - InfoItem(R.string.password_sufficient, Privilege.DPM.isActivePasswordSufficient.yesOrNo) + InfoItem(R.string.password_sufficient, isSufficient().yesOrNo) if(VERSION.SDK_INT >= 28 && privilege.work) { - InfoItem(R.string.unified_password, Privilege.DPM.isUsingUnifiedPassword(Privilege.DAR).yesOrNo) + InfoItem(R.string.unified_password, isUnified().yesOrNo) } } if(dialog != 0) AlertDialog( @@ -235,62 +227,59 @@ fun PasswordInfoScreen(onNavigateUp: () -> Unit) { ) } +data class RpTokenState(val set: Boolean, val active: Boolean) + @Serializable object ResetPasswordToken @RequiresApi(26) @Composable -fun ResetPasswordTokenScreen(onNavigateUp: () -> Unit) { +fun ResetPasswordTokenScreen( + getState: () -> RpTokenState, setToken: (String) -> Boolean, getIntent: () -> Intent?, + clearToken: () -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current var token by remember { mutableStateOf("") } - val tokenByteArray = token.toByteArray() - val focusMgr = LocalFocusManager.current + var state by remember { mutableStateOf(getState()) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + context.popToast(R.string.token_activated) + state = getState() + } + } MyScaffold(R.string.reset_password_token, onNavigateUp) { OutlinedTextField( - value = token, onValueChange = { token = it }, + token, { token = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.token)) }, - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - supportingText = { - AnimatedVisibility(tokenByteArray.size < 32) { - Text(stringResource(R.string.token_must_longer_than_32_byte)) - } - }, - modifier = Modifier.fillMaxWidth() + supportingText = { Text("${token.length}/32") } ) Button( onClick = { - try { - context.showOperationResultToast(Privilege.DPM.setResetPasswordToken(Privilege.DAR, tokenByteArray)) - } catch(_:SecurityException) { - context.popToast(R.string.security_exception) - } + val result = setToken(token) + context.showOperationResultToast(result) + if (result) state = getState() }, modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - enabled = tokenByteArray.size >= 32 + enabled = token.length >= 32 ) { Text(stringResource(R.string.set)) } - Row( - horizontalArrangement = Arrangement.SpaceBetween, + if (state.set && !state.active) Button( + onClick = { + getIntent()?.let { launcher.launch(it) } + }, modifier = Modifier.fillMaxWidth() ) { - Button( - onClick = { - if(!Privilege.DPM.isResetPasswordTokenActive(Privilege.DAR)) { - try { activateToken(context) } - catch(_:NullPointerException) { context.popToast(R.string.please_set_a_token) } - } else { context.popToast(R.string.token_already_activated) } - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.activate)) - } - Button( - onClick = { context.showOperationResultToast(Privilege.DPM.clearResetPasswordToken(Privilege.DAR)) }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.clear)) - } + Text(stringResource(R.string.activate)) + } + if (state.set) Button( + onClick = { + val result = clearToken() + context.showOperationResultToast(result) + state = getState() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.clear)) } Spacer(Modifier.padding(vertical = 5.dp)) Notes(R.string.activate_token_not_required_when_no_password) @@ -300,147 +289,81 @@ fun ResetPasswordTokenScreen(onNavigateUp: () -> Unit) { @Serializable object ResetPassword @Composable -fun ResetPasswordScreen(onNavigateUp: () -> Unit) { +fun ResetPasswordScreen(resetPassword: (String, String, Int) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current - val focusMgr = LocalFocusManager.current var password by remember { mutableStateOf("") } - var useToken by remember { mutableStateOf(false) } var token by remember { mutableStateOf("") } - val tokenByteArray = token.toByteArray() - var flag by remember { mutableIntStateOf(0) } - var confirmDialog by remember { mutableStateOf(false) } + var flags by remember { mutableIntStateOf(0) } + var confirmPassword by remember { mutableStateOf("") } MyScaffold(R.string.reset_password, onNavigateUp) { - if(VERSION.SDK_INT >= 26) { + if (VERSION.SDK_INT >= 26) { OutlinedTextField( - value = token, onValueChange = { token = it }, + token, { token = it }, Modifier.fillMaxWidth().padding(bottom = 5.dp), label = { Text(stringResource(R.string.token)) }, - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - modifier = Modifier.fillMaxWidth() + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) ) } OutlinedTextField( - value = password, - onValueChange = { password = it }, + password, { password = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.password)) }, + isError = password.length in 1..3, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + visualTransformation = PasswordVisualTransformation() + ) + OutlinedTextField( + confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + isError = confirmPassword != password, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - supportingText = { Text(stringResource(R.string.reset_pwd_desc)) }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() + visualTransformation = PasswordVisualTransformation() ) Spacer(Modifier.padding(vertical = 5.dp)) if(VERSION.SDK_INT >= 23) { CheckBoxItem( R.string.do_not_ask_credentials_on_boot, - flag and RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT != 0 - ) { flag = flag xor RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT } + flags and RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT != 0 + ) { flags = flags xor RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT } } CheckBoxItem( R.string.reset_password_require_entry, - flag and RESET_PASSWORD_REQUIRE_ENTRY != 0 - ) { flag = flag xor RESET_PASSWORD_REQUIRE_ENTRY } + flags and RESET_PASSWORD_REQUIRE_ENTRY != 0 + ) { flags = flags xor RESET_PASSWORD_REQUIRE_ENTRY } Spacer(Modifier.padding(vertical = 5.dp)) - if(VERSION.SDK_INT >= 26) { - Button( - onClick = { - useToken = true - confirmDialog = true - focusMgr.clearFocus() - }, - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - enabled = tokenByteArray.size >=32 && password.length !in 1..3, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.reset_password_with_token)) - } - } - if(VERSION.SDK_INT <= 30) { - Button( - onClick = { - useToken = false - confirmDialog = true - focusMgr.clearFocus() - }, - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - enabled = password.length !in 1..3, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.reset_password)) - } + Button( + onClick = { + context.showOperationResultToast(resetPassword(password, token, flags)) + }, + colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError), + modifier = Modifier.fillMaxWidth(), + enabled = password == confirmPassword + ) { + Text(stringResource(R.string.reset_password)) } Notes(R.string.info_reset_password) } - if(confirmDialog) { - var confirmPassword by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { confirmDialog = false }, - title = { Text(stringResource(R.string.reset_password)) }, - text = { - val dialogFocusMgr = LocalFocusManager.current - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - label = { Text(stringResource(R.string.confirm_password)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { dialogFocusMgr.clearFocus() }), - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton( - onClick = { - val success = if(VERSION.SDK_INT >= 26 && useToken) { - Privilege.DPM.resetPasswordWithToken(Privilege.DAR, password, tokenByteArray, flag) - } else { - Privilege.DPM.resetPassword(password, flag) - } - context.showOperationResultToast(success) - password = "" - confirmDialog = false - }, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error), - enabled = confirmPassword == password - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton(onClick = { confirmDialog = false }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } } @Serializable object RequiredPasswordComplexity @RequiresApi(31) @Composable -fun RequiredPasswordComplexityScreen(onNavigateUp: () -> Unit) { +fun RequiredPasswordComplexityScreen( + getComplexity: () -> PasswordComplexity, setComplexity: (PasswordComplexity) -> Unit, + onNavigateUp: () -> Unit +) { val context = LocalContext.current - val passwordComplexity = mapOf( - PASSWORD_COMPLEXITY_NONE to R.string.none, - PASSWORD_COMPLEXITY_LOW to R.string.low, - PASSWORD_COMPLEXITY_MEDIUM to R.string.medium, - PASSWORD_COMPLEXITY_HIGH to R.string.high - ) - var selectedItem by remember { mutableIntStateOf(PASSWORD_COMPLEXITY_NONE) } - LaunchedEffect(Unit) { selectedItem = Privilege.DPM.requiredPasswordComplexity } + var complexity by remember { mutableStateOf(PasswordComplexity.None) } + LaunchedEffect(Unit) { complexity = getComplexity() } MyScaffold(R.string.required_password_complexity, onNavigateUp, 0.dp) { - passwordComplexity.forEach { - FullWidthRadioButtonItem(it.value, selectedItem == it.key) { selectedItem = it.key } + PasswordComplexity.entries.forEach { + FullWidthRadioButtonItem(it.text, complexity == it) { complexity = it } } - Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - Privilege.DPM.requiredPasswordComplexity = selectedItem - selectedItem = Privilege.DPM.requiredPasswordComplexity + setComplexity(complexity) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) ) { Text(text = stringResource(R.string.apply)) } @@ -448,58 +371,63 @@ fun RequiredPasswordComplexityScreen(onNavigateUp: () -> Unit) { } } +data class KeyguardDisabledFeature(val id: Int, val text: Int, val requiresApi: Int = 0) +@Suppress("InlinedApi") +val keyguardDisabledFeatures = listOf( + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_WIDGETS_ALL, R.string.disable_keyguard_features_widgets), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_SECURE_CAMERA, R.string.disable_keyguard_features_camera), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_SECURE_NOTIFICATIONS, R.string.disable_keyguard_features_notification), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS, R.string.disable_keyguard_features_unredacted_notification), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_TRUST_AGENTS, R.string.disable_keyguard_features_trust_agents), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_FINGERPRINT, R.string.disable_keyguard_features_fingerprint), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_FACE, R.string.disable_keyguard_features_face, 28), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_IRIS, R.string.disable_keyguard_features_iris, 28), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_BIOMETRICS, R.string.disable_keyguard_features_biometrics, 28), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_SHORTCUTS_ALL, R.string.disable_keyguard_features_shortcuts, 34) +).filter { VERSION.SDK_INT >= it.requiresApi } + +enum class KeyguardDisableMode(val text: Int) { + None(R.string.enable_all), Custom(R.string.custom), All(R.string.disable_all) +} + +data class KeyguardDisableConfig(val mode: KeyguardDisableMode, val flags: Int) + + @Serializable object KeyguardDisabledFeatures @Composable -fun KeyguardDisabledFeaturesScreen(onNavigateUp: () -> Unit) { +fun KeyguardDisabledFeaturesScreen( + getConfig: () -> KeyguardDisableConfig, setConfig: (KeyguardDisableConfig) -> Unit, + onNavigateUp: () -> Unit +) { val context = LocalContext.current - var flag by remember { mutableIntStateOf(0) } - var mode by remember { mutableIntStateOf(0) } // 0:Enable all, 1:Disable all, 2:Custom - val flagsLiat = mutableListOf( - R.string.disable_keyguard_features_widgets to KEYGUARD_DISABLE_WIDGETS_ALL, - R.string.disable_keyguard_features_camera to KEYGUARD_DISABLE_SECURE_CAMERA, - R.string.disable_keyguard_features_notification to KEYGUARD_DISABLE_SECURE_NOTIFICATIONS, - R.string.disable_keyguard_features_unredacted_notification to KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS, - R.string.disable_keyguard_features_trust_agents to KEYGUARD_DISABLE_TRUST_AGENTS, - R.string.disable_keyguard_features_fingerprint to KEYGUARD_DISABLE_FINGERPRINT - ) - if(VERSION.SDK_INT >= 28) { - flagsLiat +=R.string.disable_keyguard_features_face to KEYGUARD_DISABLE_FACE - flagsLiat += R.string.disable_keyguard_features_iris to KEYGUARD_DISABLE_IRIS - flagsLiat += R.string.disable_keyguard_features_biometrics to KEYGUARD_DISABLE_BIOMETRICS + var mode by remember { mutableStateOf(KeyguardDisableMode.None) } + var flags by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + val config = getConfig() + mode = config.mode + flags = config.flags } - if(VERSION.SDK_INT >= 34) flagsLiat += R.string.disable_keyguard_features_shortcuts to KEYGUARD_DISABLE_SHORTCUTS_ALL - fun refresh() { - flag = Privilege.DPM.getKeyguardDisabledFeatures(Privilege.DAR) - mode = when(flag) { - KEYGUARD_DISABLE_FEATURES_NONE -> 0 - KEYGUARD_DISABLE_FEATURES_ALL -> 1 - else -> 2 - } - } - LaunchedEffect(mode) { if(mode != 2) flag = Privilege.DPM.getKeyguardDisabledFeatures(Privilege.DAR) } - LaunchedEffect(Unit) { refresh() } MyScaffold(R.string.disable_keyguard_features, onNavigateUp) { - FullWidthRadioButtonItem(R.string.enable_all, mode == 0) { mode = 0 } - FullWidthRadioButtonItem(R.string.disable_all, mode == 1) { mode = 1 } - FullWidthRadioButtonItem(R.string.custom, mode == 2) { mode = 2 } - AnimatedVisibility(mode == 2) { + KeyguardDisableMode.entries.forEach { + FullWidthRadioButtonItem(it.text, mode == it) { mode = it } + } + Spacer(Modifier.height(8.dp)) + AnimatedVisibility(mode == KeyguardDisableMode.Custom) { Column { - flagsLiat.forEach { - FullWidthCheckBoxItem(it.first, flag and it.second == it.second) { checked -> - flag = if(checked) flag or it.second else flag and (flag xor it.second) + keyguardDisabledFeatures.forEach { + FullWidthCheckBoxItem(it.text, flags and it.id == it.id) { checked -> + flags = flags xor it.id } } } } Button( onClick = { - val disabledFeatures = if(mode == 0) KEYGUARD_DISABLE_FEATURES_NONE else if(mode == 1) KEYGUARD_DISABLE_FEATURES_ALL else flag - Privilege.DPM.setKeyguardDisabledFeatures(Privilege.DAR, disabledFeatures) - refresh() + setConfig(KeyguardDisableConfig(mode, flags)) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) ) { Text(text = stringResource(R.string.apply)) } @@ -538,14 +466,3 @@ fun RequiredPasswordQualityScreen(onNavigateUp: () -> Unit) { } } } - -private fun activateToken(context: Context) { - val desc = context.getString(R.string.activate_reset_password_token_here) - val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - val confirmIntent = keyguardManager.createConfirmDeviceCredentialIntent(context.getString(R.string.app_name), desc) - if (confirmIntent != null) { - startActivity(context, confirmIntent, null) - } else { - context.showOperationResultToast(false) - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 7f0aa33..7124ce5 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -146,9 +146,6 @@ fun SystemManagerScreen( val privilege by Privilege.status.collectAsStateWithLifecycle() /** 1: reboot, 2: bug report, 3: org name, 4: org id, 5: enrollment specific id*/ var dialog by remember { mutableIntStateOf(0) } - var enrollmentSpecificId by remember { - mutableStateOf(if (VERSION.SDK_INT >= 31 && (privilege.device || privilege.profile)) Privilege.DPM.enrollmentSpecificId else "") - } MyScaffold(R.string.system, onNavigateUp, 0.dp) { FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(SystemOptions) } FunctionItem(R.string.keyguard, icon = R.drawable.screen_lock_portrait_fill0) { onNavigate(Keyguard) } @@ -195,7 +192,7 @@ fun SystemManagerScreen( if(VERSION.SDK_INT >= 31) { FunctionItem(R.string.org_id, icon = R.drawable.corporate_fare_fill0) { dialog = 4 } } - if(enrollmentSpecificId != "") { + if (VERSION.SDK_INT >= 31) { FunctionItem(R.string.enrollment_specific_id, icon = R.drawable.id_card_fill0) { dialog = 5 } } if(VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { @@ -249,7 +246,10 @@ fun SystemManagerScreen( text = { val focusMgr = LocalFocusManager.current LaunchedEffect(Unit) { - if (dialog == 5 && VERSION.SDK_INT >= 31) input = vm.getEnrollmentSpecificId() + if (dialog == 5 && VERSION.SDK_INT >= 31) { + val id = vm.getEnrollmentSpecificId() + input = id.ifEmpty { context.getString(R.string.none) } + } if (dialog == 3 && VERSION.SDK_INT >= 24) input = vm.getOrgName() } Column { @@ -290,7 +290,6 @@ fun SystemManagerScreen( if (dialog == 3 && VERSION.SDK_INT >= 24) vm.setOrgName(input) if (dialog == 4 && VERSION.SDK_INT >= 31) { context.showOperationResultToast(vm.setOrgId(input)) - enrollmentSpecificId = vm.getEnrollmentSpecificId() } dialog = 0 }, diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index e7b3dda..2fa9197 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -114,7 +114,7 @@ fun UserRestrictionScreen( Spacer(Modifier.padding(vertical = 2.dp)) UserRestrictionCategory.entries.forEach { FunctionItem(it.title, icon = it.icon) { - onNavigate(UserRestrictionOptions(it.id)) + onNavigate(UserRestrictionOptions(it.name)) } } } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bbafff7..9cec37c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -493,7 +493,7 @@ Сбросить токен пароля Токен Токен должен быть длиннее 32 байт - Токен уже активирован + Тoken activated Очистить Установить Установите токен @@ -524,7 +524,6 @@ Биометрия (слабая) Сложный числовой (без повторений) Требуемое качество пароля - Активируйте токен сброса пароля здесь. @@ -631,8 +630,6 @@ Все данные этого пользователя будут стерты, но сам пользователь не будет удален. Контролировать, может ли пользователь изменять сети, настроенные администратором.\nКогда эта блокировка включена, пользователь по-прежнему может настраивать другие сети Wi-Fi и подключаться к ним, а также использовать другие возможности Wi-Fi, такие как режим модема. Указать минимальный уровень безопасности, требуемый для сетей Wi-Fi. Устройство может не подключаться к сетям, которые не соответствуют минимальному уровню безопасности. Если текущая сеть не соответствует установленному минимальному уровню безопасности, соединение будет разорвано. - В этом режиме подсистема DNS попытается установить TLS-соединение с DNS-сервером, предоставленным сетью, прежде чем пытаться разрешить имена в открытом виде. - Будет выполнена проверка соединения с DNS-сервером, чтобы убедиться в его работоспособности.\nВ случае использования VPN совместно с частным DNS-сервером, частный DNS-сервер должен быть доступен как изнутри, так и снаружи VPN. В противном случае устройство может потерять возможность разрешать имена хостов, так как системный трафик к DNS-серверу может не проходить через VPN. Настроить постоянное VPN-соединение через определенное приложение для текущего пользователя. Это соединение автоматически предоставляется и сохраняется после перезагрузки.\nВключить блокировку: Запретить сетевое подключение, когда VPN не подключен. Этот прокси-сервер является только рекомендацией, и некоторые приложения могут его игнорировать. Журналы сети содержат события поиска DNS и вызовы библиотеки connect().\nИспользование этой функции в рабочем профиле позволит получить журналы сети только в рабочем профиле.\nНа этом устройстве не должно быть неаффилированных пользователей, если оно используется владельцем устройства. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 48ea208..2f643f3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -517,7 +517,7 @@ Parola Sıfırlama Jetonu Jeton Jeton 32 bayttan uzun olmalıdır - Jeton zaten etkinleştirildi + Token activated Temizle Ayarla Lütfen bir jeton ayarlayın @@ -548,7 +548,6 @@ Biyometrik (Zayıf) Sayısal karmaşık (tekrar eden karakterler olmadan) Gerekli Parola Kalitesi - Parola sıfırlama jetonunu burada etkinleştir. Ayarlar diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c041c00..d855c24 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -505,7 +505,8 @@ 密码重置令牌 令牌 令牌必须大于32字节 - 令牌已经激活 + 激活密码重置令牌 + 令牌已激活 清除 设置 请先设置令牌 @@ -525,7 +526,7 @@ 禁用可信代理 禁用指纹解锁 禁用人脸解锁 - 禁用虹膜解锁(?) + 禁用虹膜解锁 禁用生物识别 禁用快捷方式 未指定 @@ -536,7 +537,6 @@ 生物识别(弱) 复杂数字(无连续性) 密码质量要求 - 在这里激活密码重置令牌 设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be8e8da..cf68326 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -540,8 +540,9 @@ Unified password Reset password token Token - The token must be longer than 32-byte - Token already activated + The token must be longer than 32 byte + Activate reset password token + Token activated Clear Set Please set a token @@ -572,7 +573,6 @@ Biometrics (Weak) Numeric complex (No repeating characters) Required password quality - Activate reset password token here. Settings From 9e1d18b8e75ff0b114d991c1e5b370b09b774a26 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sun, 12 Oct 2025 13:16:50 +0800 Subject: [PATCH 12/26] ViewModel refactoring: Settings part User restriction shortcuts Optimize ShortcutUtils Fix Private DNS bug --- .../com/bintianqi/owndroid/ApiReceiver.kt | 7 +- .../com/bintianqi/owndroid/MainActivity.kt | 30 +- .../com/bintianqi/owndroid/MyApplication.kt | 1 + .../com/bintianqi/owndroid/MyViewModel.kt | 61 +++- .../bintianqi/owndroid/NotificationUtils.kt | 74 +++-- .../java/com/bintianqi/owndroid/Receiver.kt | 62 ++-- .../java/com/bintianqi/owndroid/Settings.kt | 264 +++++++++--------- .../com/bintianqi/owndroid/SharedPrefs.kt | 5 +- .../com/bintianqi/owndroid/ShortcutUtils.kt | 46 ++- .../owndroid/ShortcutsReceiverActivity.kt | 17 +- .../owndroid/UserRestrictionsRepository.kt | 7 + .../main/java/com/bintianqi/owndroid/Utils.kt | 12 +- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 6 +- .../com/bintianqi/owndroid/dpm/Network.kt | 18 +- .../com/bintianqi/owndroid/dpm/Password.kt | 18 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 10 +- .../bintianqi/owndroid/dpm/UserRestriction.kt | 26 +- .../java/com/bintianqi/owndroid/dpm/Users.kt | 4 +- app/src/main/res/values-ru/strings.xml | 4 - app/src/main/res/values-tr/strings.xml | 4 - app/src/main/res/values-zh-rCN/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- 22 files changed, 420 insertions(+), 266 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt index 58b0699..550df66 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt @@ -9,10 +9,9 @@ import android.util.Log class ApiReceiver: BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val requestKey = intent.getStringExtra("key") - var log = "OwnDroid API request received. action: ${intent.action}\nkey: $requestKey" - if(!SP.isApiEnabled) return - val key = SP.apiKey - if(!key.isNullOrEmpty() && key == requestKey) { + var log = "OwnDroid API request received. action: ${intent.action}" + val key = SP.apiKeyHash + if(!key.isNullOrEmpty() && key == requestKey?.hash()) { val app = intent.getStringExtra("package") val permission = intent.getStringExtra("permission") val restriction = intent.getStringExtra("restriction") diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index b060f42..9e89f07 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -1,9 +1,11 @@ package com.bintianqi.owndroid +import android.Manifest import android.os.Build.VERSION import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -240,6 +242,10 @@ class MainActivity : FragmentActivity() { val locale = context.resources?.configuration?.locale zhCN = locale == Locale.SIMPLIFIED_CHINESE || locale == Locale.CHINESE || locale == Locale.CHINA val vm by viewModels() + if (VERSION.SDK_INT >= 33) { + val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } setContent { var appLockDialog by rememberSaveable { mutableStateOf(false) } val theme by vm.theme.collectAsStateWithLifecycle() @@ -576,7 +582,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } composable { UserRestrictionOptionsScreen(it.toRoute(), vm.userRestrictions, - vm::setUserRestriction, ::navigateUp) + vm::setUserRestriction, vm::createUserRestrictionShortcut, ::navigateUp) } composable { UsersScreen(vm, ::navigateUp, ::navigate) } @@ -619,14 +625,22 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { RequiredPasswordQualityScreen(::navigateUp) } composable { SettingsScreen(::navigateUp, ::navigate) } - composable { SettingsOptionsScreen(::navigateUp) } - composable { - val theme by vm.theme.collectAsStateWithLifecycle() - AppearanceScreen(::navigateUp, theme, vm::changeTheme) + composable { + SettingsOptionsScreen(vm::getDisplayDangerousFeatures, vm::getShortcutsEnabled, + vm::setDisplayDangerousFeatures, vm::setShortcutsEnabled, ::navigateUp) + } + composable { + AppearanceScreen(::navigateUp, vm.theme, vm::changeTheme) + } + composable { + AppLockSettingsScreen(vm::getAppLockConfig, vm::setAppLockConfig, ::navigateUp) + } + composable { + ApiSettings(vm::getApiEnabled, vm::setApiKey, ::navigateUp) + } + composable { + NotificationsScreen(vm::getEnabledNotifications, vm::setNotificationEnabled, ::navigateUp) } - composable { AppLockSettingsScreen(::navigateUp) } - composable { ApiSettings(::navigateUp) } - composable { NotificationsScreen(::navigateUp) } composable { AboutScreen(::navigateUp) } } DisposableEffect(lifecycleOwner) { diff --git a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt index 9093ec7..d6105c3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt @@ -13,6 +13,7 @@ class MyApplication : Application() { val dbHelper = MyDbHelper(this) myRepo = MyRepository(dbHelper) Privilege.initialize(applicationContext) + NotificationUtils.createChannels(this) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 3ab7410..dcea517 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -122,6 +122,7 @@ import kotlin.reflect.jvm.jvmErasure class MyViewModel(application: Application): AndroidViewModel(application) { val myRepo = getApplication().myRepo val PM = application.packageManager + val theme = MutableStateFlow(ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme)) fun changeTheme(newTheme: ThemeSettings) { theme.value = newTheme @@ -129,6 +130,54 @@ class MyViewModel(application: Application): AndroidViewModel(application) { SP.darkTheme = newTheme.darkTheme SP.blackTheme = newTheme.blackTheme } + fun getDisplayDangerousFeatures(): Boolean { + return SP.displayDangerousFeatures + } + fun getShortcutsEnabled(): Boolean { + return SP.shortcuts + } + fun setDisplayDangerousFeatures(state: Boolean) { + SP.displayDangerousFeatures = state + } + fun setShortcutsEnabled(enabled: Boolean) { + SP.shortcuts = enabled + ShortcutUtils.setAllShortcuts(application, enabled) + } + fun getAppLockConfig(): AppLockConfig { + val passwordHash = SP.lockPasswordHash + return AppLockConfig(passwordHash?.ifEmpty { null }, SP.biometricsUnlock, SP.lockWhenLeaving) + } + fun setAppLockConfig(config: AppLockConfig) { + SP.lockPasswordHash = if (config.password == null) { + "" + } else { + config.password.hash() + } + SP.biometricsUnlock = config.biometrics + SP.lockWhenLeaving = config.whenLeaving + } + fun getApiEnabled(): Boolean { + return SP.apiKeyHash?.isNotEmpty() ?: false + } + fun setApiKey(key: String) { + SP.apiKeyHash = if (key.isEmpty()) "" else key.hash() + } + fun getEnabledNotifications(): List { + val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } + return if (list == null) { + NotificationType.entries + } else { + NotificationType.entries.filter { it.id in list } + } + } + fun setNotificationEnabled(type: NotificationType, enabled: Boolean) { + val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } + SP.notifications = if (list == null) { + NotificationType.entries.minus(type).map { it.id } + } else { + list.run { if (enabled) plus(type.id) else minus(type.id) } + }.joinToString { it.toString() } + } val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST) @@ -1083,11 +1132,17 @@ class MyViewModel(application: Application): AndroidViewModel(application) { DPM.clearUserRestriction(DAR, name) } userRestrictions.update { it.plus(name to state) } + ShortcutUtils.updateUserRestrictionShortcut(application, name, !state, true) true } catch (_: SecurityException) { false } } + fun createUserRestrictionShortcut(id: String): Boolean { + return ShortcutUtils.setUserRestrictionShortcut( + application, id, userRestrictions.value[id] ?: true + ) + } fun createWorkProfile(options: CreateWorkProfileOptions): Intent { val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) if (VERSION.SDK_INT >= 23) { @@ -1478,7 +1533,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun getPrivateDns(): PrivateDnsConfiguration { val mode = DPM.getGlobalPrivateDnsMode(DAR) return PrivateDnsConfiguration( - PrivateDnsMode.entries.find { it.id == mode }!!, DPM.getGlobalPrivateDnsHost(DAR) ?: "" + PrivateDnsMode.entries.find { it.id == mode }, DPM.getGlobalPrivateDnsHost(DAR) ?: "" ) } @Suppress("PrivateApi") @@ -1489,7 +1544,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { field.isAccessible = true val dpm = field.get(DPM) as IDevicePolicyManager val host = if (conf.mode == PrivateDnsMode.Host) conf.host else null - val result = dpm.setGlobalPrivateDns(DAR, conf.mode.id, host) + val result = dpm.setGlobalPrivateDns(DAR, conf.mode!!.id, host) result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR } catch (e: Exception) { e.printStackTrace() @@ -1669,7 +1724,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun getRpTokenState(): RpTokenState { return try { RpTokenState(true, DPM.isResetPasswordTokenActive(DAR)) - } catch (_: IllegalArgumentException) { + } catch (_: IllegalStateException) { RpTokenState(false, false) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt index 9cc55d8..0ed50a6 100644 --- a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt @@ -7,6 +7,7 @@ import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager import android.os.Build +import androidx.core.app.NotificationCompat object NotificationUtils { fun checkPermission(context: Context): Boolean { @@ -14,36 +15,57 @@ object NotificationUtils { context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED else false } - fun registerChannels(context: Context) { - if(Build.VERSION.SDK_INT < 26) return + fun createChannels(context: Context) { + if (Build.VERSION.SDK_INT < 26) return val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val lockTaskMode = NotificationChannel(Channel.LOCK_TASK_MODE, context.getString(R.string.lock_task_mode), NotificationManager.IMPORTANCE_HIGH) - val events = NotificationChannel(Channel.EVENTS, context.getString(R.string.events), NotificationManager.IMPORTANCE_HIGH) + val lockTaskMode = NotificationChannel( + MyNotificationChannel.LockTaskMode.id, + context.getString(MyNotificationChannel.LockTaskMode.text), + NotificationManager.IMPORTANCE_HIGH + ) + val events = NotificationChannel( + MyNotificationChannel.Events.id, + context.getString(MyNotificationChannel.Events.text), + NotificationManager.IMPORTANCE_HIGH + ) nm.createNotificationChannels(listOf(lockTaskMode, events)) } - fun notify(context: Context, id: Int, notification: Notification) { - val sp = context.getSharedPreferences("data", Context.MODE_PRIVATE) - if(sp.getBoolean("n_$id", true) && checkPermission(context)) { - registerChannels(context) + fun notifyEvent(context: Context, type: NotificationType, text: String) { + val notification = NotificationCompat.Builder(context, MyNotificationChannel.Events.id) + .setSmallIcon(type.icon) + .setContentTitle(context.getString(type.text)) + .setContentText(text) + .build() + notify(context, type, notification) + } + fun notify(context: Context, type: NotificationType, notification: Notification) { + val enabledNotifications = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } + if (enabledNotifications == null || type.id in enabledNotifications) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(id, notification) + nm.notify(type.id, notification) } } - object Channel { - const val LOCK_TASK_MODE = "LockTaskMode" - const val EVENTS = "Events" + fun cancel(context: Context, type: NotificationType) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.cancel(type.id) } - object ID { - const val LOCK_TASK_MODE = 1 - const val PASSWORD_CHANGED = 2 - const val USER_ADDED = 3 - const val USER_STARTED = 4 - const val USER_SWITCHED = 5 - const val USER_STOPPED = 6 - const val USER_REMOVED = 7 - const val BUG_REPORT_SHARED = 8 - const val BUG_REPORT_SHARING_DECLINED = 9 - const val BUG_REPORT_FAILED = 10 - const val SYSTEM_UPDATE_PENDING = 11 - } -} \ No newline at end of file +} + +enum class NotificationType(val id: Int, val text: Int, val icon: Int) { + LockTaskMode(1, R.string.lock_task_mode, R.drawable.lock_fill0), + PasswordChanged(2, R.string.password_changed, R.drawable.password_fill0), + UserAdded(3, R.string.user_added, R.drawable.person_add_fill0), + UserStarted(4, R.string.user_started, R.drawable.person_fill0), + UserSwitched(5, R.string.user_switched, R.drawable.person_fill0), + UserStopped(6, R.string.user_stopped, R.drawable.person_off), + UserRemoved(7, R.string.user_removed, R.drawable.person_remove_fill0), + BugReportShared(8, R.string.bug_report_shared, R.drawable.bug_report_fill0), + BugReportSharingDeclined(9, R.string.bug_report_sharing_declined, R.drawable.bug_report_fill0), + BugReportFailed(10, R.string.bug_report_failed, R.drawable.bug_report_fill0), + SystemUpdatePending(11, R.string.system_update_pending, R.drawable.system_update_fill0) +} + +enum class MyNotificationChannel(val id: String, val text: Int) { + LockTaskMode("LockTaskMode", R.string.lock_task_mode), + Events("Events", R.string.events) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index bcd078c..7dbadb7 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -1,6 +1,5 @@ package com.bintianqi.owndroid -import android.app.NotificationManager import android.app.PendingIntent import android.app.admin.DeviceAdminReceiver import android.content.ComponentName @@ -17,9 +16,6 @@ import com.bintianqi.owndroid.dpm.processSecurityLogs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale class Receiver : DeviceAdminReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -75,68 +71,59 @@ class Receiver : DeviceAdminReceiver() { override fun onLockTaskModeEntering(context: Context, intent: Intent, pkg: String) { super.onLockTaskModeEntering(context, intent, pkg) - if(!NotificationUtils.checkPermission(context)) return - NotificationUtils.registerChannels(context) + if (!NotificationUtils.checkPermission(context)) return val intent = Intent(context, this::class.java).setAction("com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.LOCK_TASK_MODE) + val builder = NotificationCompat.Builder(context, MyNotificationChannel.LockTaskMode.id) .setContentTitle(context.getText(R.string.lock_task_mode)) .setSmallIcon(R.drawable.lock_fill0) .addAction(NotificationCompat.Action.Builder(null, context.getString(R.string.stop), pendingIntent).build()) - NotificationUtils.notify(context, NotificationUtils.ID.LOCK_TASK_MODE, builder.build()) + NotificationUtils.notify(context, NotificationType.LockTaskMode, builder.build()) } override fun onLockTaskModeExiting(context: Context, intent: Intent) { super.onLockTaskModeExiting(context, intent) - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.cancel(NotificationUtils.ID.LOCK_TASK_MODE) + NotificationUtils.cancel(context, NotificationType.LockTaskMode) } override fun onPasswordChanged(context: Context, intent: Intent, userHandle: UserHandle) { super.onPasswordChanged(context, intent, userHandle) - sendUserRelatedNotification(context, userHandle, NotificationUtils.ID.PASSWORD_CHANGED, R.string.password_changed, R.drawable.password_fill0) + sendUserRelatedNotification(context, userHandle, NotificationType.PasswordChanged) } override fun onUserAdded(context: Context, intent: Intent, addedUser: UserHandle) { super.onUserAdded(context, intent, addedUser) - sendUserRelatedNotification(context, addedUser, NotificationUtils.ID.USER_ADDED, R.string.user_added, R.drawable.person_add_fill0) + sendUserRelatedNotification(context, addedUser, NotificationType.UserAdded) } override fun onUserStarted(context: Context, intent: Intent, startedUser: UserHandle) { super.onUserStarted(context, intent, startedUser) - sendUserRelatedNotification(context, startedUser, NotificationUtils.ID.USER_STARTED, R.string.user_started, R.drawable.person_fill0) + sendUserRelatedNotification(context, startedUser, NotificationType.UserStarted) } override fun onUserSwitched(context: Context, intent: Intent, switchedUser: UserHandle) { super.onUserSwitched(context, intent, switchedUser) - sendUserRelatedNotification(context, switchedUser, NotificationUtils.ID.USER_SWITCHED, R.string.user_switched, R.drawable.person_fill0) + sendUserRelatedNotification(context, switchedUser, NotificationType.UserSwitched) } override fun onUserStopped(context: Context, intent: Intent, stoppedUser: UserHandle) { super.onUserStopped(context, intent, stoppedUser) - sendUserRelatedNotification(context, stoppedUser, NotificationUtils.ID.USER_STOPPED, R.string.user_stopped, R.drawable.person_fill0) + sendUserRelatedNotification(context, stoppedUser, NotificationType.UserStopped) } override fun onUserRemoved(context: Context, intent: Intent, removedUser: UserHandle) { super.onUserRemoved(context, intent, removedUser) - sendUserRelatedNotification(context, removedUser, NotificationUtils.ID.USER_REMOVED, R.string.user_removed, R.drawable.person_remove_fill0) + sendUserRelatedNotification(context, removedUser, NotificationType.UserRemoved) } override fun onBugreportShared(context: Context, intent: Intent, hash: String) { super.onBugreportShared(context, intent, hash) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_shared)) - .setContentText("SHA-256 hash: $hash") - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_SHARED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportShared, "SHA-256 hash: $hash") } override fun onBugreportSharingDeclined(context: Context, intent: Intent) { super.onBugreportSharingDeclined(context, intent) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_sharing_declined)) - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_SHARING_DECLINED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportSharingDeclined, "") } override fun onBugreportFailed(context: Context, intent: Intent, failureCode: Int) { @@ -146,30 +133,21 @@ class Receiver : DeviceAdminReceiver() { BUGREPORT_FAILURE_FILE_NO_LONGER_AVAILABLE -> R.string.bug_report_failure_no_longer_available else -> R.string.place_holder } - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_failed)) - .setContentText(context.getString(message)) - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_FAILED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportFailed, context.getString(message)) } override fun onSystemUpdatePending(context: Context, intent: Intent, receivedTime: Long) { super.onSystemUpdatePending(context, intent, receivedTime) - val time = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(Date(receivedTime)) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.system_update_pending)) - .setContentText(context.getString(R.string.received_time) + ": $time") - .setSmallIcon(R.drawable.system_update_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.SYSTEM_UPDATE_PENDING, builder.build()) + val text = context.getString(R.string.received_time) + ": " + formatDate(receivedTime) + NotificationUtils.notifyEvent(context, NotificationType.SystemUpdatePending, text) } - private fun sendUserRelatedNotification(context: Context, userHandle: UserHandle, id: Int, title: Int, icon: Int) { + private fun sendUserRelatedNotification( + context: Context, userHandle: UserHandle, type: NotificationType + ) { val um = context.getSystemService(Context.USER_SERVICE) as UserManager val serial = um.getSerialNumberForUser(userHandle) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(title)) - .setContentText(context.getString(R.string.serial_number) + ": $serial") - .setSmallIcon(icon) - NotificationUtils.notify(context, id, builder.build()) + val text = context.getString(R.string.serial_number) + ": $serial" + NotificationUtils.notifyEvent(context, type, text) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/Settings.kt index 7691225..24e0185 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Settings.kt @@ -17,9 +17,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -39,24 +37,23 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll 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.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.core.content.edit import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.ui.FunctionItem @@ -64,8 +61,8 @@ import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable -import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -142,20 +139,25 @@ fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { @Serializable object SettingsOptions @Composable -fun SettingsOptionsScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun SettingsOptionsScreen( + getDisplayDangerousFeatures: () -> Boolean, getShortcutsEnabled: () -> Boolean, + setDisplayDangerousFeatures: (Boolean) -> Unit, setShortcutsEnabled: (Boolean) -> Unit, + onNavigateUp: () -> Unit +) { + var dangerousFeatures by remember { mutableStateOf(getDisplayDangerousFeatures()) } + var shortcuts by remember { mutableStateOf(getShortcutsEnabled()) } MyScaffold(R.string.options, onNavigateUp, 0.dp) { SwitchItem( - R.string.show_dangerous_features, icon = R.drawable.warning_fill0, - getState = { SP.displayDangerousFeatures }, - onCheckedChange = { SP.displayDangerousFeatures = it } + R.string.show_dangerous_features, dangerousFeatures, { + setDisplayDangerousFeatures(it) + dangerousFeatures = it + }, R.drawable.warning_fill0 ) SwitchItem( - R.string.shortcuts, icon = R.drawable.open_in_new, - getState = { SP.shortcuts }, onCheckedChange = { - SP.shortcuts = it - ShortcutUtils.setAllShortcuts(context) - } + R.string.shortcuts, shortcuts, { + setShortcutsEnabled(it) + shortcuts = it + }, R.drawable.open_in_new ) } } @@ -163,13 +165,12 @@ fun SettingsOptionsScreen(onNavigateUp: () -> Unit) { @Serializable object Appearance @Composable -fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onThemeChange: (ThemeSettings) -> Unit) { +fun AppearanceScreen( + onNavigateUp: () -> Unit, currentTheme: StateFlow, + setTheme: (ThemeSettings) -> Unit +) { var darkThemeMenu by remember { mutableStateOf(false) } - var theme by remember { mutableStateOf(currentTheme) } - fun update(it: ThemeSettings) { - theme = it - onThemeChange(it) - } + val theme by currentTheme.collectAsStateWithLifecycle() val darkThemeTextID = when(theme.darkTheme) { 1 -> R.string.on 0 -> R.string.off @@ -180,7 +181,7 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh SwitchItem( R.string.material_you_color, state = theme.materialYou, - onCheckedChange = { update(theme.copy(materialYou = it)) } + onCheckedChange = { setTheme(theme.copy(materialYou = it)) } ) } Box { @@ -192,22 +193,21 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh DropdownMenuItem( text = { Text(stringResource(R.string.follow_system)) }, onClick = { - update(theme.copy(darkTheme = -1)) + setTheme(theme.copy(darkTheme = -1)) darkThemeMenu = false } ) DropdownMenuItem( text = { Text(stringResource(R.string.on)) }, onClick = { - update(theme.copy(darkTheme = 1)) - theme = theme.copy(darkTheme = 1) + setTheme(theme.copy(darkTheme = 1)) darkThemeMenu = false } ) DropdownMenuItem( text = { Text(stringResource(R.string.off)) }, onClick = { - update(theme.copy(darkTheme = 0)) + setTheme(theme.copy(darkTheme = 0)) darkThemeMenu = false } ) @@ -216,148 +216,138 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh AnimatedVisibility(theme.darkTheme == 1 || (theme.darkTheme == -1 && isSystemInDarkTheme())) { SwitchItem( R.string.black_theme, state = theme.blackTheme, - onCheckedChange = { update(theme.copy(blackTheme = it)) } + onCheckedChange = { setTheme(theme.copy(blackTheme = it)) } ) } } } +data class AppLockConfig( + /** null means no password, empty means password already set */ + val password: String?, val biometrics: Boolean, val whenLeaving: Boolean +) + @Serializable object AppLockSettings @Composable -fun AppLockSettingsScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.app_lock, onNavigateUp, 0.dp) { - val fm = LocalFocusManager.current +fun AppLockSettingsScreen( + getConfig: () -> AppLockConfig, setConfig: (AppLockConfig) -> Unit, + onNavigateUp: () -> Unit +) = MyScaffold(R.string.app_lock, onNavigateUp) { var password by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") } - var allowBiometrics by remember { mutableStateOf(SP.biometricsUnlock) } - var lockWhenLeaving by remember { mutableStateOf(SP.lockWhenLeaving) } - val fr = remember { FocusRequester() } - val alreadySet = !SP.lockPasswordHash.isNullOrEmpty() - val isInputLegal = password.length !in 1..3 && (alreadySet || (password.isNotEmpty() && password.isNotBlank())) - Column(Modifier - .widthIn(max = 300.dp) - .align(Alignment.CenterHorizontally)) { - OutlinedTextField( - password, { password = it }, Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - label = { Text(stringResource(R.string.password)) }, - supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() } - ) - OutlinedTextField( - confirmPassword, { confirmPassword = it }, Modifier - .fillMaxWidth() - .focusRequester(fr), - label = { Text(stringResource(R.string.confirm_password)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) - if(VERSION.SDK_INT >= 28) Row(Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.allow_biometrics)) - Switch(allowBiometrics, { allowBiometrics = it }) - } - Row(Modifier - .fillMaxWidth() - .padding(bottom = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.lock_when_leaving)) - Switch(lockWhenLeaving, { lockWhenLeaving = it }) - } - Button( - onClick = { - fm.clearFocus() - if(password.isNotEmpty()) SP.lockPasswordHash = password.hash() - SP.biometricsUnlock = allowBiometrics - SP.lockWhenLeaving = lockWhenLeaving - onNavigateUp() - }, - modifier = Modifier.fillMaxWidth(), - enabled = isInputLegal && confirmPassword == password - ) { - Text(stringResource(if(alreadySet) R.string.update else R.string.set)) - } - if(alreadySet) FilledTonalButton( - onClick = { - fm.clearFocus() - SP.lockPasswordHash = "" - SP.biometricsUnlock = false - SP.lockWhenLeaving = false - onNavigateUp() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.disable)) - } + var allowBiometrics by remember { mutableStateOf(false) } + var lockWhenLeaving by remember { mutableStateOf(false) } + var alreadySet by remember { mutableStateOf(false) } + val isInputLegal = password.length !in 1..3 && (alreadySet || password.isNotBlank()) + LaunchedEffect(Unit) { + val config = getConfig() + password = config.password ?: "" + allowBiometrics = config.biometrics + lockWhenLeaving = config.whenLeaving + alreadySet = config.password != null + } + OutlinedTextField( + password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + label = { Text(stringResource(R.string.password)) }, + supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next) + ) + OutlinedTextField( + confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 28) Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.allow_biometrics)) + Switch(allowBiometrics, { allowBiometrics = it }) + } + Row( + Modifier.fillMaxWidth().padding(bottom = 6.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.lock_when_leaving)) + Switch(lockWhenLeaving, { lockWhenLeaving = it }) + } + Button( + onClick = { + setConfig(AppLockConfig(password, allowBiometrics, lockWhenLeaving)) + onNavigateUp() + }, + modifier = Modifier.fillMaxWidth(), + enabled = isInputLegal && confirmPassword == password + ) { + Text(stringResource(if(alreadySet) R.string.update else R.string.set)) + } + if (alreadySet) FilledTonalButton( + onClick = { + setConfig(AppLockConfig(null, false, false)) + onNavigateUp() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.disable)) } } @Serializable object ApiSettings @Composable -fun ApiSettings(onNavigateUp: () -> Unit) { +fun ApiSettings( + getEnabled: () -> Boolean, setKey: (String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var alreadyEnabled by remember { mutableStateOf(getEnabled()) } MyScaffold(R.string.api, onNavigateUp) { - var enabled by remember { mutableStateOf(SP.isApiEnabled) } + var enabled by remember { mutableStateOf(alreadyEnabled) } + var key by remember { mutableStateOf("") } SwitchItem(R.string.enable, state = enabled, onCheckedChange = { enabled = it - SP.isApiEnabled = it - if(!it) SP.sharedPrefs.edit { remove("api.key") } }, padding = false) - if(enabled) { - var key by remember { mutableStateOf("") } + if (enabled) { OutlinedTextField( - value = key, onValueChange = { key = it }, label = { Text(stringResource(R.string.api_key)) }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), readOnly = true, + key, { key = it }, Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.api_key)) }, trailingIcon = { - IconButton( - onClick = { - val charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - val sr = SecureRandom() - key = (1..20).map { charset[sr.nextInt(charset.length)] }.joinToString("") - } - ) { - Icon(painter = painterResource(R.drawable.casino_fill0), contentDescription = "Random") + IconButton({ key = generateBase64Key(10) }) { + Icon(painterResource(R.drawable.casino_fill0), null) } } ) - Button( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp), - onClick = { - SP.apiKey = key - context.showOperationResultToast(true) - }, - enabled = key.isNotEmpty() - ) { - Text(stringResource(R.string.apply)) - } - if(SP.apiKey != null) Notes(R.string.api_key_exist) } + Button( + onClick = { + setKey(if (enabled) key else "") + alreadyEnabled = enabled + context.showOperationResultToast(true) + }, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + enabled = !enabled || key.length !in 0..7 + ) { + Text(stringResource(R.string.apply)) + } + if (enabled && alreadyEnabled) Notes(R.string.api_key_exist) } } @Serializable object Notifications @Composable -fun NotificationsScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { - val sp = LocalContext.current.getSharedPreferences("data", Context.MODE_PRIVATE) - val map = mapOf( - NotificationUtils.ID.PASSWORD_CHANGED to R.string.password_changed, NotificationUtils.ID.USER_ADDED to R.string.user_added, - NotificationUtils.ID.USER_STARTED to R.string.user_started, NotificationUtils.ID.USER_SWITCHED to R.string.user_switched, - NotificationUtils.ID.USER_STOPPED to R.string.user_stopped, NotificationUtils.ID.USER_REMOVED to R.string.user_removed, - NotificationUtils.ID.BUG_REPORT_SHARED to R.string.bug_report_shared, - NotificationUtils.ID.BUG_REPORT_SHARING_DECLINED to R.string.bug_report_sharing_declined, - NotificationUtils.ID.BUG_REPORT_FAILED to R.string.bug_report_failed, - NotificationUtils.ID.SYSTEM_UPDATE_PENDING to R.string.system_update_pending - ) - map.forEach { (k, v) -> - SwitchItem(v, getState = { sp.getBoolean("n_$k", true) }, onCheckedChange = { sp.edit(true) { putBoolean("n_$k", it) } }) +fun NotificationsScreen( + getState: () -> List, setNotification: (NotificationType, Boolean) -> Unit, + onNavigateUp: () -> Unit +) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { + val enabledNotifications = remember { mutableStateListOf(*getState().toTypedArray()) } + NotificationType.entries.forEach { type -> + SwitchItem(type.text, type in enabledNotifications, { + setNotification(type, it) + enabledNotifications.run { if (it) plusAssign(type) else minusAssign(type) } + }) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt index 2dd9369..27a880e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt +++ b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt @@ -13,8 +13,7 @@ class SharedPrefs(context: Context) { var dhizuku by BooleanSharedPref("dhizuku_mode") var isDefaultAffiliationIdSet by BooleanSharedPref("default_affiliation_id_set") var displayDangerousFeatures by BooleanSharedPref("display_dangerous_features") - var isApiEnabled by BooleanSharedPref("api.enabled") - var apiKey by StringSharedPref("api.key") + var apiKeyHash by StringSharedPref("api_key_hash") var materialYou by BooleanSharedPref("theme.material_you", Build.VERSION.SDK_INT >= 31) /** -1: follow system, 0: off, 1: on */ var darkTheme by IntSharedPref("theme.dark", -1) @@ -25,6 +24,8 @@ class SharedPrefs(context: Context) { var applicationsListView by BooleanSharedPref("applications.list_view", true) var shortcuts by BooleanSharedPref("shortcuts") var dhizukuServer by BooleanSharedPref("dhizuku_server") + var notifications by StringSharedPref("notifications") + var shortcutKey by StringSharedPref("shortcut_key") } private class BooleanSharedPref(val key: String, val defValue: Boolean = false): ReadWriteProperty { diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt index 45ca5fa..edc0056 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt @@ -5,10 +5,13 @@ import android.content.Intent import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat +import java.security.SecureRandom +import kotlin.io.encoding.Base64 object ShortcutUtils { - fun setAllShortcuts(context: Context) { - if (SP.shortcuts) { + fun setAllShortcuts(context: Context, enabled: Boolean) { + if (enabled) { + setShortcutKey() val list = listOf( createShortcut(context, MyShortcut.Lock, true), createShortcut(context, MyShortcut.DisableCamera, @@ -22,6 +25,7 @@ object ShortcutUtils { } } fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) { + setShortcutKey() ShortcutManagerCompat.pushDynamicShortcut( context, createShortcut(context, shortcut, state) ) @@ -41,9 +45,47 @@ object ShortcutUtils { .setIntent( Intent(context, ShortcutsReceiverActivity::class.java) .setAction("com.bintianqi.owndroid.action.${shortcut.id}") + .putExtra("key", SP.shortcutKey) ) .build() } + /** @param state If true, set the user restriction */ + fun createUserRestrictionShortcut(context: Context, id: String, state: Boolean): ShortcutInfoCompat { + val restriction = UserRestrictionsRepository.findRestrictionById(id) + val label = context.getString(if (state) R.string.disable else R.string.enable) + " " + + context.getString(restriction.name) + setShortcutKey() + return ShortcutInfoCompat.Builder(context, "USER_RESTRICTION-$id") + .setIcon(IconCompat.createWithResource(context, restriction.icon)) + .setShortLabel(label) + .setIntent( + Intent(context, ShortcutsReceiverActivity::class.java) + .setAction("com.bintianqi.owndroid.action.USER_RESTRICTION") + .putExtra("restriction", id) + .putExtra("state", state) + .putExtra("key", SP.shortcutKey) + ) + .build() + } + fun setUserRestrictionShortcut(context: Context, id: String, state: Boolean): Boolean { + val shortcut = createUserRestrictionShortcut(context, id, state) + return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + } + fun updateUserRestrictionShortcut(context: Context, id: String, state: Boolean, checkExist: Boolean) { + if (checkExist) { + val shortcuts = ShortcutManagerCompat.getShortcuts( + context, ShortcutManagerCompat.FLAG_MATCH_PINNED + ) + if (shortcuts.find { it.id == "USER_RESTRICTION-$id" } == null) return + } + val shortcut = createUserRestrictionShortcut(context, id, state) + ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut)) + } + fun setShortcutKey() { + if (SP.shortcutKey.isNullOrEmpty()) { + SP.shortcutKey = generateBase64Key(10) + } + } } enum class MyShortcut( diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt index 780adb1..7c1c7cd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt @@ -9,7 +9,9 @@ class ShortcutsReceiverActivity : Activity() { super.onCreate(savedInstanceState) try { val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.") - if (action != null && SP.shortcuts) { + val key = SP.shortcutKey + val requestKey = intent?.getStringExtra("key") + if (action != null && SP.shortcuts && key != null && requestKey == key) { when (action) { "LOCK" -> Privilege.DPM.lockNow() "DISABLE_CAMERA" -> { @@ -22,9 +24,22 @@ class ShortcutsReceiverActivity : Activity() { Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !state) ShortcutUtils.setShortcut(this, MyShortcut.Mute, state) } + "USER_RESTRICTION" -> { + val state = intent?.getBooleanExtra("state", false) + val id = intent?.getStringExtra("restriction") + if (state == null || id == null) return + if (state) { + Privilege.DPM.addUserRestriction(Privilege.DAR, id) + } else { + Privilege.DPM.clearUserRestriction(Privilege.DAR, id) + } + ShortcutUtils.updateUserRestrictionShortcut(this, id, !state, false) + } } Log.d(TAG, "Received intent: $action") showOperationResultToast(true) + } else { + showOperationResultToast(false) } } finally { finish() diff --git a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt index 8f18f52..e00916b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt @@ -98,6 +98,13 @@ object UserRestrictionsRepository { UserRestrictionCategory.Other -> other }.filter { Build.VERSION.SDK_INT >= it.requiresApi } } + fun findRestrictionById(id: String): Restriction { + listOf(network, connectivity, applications, media, users, other).forEach { list -> + val restriction = list.find { it.id == id } + if (restriction != null) return restriction + } + throw Exception("User restriction not found") + } } enum class UserRestrictionCategory(val title: Int, val icon: Int) { diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index 88a3e3d..ed81a05 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -18,10 +18,12 @@ import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.security.MessageDigest +import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit +import kotlin.io.encoding.Base64 var zhCN = true @@ -64,8 +66,8 @@ fun formatFileSize(bytes: Long): String { val Boolean.yesOrNo @StringRes get() = if(this) R.string.yes else R.string.no -fun formatTime(ms: Long): String { - return SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(Date(ms)) +fun formatDate(ms: Long): String { + return formatDate(Date(ms)) } fun formatDate(date: Date): String { return SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(date) @@ -121,3 +123,9 @@ class SerializableSaver(val serializer: KSerializer) : Saver { return Json.encodeToString(serializer, value) } } + +fun generateBase64Key(length: Int): String { + val ba = ByteArray(length) + SecureRandom().nextBytes(ba) + return Base64.withPadding(Base64.PaddingOption.ABSENT).encode(ba) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index ba81245..0e2214e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -443,13 +443,13 @@ fun handlePrivilegeChange(context: Context) { SP.dhizukuServer = false SP.shortcuts = privilege.activated if (privilege.activated) { - ShortcutUtils.setAllShortcuts(context) + ShortcutUtils.setAllShortcuts(context, true) if (!privilege.dhizuku) { setDefaultAffiliationID() } } else { SP.isDefaultAffiliationIdSet = false - ShortcutUtils.setAllShortcuts(context) - SP.isApiEnabled = false + ShortcutUtils.setAllShortcuts(context, false) + SP.apiKeyHash = "" } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 1f4cef1..df3e91f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -70,7 +70,6 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -115,7 +114,7 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.formatTime +import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.ErrorDialog @@ -511,7 +510,7 @@ fun UpdateNetworkScreen(info: WifiInfo, setNetwork: (WifiInfo) -> Boolean, onNav TopAppBar( { Text(stringResource(R.string.update_network)) }, navigationIcon = { NavIcon(onNavigateUp) }, - colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) + colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) ) }, contentWindowInsets = WindowInsets.ime @@ -1046,14 +1045,14 @@ fun NetworkStatsScreen( } } OutlinedTextField( - value = startTime.let { if(it == -1L) "" else formatTime(it) }, onValueChange = {}, readOnly = true, + value = startTime.let { if(it == -1L) "" else formatDate(it) }, onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.start_time)) }, interactionSource = startTimeIs, isError = startTime >= endTime, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) ) OutlinedTextField( - value = formatTime(endTime), onValueChange = {}, readOnly = true, + value = formatDate(endTime), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.end_time)) }, interactionSource = endTimeIs, isError = startTime >= endTime, @@ -1292,7 +1291,7 @@ fun NetworkStatsViewerScreen( HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page -> val item = data[index] Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { - Text(formatTime(item.startTime) + "\n~\n" + formatTime(item.endTime), + Text(formatDate(item.startTime) + "\n~\n" + formatDate(item.endTime), Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) Spacer(Modifier.height(5.dp)) val txBytes = item.txBytes @@ -1350,7 +1349,7 @@ enum class PrivateDnsMode(val id: Int, val text: Int) { Host(DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME, R.string.enabled) } -data class PrivateDnsConfiguration(val mode: PrivateDnsMode, val host: String) +data class PrivateDnsConfiguration(val mode: PrivateDnsMode?, val host: String) @Serializable object PrivateDns @@ -1362,7 +1361,7 @@ fun PrivateDnsScreen( ) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var mode by remember { mutableStateOf(PrivateDnsMode.Opportunistic) } + var mode by remember { mutableStateOf(PrivateDnsMode.Opportunistic) } var inputHost by remember { mutableStateOf("") } LaunchedEffect(Unit) { val conf = getPrivateDns() @@ -1385,7 +1384,8 @@ fun PrivateDnsScreen( val result = setPrivateDns(PrivateDnsConfiguration(mode, inputHost)) context.showOperationResultToast(result) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + enabled = mode != null ) { Text(stringResource(R.string.apply)) } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt index 6f957cd..13bdc79 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -30,6 +30,8 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField @@ -45,6 +47,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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 @@ -56,6 +59,7 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP +import com.bintianqi.owndroid.generateBase64Key import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -250,7 +254,12 @@ fun ResetPasswordTokenScreen( OutlinedTextField( token, { token = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.token)) }, - supportingText = { Text("${token.length}/32") } + supportingText = { Text("${token.length}/32") }, + trailingIcon = { + IconButton({ token = generateBase64Key(24) }) { + Icon(painterResource(R.drawable.casino_fill0), null) + } + } ) Button( onClick = { @@ -265,7 +274,12 @@ fun ResetPasswordTokenScreen( } if (state.set && !state.active) Button( onClick = { - getIntent()?.let { launcher.launch(it) } + val intent = getIntent() + if (intent == null) { + context.showOperationResultToast(false) + } else { + launcher.launch(intent) + } }, modifier = Modifier.fillMaxWidth() ) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 7124ce5..2e829b3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -112,7 +112,7 @@ import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.formatTime +import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -588,7 +588,7 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un ) { if(page == 0) { OutlinedTextField( - value = datePickerState.selectedDateMillis?.let { formatTime(it) } ?: "", + value = datePickerState.selectedDateMillis?.let { formatDate(it) } ?: "", onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.date)) }, interactionSource = dateInteractionSource, @@ -1436,9 +1436,9 @@ fun CaCertScreen( Text("Issuer", style = typography.labelLarge) SelectionContainer { Text(cert.issuer) } Text("Issued on", style = typography.labelLarge) - SelectionContainer { Text(formatTime(cert.issuedTime)) } + SelectionContainer { Text(formatDate(cert.issuedTime)) } Text("Expires on", style = typography.labelLarge) - SelectionContainer { Text(formatTime(cert.expiresTime)) } + SelectionContainer { Text(formatDate(cert.expiresTime)) } Text("SHA-256 fingerprint", style = typography.labelLarge) SelectionContainer { Text(cert.hash) } if (dialog == 2) Row( @@ -1929,7 +1929,7 @@ fun SystemUpdatePolicyScreen( if (VERSION.SDK_INT >= 26) { Column(Modifier.padding(HorizontalPadding)) { if (pendingUpdate.exists) { - Text(stringResource(R.string.update_received_time, formatTime(pendingUpdate.time))) + Text(stringResource(R.string.update_received_time, formatDate(pendingUpdate.time))) Text(stringResource(R.string.is_security_patch, stringResource(pendingUpdate.securityPatch.yesOrNo))) } else { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index 2fa9197..6662ba9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -1,6 +1,8 @@ package com.bintianqi.owndroid.dpm import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -21,6 +24,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,6 +58,7 @@ import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.UserRestrictionCategory import com.bintianqi.owndroid.UserRestrictionsRepository +import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyLazyScaffold @@ -117,6 +122,17 @@ fun UserRestrictionScreen( onNavigate(UserRestrictionOptions(it.name)) } } + Row( + Modifier + .padding(HorizontalPadding, 10.dp) + .fillMaxWidth() + .background(colorScheme.primaryContainer, RoundedCornerShape(8.dp)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Outlined.Info, null, Modifier.padding(end = 8.dp), colorScheme.onPrimaryContainer) + Text(stringResource(R.string.user_restriction_tip), color = colorScheme.onPrimaryContainer) + } } } } @@ -128,7 +144,8 @@ data class UserRestrictionOptions(val id: String) @Composable fun UserRestrictionOptionsScreen( args: UserRestrictionOptions, userRestrictions: StateFlow>, - setRestriction: (String, Boolean) -> Boolean, onNavigateUp: () -> Unit + setRestriction: (String, Boolean) -> Boolean, setShortcut: (String) -> Boolean, + onNavigateUp: () -> Unit ) { val context = LocalContext.current val status by userRestrictions.collectAsStateWithLifecycle() @@ -136,7 +153,12 @@ fun UserRestrictionOptionsScreen( MyLazyScaffold(title, onNavigateUp) { items(items) { restriction -> Row( - Modifier.fillMaxWidth().padding(15.dp, 6.dp), + Modifier + .fillMaxWidth() + .combinedClickable(onClick = {}, onLongClick = { + if (!setShortcut(restriction.id)) context.popToast(R.string.unsupported) + }) + .padding(15.dp, 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt index 958c48a..30fd057 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt @@ -58,7 +58,7 @@ import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.formatTime +import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog @@ -205,7 +205,7 @@ fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { if (VERSION.SDK_INT >= 23) InfoItem(R.string.system_user, info.system.yesOrNo) if (VERSION.SDK_INT >= 34) InfoItem(R.string.admin_user, info.admin.yesOrNo) if (VERSION.SDK_INT >= 25) InfoItem(R.string.demo_user, info.demo.yesOrNo) - if (info.time != 0L) InfoItem(R.string.creation_time, formatTime(info.time)) + if (info.time != 0L) InfoItem(R.string.creation_time, formatDate(info.time)) if (VERSION.SDK_INT >= 28) { InfoItem(R.string.logout_enabled, info.logout.yesOrNo) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9cec37c..93940e9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -476,7 +476,6 @@ Пароль и блокировка экрана Информация о пароле - Оставьте пустым, чтобы удалить пароль Максимальное количество неудачных попыток ввода пароля Максимальное количество неудачных попыток Время истечения срока действия пароля @@ -492,17 +491,14 @@ Единый пароль Сбросить токен пароля Токен - Токен должен быть длиннее 32 байт Тoken activated Очистить Установить - Установите токен Токен будет автоматически активирован, если пароль не установлен. Сбросить пароль Подтвердите пароль Не запрашивать учетные данные при загрузке Требовать ввод - Сбросить пароль с помощью токена Требуемая сложность пароля Функции блокировки экрана (Keyguard) Включить все diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2f643f3..dfad27d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -501,7 +501,6 @@ Parola ve Kilit Ekranı Parola Bilgisi - Parolayı kaldırmak için boş bırakın Maksimum Başarısız Parola Maksimum başarısız deneme sayısı Parola Son Kullanma Zaman Aşımı @@ -516,17 +515,14 @@ Birleşik Parola Parola Sıfırlama Jetonu Jeton - Jeton 32 bayttan uzun olmalıdır Token activated Temizle Ayarla - Lütfen bir jeton ayarlayın Parola ayarlanmamışsa jeton otomatik olarak etkinleştirilir. Parolayı Sıfırla Parolayı Onayla Başlangıçta kimlik bilgisi sorma Giriş gerektir - Jeton ile parolayı sıfırla Gerekli Parola Karmaşıklığı Kilit Ekranı Özelliklerini Devre Dışı Bırak Hepsini Etkinleştir diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d855c24..dcaa54a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -372,6 +372,7 @@ 用户限制 + 长按一个条目以创建快捷方式 Profile owner无法使用部分功能 打开开关后会禁用对应的功能 工作资料中部分功能无效 @@ -489,7 +490,6 @@ 密码与锁屏 密码信息 - 留空以清除密码 最大密码错误次数 错误次数 密码失效超时 @@ -504,18 +504,15 @@ 一致的密码 密码重置令牌 令牌 - 令牌必须大于32字节 激活密码重置令牌 令牌已激活 清除 设置 - 请先设置令牌 没有密码时会自动激活令牌 重置密码 确认密码 启动(boot)时不要求密码 不允许其他设备管理员重置密码直至用户输入一次密码 - 使用令牌重置密码 密码复杂度要求 锁屏功能 启用全部 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf68326..7f0ff06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -406,6 +406,7 @@ User restriction + Long press an item to create a shortcut Profile owner can use limited function Turn on a switch to disable that function. Functions in work profile is limited. @@ -525,7 +526,6 @@ Password and keyguard Password Info - Keep empty to remove password Max failed passwords Maximum failed attempts Password expiration timeout @@ -540,18 +540,15 @@ Unified password Reset password token Token - The token must be longer than 32 byte Activate reset password token Token activated Clear Set - Please set a token Token will be automatically activated if no password is set. Reset password Confirm password Do not ask credentials on boot Require entry - Reset password with token Required password complexity Keyguard features Enable all From 9c796690e3ad7fe57be266c46bfe903baf926070 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Tue, 14 Oct 2025 23:35:28 +0800 Subject: [PATCH 13/26] Refactor security logging Bump version number --- app/build.gradle.kts | 4 +- .../com/bintianqi/owndroid/MainActivity.kt | 12 +- .../java/com/bintianqi/owndroid/MyDbHelper.kt | 9 +- .../com/bintianqi/owndroid/MyRepository.kt | 111 +++++ .../com/bintianqi/owndroid/MyViewModel.kt | 61 ++- .../bintianqi/owndroid/NotificationUtils.kt | 55 +-- .../java/com/bintianqi/owndroid/Receiver.kt | 34 +- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 459 +++++++++++------- .../com/bintianqi/owndroid/dpm/Network.kt | 43 -- .../java/com/bintianqi/owndroid/dpm/System.kt | 154 +++--- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- 14 files changed, 603 insertions(+), 347 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d94f989..f405d2d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "com.bintianqi.owndroid" minSdk = 21 targetSdk = 36 - versionCode = 40 - versionName = "7.1" + versionCode = 41 + versionName = "7.2" multiDexEnabled = false } diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 9e89f07..165e88e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -1,6 +1,7 @@ package com.bintianqi.owndroid import android.Manifest +import android.content.pm.PackageManager import android.os.Build.VERSION import android.os.Bundle import androidx.activity.compose.setContent @@ -242,7 +243,10 @@ class MainActivity : FragmentActivity() { val locale = context.resources?.configuration?.locale zhCN = locale == Locale.SIMPLIFIED_CHINESE || locale == Locale.CHINESE || locale == Locale.CHINA val vm by viewModels() - if (VERSION.SDK_INT >= 33) { + if ( + VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } @@ -379,7 +383,11 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { CaCertScreen(vm.installedCaCerts, vm::getCaCerts, vm::installCaCert, vm::parseCaCert, vm::exportCaCert, vm::uninstallCaCert, vm::uninstallAllCaCerts, ::navigateUp) } - composable { SecurityLoggingScreen(::navigateUp) } + composable { + SecurityLoggingScreen(vm::getSecurityLoggingEnabled, vm::setSecurityLoggingEnabled, + vm::exportSecurityLogs, vm::getSecurityLogsCount, vm::deleteSecurityLogs, + vm::getPreRebootSecurityLogs, vm::exportPreRebootSecurityLogs, ::navigateUp) + } composable { DisableAccountManagementScreen(vm.mdAccountTypes, vm::getMdAccountTypes, vm::setMdAccountType, ::navigateUp) diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt index bb4bdf6..f49867f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -4,12 +4,15 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 1) { +class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 2) { override fun onCreate(db: SQLiteDatabase) { db.execSQL("CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + "signature TEXT, permissions TEXT)") } - override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { - + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 2) { + db.execSQL("CREATE TABLE security_logs (id INTEGER, tag INTEGER, level INTEGER," + + "time INTEGER, data TEXT)") + } } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt index 0f08b0c..4dc4b57 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt @@ -1,7 +1,18 @@ package com.bintianqi.owndroid +import android.app.admin.SecurityLog import android.content.ContentValues +import android.database.DatabaseUtils import android.database.sqlite.SQLiteDatabase +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.core.database.getStringOrNull +import com.bintianqi.owndroid.dpm.SecurityEvent +import com.bintianqi.owndroid.dpm.SecurityEventWithData +import com.bintianqi.owndroid.dpm.transformSecurityEventData +import kotlinx.serialization.json.ClassDiscriminatorMode +import kotlinx.serialization.json.Json +import java.io.OutputStream class MyRepository(val dbHelper: MyDbHelper) { fun getDhizukuClients(): List { @@ -43,4 +54,104 @@ class MyRepository(val dbHelper: MyDbHelper) { fun deleteDhizukuClient(info: DhizukuClientInfo) { dbHelper.writableDatabase.delete("dhizuku_clients", "uid = ${info.uid}", null) } + + fun getSecurityLogsCount(): Long { + return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "security_logs") + } + @RequiresApi(24) + fun writeSecurityLogs(events: List) { + val db = dbHelper.writableDatabase + val json = Json { + classDiscriminatorMode = ClassDiscriminatorMode.NONE + } + val statement = db.compileStatement("INSERT INTO security_logs VALUES (?, ?, ?, ?, ?)") + db.beginTransaction() + events.forEach { event -> + try { + if (VERSION.SDK_INT >= 28) { + statement.bindLong(1, event.id) + statement.bindLong(3, event.logLevel.toLong()) + } else { + statement.bindNull(1) + statement.bindNull(3) + } + statement.bindLong(2, event.tag.toLong()) + statement.bindLong(4, event.timeNanos / 1000000) + val dataObject = transformSecurityEventData(event.tag, event.data) + if (dataObject == null) { + statement.bindNull(5) + } else { + statement.bindString(5, json.encodeToString(dataObject)) + } + statement.executeInsert() + } catch (e: Exception) { + e.printStackTrace() + } finally { + statement.clearBindings() + } + } + db.setTransactionSuccessful() + db.endTransaction() + statement.close() + } + fun exportSecurityLogs(stream: OutputStream) { + var offset = 0 + val json = Json { + explicitNulls = false + } + var addComma = false + val bw = stream.bufferedWriter() + bw.write("[") + while (true) { + dbHelper.readableDatabase.rawQuery( + "SELECT * FROM security_logs LIMIT ? OFFSET ?", + arrayOf(100.toString(), offset.toString()) + ).use { cursor -> + if (cursor.count == 0) { + break + } + while (cursor.moveToNext()) { + if (addComma) bw.write(",") + addComma = true + val event = SecurityEvent( + cursor.getLong(0), cursor.getInt(1), cursor.getInt(2), cursor.getLong(3), + cursor.getStringOrNull(4)?.let { json.decodeFromString(it) } + ) + bw.write(json.encodeToString(event)) + } + offset += 100 + } + } + bw.write("]") + bw.close() + } + @RequiresApi(24) + fun exportPRSecurityLogs(logs: List, stream: OutputStream) { + val bw = stream.bufferedWriter() + bw.write("[") + val json = Json { + explicitNulls = false + classDiscriminatorMode = ClassDiscriminatorMode.NONE + } + var addComma = false + logs.forEach { log -> + try { + if (addComma) bw.write(",") + addComma = true + val event = SecurityEventWithData( + if (VERSION.SDK_INT >= 28) log.id else null, log.tag, + if (VERSION.SDK_INT >= 28) log.logLevel else null, log.timeNanos / 1000000, + transformSecurityEventData(log.tag, log.data) + ) + bw.write(json.encodeToString(event)) + } catch (e: Exception) { + e.printStackTrace() + } + } + bw.write("]") + bw.close() + } + fun deleteSecurityLogs() { + dbHelper.writableDatabase.execSQL("DELETE FROM security_logs") + } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index dcea517..929ff3b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -13,6 +13,7 @@ import android.app.admin.FactoryResetProtectionPolicy import android.app.admin.IDevicePolicyManager import android.app.admin.PackagePolicy import android.app.admin.PreferentialNetworkServiceConfig +import android.app.admin.SecurityLog import android.app.admin.SystemUpdateInfo import android.app.admin.SystemUpdatePolicy import android.app.admin.WifiSsidPolicy @@ -756,9 +757,12 @@ class MyViewModel(application: Application): AndroidViewModel(application) { getLockTaskPackages() } @RequiresApi(28) - fun startLockTaskMode(packageName: String, activity: String): Int { - if (!NotificationUtils.checkPermission(application)) return 0 - if (!DPM.isLockTaskPermitted(packageName)) return 1 + fun startLockTaskMode(packageName: String, activity: String): Boolean { + if (!DPM.isLockTaskPermitted(packageName)) { + val list = lockTaskPackages.value.map { it.name } + packageName + DPM.setLockTaskPackages(DAR, list.toTypedArray()) + getLockTaskPackages() + } val options = ActivityOptions.makeBasic().setLockTaskEnabled(true) val intent = if(activity.isNotEmpty()) { Intent().setComponent(ComponentName(packageName, activity)) @@ -766,9 +770,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { if (intent != null) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) application.startActivity(intent, options.toBundle()) - return 0 + return true } else { - return 2 + return false } } @RequiresApi(28) @@ -913,6 +917,53 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } DPM.installSystemUpdate(DAR, uri, application.mainExecutor, callback) } + @RequiresApi(24) + fun getSecurityLoggingEnabled(): Boolean { + return DPM.isSecurityLoggingEnabled(DAR) + } + @RequiresApi(24) + fun setSecurityLoggingEnabled(enabled: Boolean) { + DPM.setSecurityLoggingEnabled(DAR, enabled) + } + fun exportSecurityLogs(uri: Uri, callback: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + application.contentResolver.openOutputStream(uri)?.use { + myRepo.exportSecurityLogs(it) + } + withContext(Dispatchers.Main) { + callback() + } + } + } + fun getSecurityLogsCount(): Int { + return myRepo.getSecurityLogsCount().toInt() + } + fun deleteSecurityLogs() { + myRepo.deleteSecurityLogs() + } + var preRebootSecurityLogs = emptyList() + @RequiresApi(24) + fun getPreRebootSecurityLogs(): Boolean { + if (preRebootSecurityLogs.isNotEmpty()) return true + return try { + val logs = DPM.retrievePreRebootSecurityLogs(DAR) + if (logs != null && logs.isNotEmpty()) { + preRebootSecurityLogs = logs + true + } else false + } catch (_: SecurityException) { + false + } + } + @RequiresApi(24) + fun exportPreRebootSecurityLogs(uri: Uri, callback: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val stream = application.contentResolver.openOutputStream(uri) ?: return@launch + myRepo.exportPRSecurityLogs(preRebootSecurityLogs, stream) + stream.close() + withContext(Dispatchers.Main) { callback() } + } + } @RequiresApi(24) fun isCreatingWorkProfileAllowed(): Boolean { diff --git a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt index 0ed50a6..5e90d18 100644 --- a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt @@ -1,48 +1,35 @@ package com.bintianqi.owndroid -import android.Manifest -import android.app.Notification -import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context -import android.content.pm.PackageManager -import android.os.Build +import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat object NotificationUtils { - fun checkPermission(context: Context): Boolean { - return if(Build.VERSION.SDK_INT >= 33) - context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - else false - } fun createChannels(context: Context) { - if (Build.VERSION.SDK_INT < 26) return - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val lockTaskMode = NotificationChannel( - MyNotificationChannel.LockTaskMode.id, - context.getString(MyNotificationChannel.LockTaskMode.text), - NotificationManager.IMPORTANCE_HIGH - ) - val events = NotificationChannel( - MyNotificationChannel.Events.id, - context.getString(MyNotificationChannel.Events.text), - NotificationManager.IMPORTANCE_HIGH - ) - nm.createNotificationChannels(listOf(lockTaskMode, events)) + val channels = MyNotificationChannel.entries.map { + NotificationChannelCompat.Builder(it.id, it.importance) + .setName(context.getString(it.text)) + .build() + } + NotificationManagerCompat.from(context).createNotificationChannelsCompat(channels) } - fun notifyEvent(context: Context, type: NotificationType, text: String) { - val notification = NotificationCompat.Builder(context, MyNotificationChannel.Events.id) + fun sendBasicNotification( + context: Context, type: NotificationType, channel: MyNotificationChannel, text: String + ) { + val notification = NotificationCompat.Builder(context, channel.id) .setSmallIcon(type.icon) .setContentTitle(context.getString(type.text)) .setContentText(text) .build() - notify(context, type, notification) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(type.id, notification) } - fun notify(context: Context, type: NotificationType, notification: Notification) { + fun notifyEvent(context: Context, type: NotificationType, text: String) { val enabledNotifications = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } if (enabledNotifications == null || type.id in enabledNotifications) { - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(type.id, notification) + sendBasicNotification(context, type, MyNotificationChannel.Events, text) } } fun cancel(context: Context, type: NotificationType) { @@ -62,10 +49,12 @@ enum class NotificationType(val id: Int, val text: Int, val icon: Int) { BugReportShared(8, R.string.bug_report_shared, R.drawable.bug_report_fill0), BugReportSharingDeclined(9, R.string.bug_report_sharing_declined, R.drawable.bug_report_fill0), BugReportFailed(10, R.string.bug_report_failed, R.drawable.bug_report_fill0), - SystemUpdatePending(11, R.string.system_update_pending, R.drawable.system_update_fill0) + SystemUpdatePending(11, R.string.system_update_pending, R.drawable.system_update_fill0), + SecurityLogsCollected(12, R.string.security_logs_collected, R.drawable.description_fill0), } -enum class MyNotificationChannel(val id: String, val text: Int) { - LockTaskMode("LockTaskMode", R.string.lock_task_mode), - Events("Events", R.string.events) +enum class MyNotificationChannel(val id: String, val text: Int, val importance: Int) { + LockTaskMode("LockTaskMode", R.string.lock_task_mode, NotificationManagerCompat.IMPORTANCE_HIGH), + Events("Events", R.string.events, NotificationManagerCompat.IMPORTANCE_LOW), + SecurityLogging("SecurityLogging", R.string.security_logging, NotificationManagerCompat.IMPORTANCE_MIN) } diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index 7dbadb7..117f185 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -1,5 +1,6 @@ package com.bintianqi.owndroid +import android.app.NotificationManager import android.app.PendingIntent import android.app.admin.DeviceAdminReceiver import android.content.ComponentName @@ -12,7 +13,7 @@ import android.os.UserManager import androidx.core.app.NotificationCompat import com.bintianqi.owndroid.dpm.handleNetworkLogs import com.bintianqi.owndroid.dpm.handlePrivilegeChange -import com.bintianqi.owndroid.dpm.processSecurityLogs +import com.bintianqi.owndroid.dpm.retrieveSecurityLogs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -21,11 +22,10 @@ class Receiver : DeviceAdminReceiver() { override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) if(VERSION.SDK_INT >= 26 && intent.action == "com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") { - val dpm = getManager(context) val receiver = ComponentName(context, this::class.java) - val packages = dpm.getLockTaskPackages(receiver) - dpm.setLockTaskPackages(receiver, arrayOf()) - dpm.setLockTaskPackages(receiver, packages) + val packages = Privilege.DPM.getLockTaskPackages(receiver) + Privilege.DPM.setLockTaskPackages(receiver, arrayOf()) + Privilege.DPM.setLockTaskPackages(receiver, packages) } } @@ -56,29 +56,23 @@ class Receiver : DeviceAdminReceiver() { override fun onSecurityLogsAvailable(context: Context, intent: Intent) { super.onSecurityLogsAvailable(context, intent) - if(VERSION.SDK_INT >= 24) { - CoroutineScope(Dispatchers.IO).launch { - val events = getManager(context).retrieveSecurityLogs(MyAdminComponent) ?: return@launch - val file = context.filesDir.resolve("SecurityLogs.json") - val fileExists = file.exists() - file.outputStream().use { - if(fileExists) it.write(",".encodeToByteArray()) - processSecurityLogs(events, it) - } - } + if (VERSION.SDK_INT >= 24) { + retrieveSecurityLogs(context.applicationContext as MyApplication) } } override fun onLockTaskModeEntering(context: Context, intent: Intent, pkg: String) { super.onLockTaskModeEntering(context, intent, pkg) - if (!NotificationUtils.checkPermission(context)) return - val intent = Intent(context, this::class.java).setAction("com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") - val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - val builder = NotificationCompat.Builder(context, MyNotificationChannel.LockTaskMode.id) + val stopIntent = Intent(context, this::class.java) + .setAction("com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") + val pendingIntent = PendingIntent.getBroadcast(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE) + val notification = NotificationCompat.Builder(context, MyNotificationChannel.LockTaskMode.id) .setContentTitle(context.getText(R.string.lock_task_mode)) .setSmallIcon(R.drawable.lock_fill0) .addAction(NotificationCompat.Action.Builder(null, context.getString(R.string.stop), pendingIntent).build()) - NotificationUtils.notify(context, NotificationType.LockTaskMode, builder.build()) + .build() + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NotificationType.LockTaskMode.id, notification) } override fun onLockTaskModeExiting(context: Context, intent: Intent) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index 0e2214e..c075f31 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -14,21 +14,28 @@ import android.content.pm.PackageInstaller import android.os.Build.VERSION import android.util.Log import androidx.annotation.RequiresApi +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.MyNotificationChannel +import com.bintianqi.owndroid.NotificationType +import com.bintianqi.owndroid.NotificationUtils import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP import com.bintianqi.owndroid.ShortcutUtils import com.rosan.dhizuku.api.Dhizuku import com.rosan.dhizuku.api.DhizukuBinderWrapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray -import java.io.OutputStream @SuppressLint("PrivateApi") fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager? { @@ -166,230 +173,336 @@ fun handleNetworkLogs(context: Context, batchToken: Long) { buffer.close() } -@RequiresApi(24) -fun processSecurityLogs(securityEvents: List, outputStream: OutputStream) { - val json = Json { ignoreUnknownKeys = true; explicitNulls = false } - val buffer = outputStream.bufferedWriter() - securityEvents.forEachIndexed { index, event -> - val item = buildJsonObject { - put("time", event.timeNanos / 1000) - put("tag", event.tag) - if(VERSION.SDK_INT >= 28) put("level", event.logLevel) - if(VERSION.SDK_INT >= 28) put("id", event.id) - parseSecurityEventData(event).let { if(it != null) put("data", it) } - } - buffer.write(json.encodeToString(item)) - if(index < securityEvents.size - 1) buffer.write(",") - } - buffer.close() +@Serializable +class SecurityEvent( + val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: JsonObject? +) + +@Serializable +class SecurityEventWithData( + val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: SecurityEventData? +) + +@Serializable +sealed class SecurityEventData { + @Serializable + class AdbShellCmd(val command: String): SecurityEventData() + @Serializable + class AppProcessStart( + val name: String, + val time: Long, + val uid: Int, + val pid: Int, + val seinfo: String, + val hash: String + ): SecurityEventData() + @Serializable + class BackupServiceToggled( + val admin: String, + val user: Int, + val state: Int + ): SecurityEventData() + @Serializable + class BluetoothConnection( + val mac: String, + val successful: Int, + @SerialName("failure_reason") val failureReason: String + ): SecurityEventData() + @Serializable + class BluetoothDisconnection( + val mac: String, + val reason: String + ): SecurityEventData() + @Serializable + class CameraPolicySet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val disabled: Int + ): SecurityEventData() + @Serializable + class CaInstalledRemoved( + val result: Int, + val subject: String, + val user: Int + ): SecurityEventData() + @Serializable + class CertValidationFailure(val reason: String): SecurityEventData() + @Serializable + class CryptoSelfTestCompleted(val result: Int): SecurityEventData() + @Serializable + class KeyguardDisabledFeaturesSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val mask: Int + ): SecurityEventData() + @Serializable + class KeyguardDismissAuthAttempt( + val result: Int, + val strength: Int + ): SecurityEventData() + @Serializable + class KeyGeneratedImportDestruction( + val result: Int, + val alias: String, + val uid: Int + ): SecurityEventData() + @Serializable + class KeyIntegrityViolation( + val alias: String, + val uid: Int + ): SecurityEventData() + @Serializable + class MaxPasswordAttemptsSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val value: Int + ): SecurityEventData() + @Serializable + class MaxScreenLockTimeoutSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val timeout: Long + ): SecurityEventData() + @Serializable + class MediaMountUnmount( + @SerialName("mount_point") val mountPoint: String, + val label: String + ): SecurityEventData() + @Serializable + class OsStartup( + @SerialName("verified_boot_state") val verifiedBootState: String, + @SerialName("dm_verity_mode") val dmVerityMode: String + ): SecurityEventData() + @Serializable + class PackageInstalledUninstalledUpdated( + val name: String, + val version: Long, + val user: Int + ): SecurityEventData() + @Serializable + class PasswordChanged( + val complexity: Int, + val user: Int + ): SecurityEventData() + @Serializable + class PasswordComplexityRequired( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val complexity: Int + ): SecurityEventData() + @Serializable + class PasswordComplexitySet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val length: Int, + val quality: Int, + val letters: Int, + @SerialName("non_letters") val nonLetters: Int, + val digits: Int, + val uppercase: Int, + val lowercase: Int, + val symbols: Int + ): SecurityEventData() + @Serializable + class PasswordExpirationSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val expiration: Long + ): SecurityEventData() + @Serializable + class PasswordHistoryLengthSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val length: Int + ): SecurityEventData() + @Serializable + class RemoteLock( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + ): SecurityEventData() + @Serializable + class SyncRecvSendFile(val path: String): SecurityEventData() + @Serializable + class UserRestrictionAddedRemoved( + val admin: String, + val user: Int, + val restriction: String + ): SecurityEventData() + @Serializable + class WifiConnection( + val bssid: String, + val type: String, + @SerialName("failure_reason") val failureReason: String + ): SecurityEventData() + @Serializable + class WifiDisconnection( + val bssid: String, + val reason: String + ): SecurityEventData() } -@RequiresApi(24) -fun parseSecurityEventData(event: SecurityLog.SecurityEvent): JsonElement? { - return when(event.tag) { - SecurityLog.TAG_ADB_SHELL_CMD -> JsonPrimitive(event.data as String) +fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? { + return when(tag) { + SecurityLog.TAG_ADB_SHELL_CMD -> SecurityEventData.AdbShellCmd(payload as String) SecurityLog.TAG_ADB_SHELL_INTERACTIVE -> null SecurityLog.TAG_APP_PROCESS_START -> { - val payload = event.data as Array<*> - buildJsonObject { - put("name", payload[0] as String) - put("time", payload[1] as Long) - put("uid", payload[2] as Int) - put("pid", payload[3] as Int) - put("seinfo", payload[4] as String) - put("apk_hash", payload[5] as String) - } + val data = payload as Array<*> + SecurityEventData.AppProcessStart( + data[0] as String, data[1] as Long, data[2] as Int, data[3] as Int, + data[4] as String, data[5] as String + ) } SecurityLog.TAG_BACKUP_SERVICE_TOGGLED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("state", payload[2] as Int) - } + val data = payload as Array<*> + SecurityEventData.BackupServiceToggled(data[0] as String, data[1] as Int, data[2] as Int) } SecurityLog.TAG_BLUETOOTH_CONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("mac", payload[0] as String) - put("successful", payload[1] as Int) - (payload[2] as String).let { if(it != "") put("failure_reason", it) } - } + val data = payload as Array<*> + SecurityEventData.BluetoothConnection(data[0] as String, data[1] as Int, data[2] as String) } SecurityLog.TAG_BLUETOOTH_DISCONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("mac", payload[0] as String) - (payload[1] as String).let { if(it != "") put("reason", it) } - } + val data = payload as Array<*> + SecurityEventData.BluetoothDisconnection(data[0] as String, data[1] as String) } SecurityLog.TAG_CAMERA_POLICY_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("disabled", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.CameraPolicySet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_CERT_AUTHORITY_INSTALLED, SecurityLog.TAG_CERT_AUTHORITY_REMOVED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("result", payload[0] as Int) - put("subject", payload[1] as String) - if(VERSION.SDK_INT >= 30) put("user", payload[2] as Int) - } + val data = payload as Array<*> + SecurityEventData.CaInstalledRemoved(data[0] as Int, data[1] as String, data[2] as Int) } - SecurityLog.TAG_CERT_VALIDATION_FAILURE -> JsonPrimitive(event.data as String) - SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED -> JsonPrimitive(event.data as Int) + SecurityLog.TAG_CERT_VALIDATION_FAILURE -> + SecurityEventData.CertValidationFailure(payload as String) + SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED -> + SecurityEventData.CryptoSelfTestCompleted(payload as Int) SecurityLog.TAG_KEYGUARD_DISABLED_FEATURES_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("mask", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.KeyguardDisabledFeaturesSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_KEYGUARD_DISMISSED -> null SecurityLog.TAG_KEYGUARD_DISMISS_AUTH_ATTEMPT -> { - val payload = event.data as Array<*> - buildJsonObject { - put("result", payload[0] as Int) - put("strength", payload[1] as Int) - } + val data = payload as Array<*> + SecurityEventData.KeyguardDismissAuthAttempt(data[0] as Int, data[1] as Int) } SecurityLog.TAG_KEYGUARD_SECURED -> null - SecurityLog.TAG_KEY_DESTRUCTION, SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT -> { - val payload = event.data as Array<*> - buildJsonObject { - put("result", payload[0] as Int) - put("alias", payload[1] as String) - put("uid", payload[2] as Int) - } - } - SecurityLog.TAG_KEY_INTEGRITY_VIOLATION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("alias", payload[0] as String) - put("uid", payload[1] as Int) - } + SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT, SecurityLog.TAG_KEY_DESTRUCTION -> { + val data = payload as Array<*> + SecurityEventData.KeyGeneratedImportDestruction( + data[0] as Int, data[1] as String, data[2] as Int + ) } SecurityLog.TAG_LOGGING_STARTED, SecurityLog.TAG_LOGGING_STOPPED -> null SecurityLog.TAG_LOG_BUFFER_SIZE_CRITICAL -> null SecurityLog.TAG_MAX_PASSWORD_ATTEMPTS_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("value", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.MaxPasswordAttemptsSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_MAX_SCREEN_LOCK_TIMEOUT_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("timeout", payload[3] as Long) - } + val data = payload as Array<*> + SecurityEventData.MaxScreenLockTimeoutSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long + ) } SecurityLog.TAG_MEDIA_MOUNT, SecurityLog.TAG_MEDIA_UNMOUNT -> { - val payload = event.data as Array<*> - buildJsonObject { - put("mount_point", payload[0] as String) - put("volume_label", payload[1] as String) - } + val data = payload as Array<*> + SecurityEventData.MediaMountUnmount(data[0] as String, data[1] as String) } + SecurityLog.TAG_NFC_ENABLED, SecurityLog.TAG_NFC_DISABLED -> null SecurityLog.TAG_OS_SHUTDOWN -> null SecurityLog.TAG_OS_STARTUP -> { - val payload = event.data as Array<*> - buildJsonObject { - put("verified_boot_state", payload[0] as String) - put("dm_verify_state", payload[1] as String) - } + val data = payload as Array<*> + SecurityEventData.OsStartup(data[0] as String, data[1] as String) } - SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UNINSTALLED, SecurityLog.TAG_PACKAGE_UPDATED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("name", payload[0] as String) - put("version", payload[1] as Long) - put("user_id", payload[2] as Int) - } + SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UPDATED, + SecurityLog.TAG_PACKAGE_UNINSTALLED -> { + val data = payload as Array<*> + SecurityEventData.PackageInstalledUninstalledUpdated( + data[0] as String, data[1] as Long, data[2] as Int + ) } SecurityLog.TAG_PASSWORD_CHANGED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("complexity", payload[0] as Int) - put("user_id", payload[1] as Int) - } + val data = payload as Array<*> + SecurityEventData.PasswordChanged(data[0] as Int, data[1] as Int) } - SecurityLog. TAG_PASSWORD_COMPLEXITY_REQUIRED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("complexity", payload[3] as Int) - } + SecurityLog.TAG_PASSWORD_COMPLEXITY_REQUIRED -> { + val data = payload as Array<*> + SecurityEventData.PasswordComplexityRequired( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) + } + SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> { + val data = payload as Array<*> + SecurityEventData.PasswordComplexitySet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int, data[4] as Int, + data[5] as Int, data[6] as Int, data[7] as Int, data[8] as Int, data[9] as Int, + data[10] as Int + ) } - SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> null //Deprecated SecurityLog.TAG_PASSWORD_EXPIRATION_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("timeout", payload[3] as Long) - } + val data = payload as Array<*> + SecurityEventData.PasswordExpirationSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long + ) } SecurityLog.TAG_PASSWORD_HISTORY_LENGTH_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("length", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.PasswordHistoryLengthSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_REMOTE_LOCK -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - } + val data = payload as Array<*> + SecurityEventData.RemoteLock(data[0] as String, data[1] as Int, data[2] as Int) } - SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE -> JsonPrimitive(event.data as String) + SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE -> + SecurityEventData.SyncRecvSendFile(payload as String) SecurityLog.TAG_USER_RESTRICTION_ADDED, SecurityLog.TAG_USER_RESTRICTION_REMOVED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("restriction", payload[2] as String) - } + val data = payload as Array<*> + SecurityEventData.UserRestrictionAddedRemoved( + data[0] as String, data[1] as Int, data[2] as String + ) } SecurityLog.TAG_WIFI_CONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("bssid", payload[0] as String) - put("type", payload[1] as String) - (payload[2] as String).let { if(it != "") put("failure_reason", it) } - } + val data = payload as Array<*> + SecurityEventData.WifiConnection(data[0] as String, data[1] as String, data[2] as String) } SecurityLog.TAG_WIFI_DISCONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("bssid", payload[0] as String) - (payload[1] as String).let { if(it != "") put("reason", it) } - } + val data = payload as Array<*> + SecurityEventData.WifiDisconnection(data[0] as String, data[1] as String) } SecurityLog.TAG_WIPE_FAILURE -> null else -> null } } +@RequiresApi(24) +fun retrieveSecurityLogs(app: MyApplication) { + CoroutineScope(Dispatchers.IO).launch { + val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR) ?: return@launch + app.myRepo.writeSecurityLogs(logs) + NotificationUtils.sendBasicNotification( + app, NotificationType.SecurityLogsCollected, MyNotificationChannel.SecurityLogging, + app.getString(R.string.n_logs_in_total, logs.size) + ) + } +} + fun setDefaultAffiliationID() { if (VERSION.SDK_INT < 26) return if(!SP.isDefaultAffiliationIdSet) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index df3e91f..5be58f1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -1567,49 +1567,6 @@ fun NetworkLoggingScreen(onNavigateUp: () -> Unit) { } } -@Serializable object WifiAuthKeypair - -@RequiresApi(31) -@Composable -fun WifiAuthKeypairScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - var keyPair by remember { mutableStateOf("") } - MyScaffold(R.string.wifi_auth_keypair, onNavigateUp) { - OutlinedTextField( - value = keyPair, - label = { Text(stringResource(R.string.alias)) }, - onValueChange = { keyPair = it }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.padding(vertical = 5.dp)) - val isExist = try { - Privilege.DPM.isKeyPairGrantedToWifiAuth(keyPair) - } catch(e: java.lang.IllegalArgumentException) { - e.printStackTrace() - false - } - Text(stringResource(R.string.already_exist)+":$isExist") - Spacer(Modifier.padding(vertical = 5.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { context.showOperationResultToast(Privilege.DPM.grantKeyPairToWifiAuth(keyPair)) }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.grant)) - } - Button( - onClick = { context.showOperationResultToast(Privilege.DPM.revokeKeyPairFromWifiAuth(keyPair)) }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.revoke)) - } - } - } -} - @Serializable object PreferentialNetworkService @RequiresApi(33) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 2e829b3..45b3555 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -88,7 +88,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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 @@ -111,11 +110,11 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP -import com.bintianqi.owndroid.formatFileSize import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.ErrorDialog import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem @@ -132,7 +131,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import java.io.ByteArrayOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.TimeZone import kotlin.math.roundToLong @@ -178,7 +179,7 @@ fun SystemManagerScreen( if(VERSION.SDK_INT >= 31) { FunctionItem(R.string.nearby_streaming_policy, icon = R.drawable.share_fill0) { onNavigate(NearbyStreamingPolicy) } } - if(VERSION.SDK_INT >= 28 && privilege.device) { + if (VERSION.SDK_INT >= 28 && privilege.device && !privilege.dhizuku) { FunctionItem(R.string.lock_task_mode, icon = R.drawable.lock_fill0) { onNavigate(LockTaskMode) } } FunctionItem(R.string.ca_cert, icon = R.drawable.license_fill0) { onNavigate(CaCert) } @@ -676,7 +677,7 @@ fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> U var dialog by remember { mutableStateOf(false) } val availableIds = TimeZone.getAvailableIDs() val validInput = inputTimezone in availableIds - MyScaffold(R.string.change_timezone, onNavigateUp) { + MyScaffold(R.string.change_timezone, onNavigateUp) { OutlinedTextField( value = inputTimezone, label = { Text(stringResource(R.string.timezone_id)) }, @@ -1147,7 +1148,7 @@ fun NearbyStreamingPolicyScreen( fun LockTaskModeScreen( chosenPackage: Channel, onChoosePackage: () -> Unit, lockTaskPackages: StateFlow>, getLockTaskPackages: () -> Unit, - setLockTaskPackage: (String, Boolean) -> Unit, startLockTaskMode: (String, String) -> Unit, + setLockTaskPackage: (String, Boolean) -> Unit, startLockTaskMode: (String, String) -> Boolean, getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String?, onNavigateUp: () -> Unit ) { val coroutine = rememberCoroutineScope() @@ -1202,9 +1203,10 @@ fun LockTaskModeScreen( @RequiresApi(28) @Composable private fun StartLockTaskMode( - startLockTaskMode: (String, String) -> Unit, + startLockTaskMode: (String, String) -> Boolean, chosenPackage: Channel, onChoosePackage: () -> Unit ) { + val context = LocalContext.current val focusMgr = LocalFocusManager.current var packageName by rememberSaveable { mutableStateOf("") } var activity by rememberSaveable { mutableStateOf("") } @@ -1244,7 +1246,8 @@ private fun StartLockTaskMode( .fillMaxWidth() .padding(bottom = 5.dp), onClick = { - startLockTaskMode(packageName, activity) + val result = startLockTaskMode(packageName, activity) + if (!result) context.showOperationResultToast(false) }, enabled = packageName.isNotBlank() && (!specifyActivity || activity.isNotBlank()) ) { @@ -1525,77 +1528,104 @@ fun CaCertScreen( @RequiresApi(24) @Composable -fun SecurityLoggingScreen(onNavigateUp: () -> Unit) { +fun SecurityLoggingScreen( + getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, exportLogs: (Uri, () -> Unit) -> Unit, + getCount: () -> Int, deleteLogs: () -> Unit, getPRLogs: () -> Boolean, + exportPRLogs: (Uri, () -> Unit) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val logFile = context.filesDir.resolve("SecurityLogs.json") - var fileSize by remember { mutableLongStateOf(0) } - LaunchedEffect(Unit) { fileSize = logFile.length() } - var preRebootSecurityLogs by remember { mutableStateOf(byteArrayOf()) } - val exportPreRebootSecurityLogs = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - if(uri != null) context.contentResolver.openOutputStream(uri)?.use { outStream -> - preRebootSecurityLogs.inputStream().copyTo(outStream) + var enabled by remember { mutableStateOf(false) } + var logsCount by remember { mutableIntStateOf(0) } + var exporting by remember { mutableStateOf(false) } + var dialog by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + enabled = getEnabled() + logsCount = getCount() + } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) { + exporting = true + exportLogs(it) { + exporting = false + context.showOperationResultToast(true) + } } } - val exportSecurityLogs = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - if(uri != null) context.contentResolver.openOutputStream(uri)?.use { outStream -> - outStream.write("[".toByteArray()) - logFile.inputStream().use { it.copyTo(outStream) } - outStream.write("]".toByteArray()) - context.showOperationResultToast(true) + val exportPRLogsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) { + exporting = true + exportPRLogs(it) { + exporting = false + context.showOperationResultToast(true) + } } } - MyScaffold(R.string.security_logging, onNavigateUp) { + MyScaffold(R.string.security_logging, onNavigateUp, 0.dp) { SwitchItem( - R.string.enable, - getState = { Privilege.DPM.isSecurityLoggingEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setSecurityLoggingEnabled(Privilege.DAR, it) }, - padding = false + R.string.enable, enabled, { + setEnabled(it) + enabled = it + } ) - Text(stringResource(R.string.log_file_size_is, formatFileSize(fileSize))) - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - exportSecurityLogs.launch("SecurityLogs.json") - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.export_logs)) - } - Button( - onClick = { - logFile.delete() - fileSize = logFile.length() - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.delete_logs)) - } + Text( + stringResource(R.string.n_logs_in_total, logsCount), + Modifier.padding(HorizontalPadding) + ) + Button( + { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportLauncher.launch("security_logs_$date") + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + logsCount > 0 + ) { + Text(stringResource(R.string.export_logs)) } - Notes(R.string.info_security_log) - Spacer(Modifier.padding(vertical = 5.dp)) + if (logsCount > 0) FilledTonalButton( + { dialog = true }, + Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp) + ) { + Text(stringResource(R.string.delete_logs)) + } + Notes(R.string.info_security_log, HorizontalPadding) Button( onClick = { - val logs = Privilege.DPM.retrievePreRebootSecurityLogs(Privilege.DAR) - if(logs == null) { - context.popToast(R.string.no_logs) - return@Button + if (getPRLogs()) { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportPRLogsLauncher.launch("pre_reboot_security_logs_$date") } else { - val outputStream = ByteArrayOutputStream() - outputStream.write("[".encodeToByteArray()) - processSecurityLogs(logs, outputStream) - outputStream.write("]".encodeToByteArray()) - preRebootSecurityLogs = outputStream.toByteArray() - exportPreRebootSecurityLogs.launch("PreRebootSecurityLogs.json") + context.showOperationResultToast(false) } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 15.dp) ) { Text(stringResource(R.string.pre_reboot_security_logs)) } - Notes(R.string.info_pre_reboot_security_log) + Notes(R.string.info_pre_reboot_security_log, HorizontalPadding) } + if (exporting) CircularProgressDialog { exporting = false } + if (dialog) AlertDialog( + text = { Text(stringResource(R.string.delete_logs)) }, + confirmButton = { + TextButton({ + deleteLogs() + logsCount = 0 + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) } @Serializable object DisableAccountManagement diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 93940e9..5012319 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -35,7 +35,6 @@ Время (мс) Длина Нет - Нет журналов По умолчанию Применить Определять пользователем diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index dfad27d..d593b89 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -35,7 +35,6 @@ Zaman (ms) Uzunluk Yok - Kayıt Yok Varsayılan Uygula Kullanıcı Tarafından Karar Ver diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dcaa54a..81262df 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -35,7 +35,6 @@ 时间(毫秒) 长度 - 无日志 默认 应用 由用户决定 @@ -194,6 +193,8 @@ 卸载所有用户证书 安全日志 重启前安全日志 + 安全日志已收集 + 总共 %1$d 条日志 清除数据 清除外部存储 清除受保护的数据 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f0ff06..55e9949 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,6 @@ Time(ms) Length None - No logs Default Apply Decide by user @@ -221,6 +220,8 @@ Uninstall all user CA certificate Security logging Pre-reboot security logs + Security logs collected + %1$d logs in total Wipe data Wipe external storage Wipe protected data From b5218d7ee5683aa3033dc7883769317e0e114645 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Thu, 16 Oct 2025 17:45:01 +0800 Subject: [PATCH 14/26] Bugfix (#181, #182, #183, #185) --- .../java/com/bintianqi/owndroid/AppLock.kt | 6 +- .../com/bintianqi/owndroid/DhizukuServer.kt | 3 +- .../com/bintianqi/owndroid/MainActivity.kt | 14 +- .../com/bintianqi/owndroid/MyViewModel.kt | 24 +- .../bintianqi/owndroid/NotificationUtils.kt | 53 ++-- .../com/bintianqi/owndroid/PackageChooser.kt | 7 +- .../java/com/bintianqi/owndroid/Settings.kt | 45 ++-- .../com/bintianqi/owndroid/ShortcutUtils.kt | 2 - .../main/java/com/bintianqi/owndroid/Utils.kt | 31 +++ .../bintianqi/owndroid/dpm/Applications.kt | 67 ++--- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 5 +- .../com/bintianqi/owndroid/dpm/Network.kt | 234 ++++++++++-------- .../com/bintianqi/owndroid/dpm/Password.kt | 25 +- .../com/bintianqi/owndroid/dpm/Permissions.kt | 27 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 114 ++++----- .../bintianqi/owndroid/dpm/UserRestriction.kt | 14 +- .../java/com/bintianqi/owndroid/dpm/Users.kt | 25 +- .../com/bintianqi/owndroid/dpm/WorkProfile.kt | 4 +- .../com/bintianqi/owndroid/ui/AppInstaller.kt | 3 +- .../com/bintianqi/owndroid/ui/Components.kt | 21 +- 20 files changed, 377 insertions(+), 347 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt index d3c0052..e25a6c3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,8 +44,8 @@ import androidx.compose.ui.window.DialogProperties fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismiss, DialogProperties(true, false)) { val context = LocalContext.current val fm = LocalFocusManager.current - var input by remember { mutableStateOf("") } - var isError by remember { mutableStateOf(false) } + var input by rememberSaveable { mutableStateOf("") } + var isError by rememberSaveable { mutableStateOf(false) } fun unlock() { if(input.hash() == SP.lockPasswordHash) { fm.clearFocus() diff --git a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt index efe379f..51bce41 100644 --- a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt +++ b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -103,7 +104,7 @@ class DhizukuActivity : ComponentActivity() { enableEdgeToEdge() val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) setContent { - var appLockDialog by remember { mutableStateOf(false) } + var appLockDialog by rememberSaveable { mutableStateOf(false) } OwnDroidTheme(theme) { if (!appLockDialog) AlertDialog( icon = { diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 165e88e..8e0fbcc 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -14,10 +14,9 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -400,7 +399,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { InstallSystemUpdateScreen(vm::installSystemUpdate, ::navigateUp) } composable { - FrpPolicyScreen(vm::getFrpPolicy, vm::setFrpPolicy, ::navigateUp) + FrpPolicyScreen(vm.getFrpPolicy(), vm::setFrpPolicy, ::navigateUp) } composable { WipeDataScreen(vm::wipeData, ::navigateUp) } @@ -641,13 +640,14 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { AppearanceScreen(::navigateUp, vm.theme, vm::changeTheme) } composable { - AppLockSettingsScreen(vm::getAppLockConfig, vm::setAppLockConfig, ::navigateUp) + AppLockSettingsScreen(vm.getAppLockConfig(), vm::setAppLockConfig, ::navigateUp) } composable { ApiSettings(vm::getApiEnabled, vm::setApiKey, ::navigateUp) } composable { - NotificationsScreen(vm::getEnabledNotifications, vm::setNotificationEnabled, ::navigateUp) + NotificationsScreen(vm.enabledNotifications, vm::getEnabledNotifications, + vm::setNotificationEnabled, ::navigateUp) } composable { AboutScreen(::navigateUp) } } @@ -702,7 +702,7 @@ private fun HomeScreen(onNavigate: (Any) -> Unit) { scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { Column(Modifier .fillMaxSize() @@ -727,7 +727,7 @@ private fun HomeScreen(onNavigate: (Any) -> Unit) { HomePageItem(R.string.users,R.drawable.manage_accounts_fill0) { onNavigate(Users) } HomePageItem(R.string.password_and_keyguard, R.drawable.password_fill0) { onNavigate(Password) } } - Spacer(Modifier.padding(vertical = 20.dp)) + Spacer(Modifier.height(BottomPadding)) } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 929ff3b..5083798 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -163,21 +163,16 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun setApiKey(key: String) { SP.apiKeyHash = if (key.isEmpty()) "" else key.hash() } - fun getEnabledNotifications(): List { + val enabledNotifications = MutableStateFlow(emptyList()) + fun getEnabledNotifications() { val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } - return if (list == null) { - NotificationType.entries - } else { - NotificationType.entries.filter { it.id in list } - } + enabledNotifications.value = list ?: NotificationType.entries.map { it.id } } fun setNotificationEnabled(type: NotificationType, enabled: Boolean) { - val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } - SP.notifications = if (list == null) { - NotificationType.entries.minus(type).map { it.id } - } else { - list.run { if (enabled) plus(type.id) else minus(type.id) } - }.joinToString { it.toString() } + enabledNotifications.update { list -> + if (enabled) list.plus(type.id) else list.minus(type.id) + } + SP.notifications = enabledNotifications.value.joinToString(",") { it.toString() } } val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST) @@ -267,7 +262,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun getPackagePermissions(name: String) { if (name.isValidPackageName) { packagePermissions.value = runtimePermissions.associate { - it.permission to DPM.getPermissionGrantState(DAR, name, it.permission) + it.id to DPM.getPermissionGrantState(DAR, name, it.id) } } else { packagePermissions.value = emptyMap() @@ -1027,7 +1022,8 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } else { Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { override fun onRequestPermission(grantResult: Int) { - if(grantResult == PackageManager.PERMISSION_GRANTED) onSucceed() + if (grantResult == PackageManager.PERMISSION_GRANTED) onSucceed() + else callback(false, application.getString(R.string.dhizuku_permission_not_granted)) } }) } diff --git a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt index 5e90d18..2b664c8 100644 --- a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt @@ -16,9 +16,9 @@ object NotificationUtils { NotificationManagerCompat.from(context).createNotificationChannelsCompat(channels) } fun sendBasicNotification( - context: Context, type: NotificationType, channel: MyNotificationChannel, text: String + context: Context, type: NotificationType, text: String ) { - val notification = NotificationCompat.Builder(context, channel.id) + val notification = NotificationCompat.Builder(context, type.channel.id) .setSmallIcon(type.icon) .setContentTitle(context.getString(type.text)) .setContentText(text) @@ -29,7 +29,7 @@ object NotificationUtils { fun notifyEvent(context: Context, type: NotificationType, text: String) { val enabledNotifications = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } if (enabledNotifications == null || type.id in enabledNotifications) { - sendBasicNotification(context, type, MyNotificationChannel.Events, text) + sendBasicNotification(context, type, text) } } fun cancel(context: Context, type: NotificationType) { @@ -38,19 +38,40 @@ object NotificationUtils { } } -enum class NotificationType(val id: Int, val text: Int, val icon: Int) { - LockTaskMode(1, R.string.lock_task_mode, R.drawable.lock_fill0), - PasswordChanged(2, R.string.password_changed, R.drawable.password_fill0), - UserAdded(3, R.string.user_added, R.drawable.person_add_fill0), - UserStarted(4, R.string.user_started, R.drawable.person_fill0), - UserSwitched(5, R.string.user_switched, R.drawable.person_fill0), - UserStopped(6, R.string.user_stopped, R.drawable.person_off), - UserRemoved(7, R.string.user_removed, R.drawable.person_remove_fill0), - BugReportShared(8, R.string.bug_report_shared, R.drawable.bug_report_fill0), - BugReportSharingDeclined(9, R.string.bug_report_sharing_declined, R.drawable.bug_report_fill0), - BugReportFailed(10, R.string.bug_report_failed, R.drawable.bug_report_fill0), - SystemUpdatePending(11, R.string.system_update_pending, R.drawable.system_update_fill0), - SecurityLogsCollected(12, R.string.security_logs_collected, R.drawable.description_fill0), +enum class NotificationType( + val id: Int, val text: Int, val icon: Int, val channel: MyNotificationChannel +) { + LockTaskMode( + 1, R.string.lock_task_mode, R.drawable.lock_fill0, MyNotificationChannel.LockTaskMode + ), + PasswordChanged( + 2, R.string.password_changed, R.drawable.password_fill0, MyNotificationChannel.Events + ), + UserAdded(3, R.string.user_added, R.drawable.person_add_fill0, MyNotificationChannel.Events), + UserStarted(4, R.string.user_started, R.drawable.person_fill0, MyNotificationChannel.Events), + UserSwitched(5, R.string.user_switched, R.drawable.person_fill0, MyNotificationChannel.Events), + UserStopped(6, R.string.user_stopped, R.drawable.person_off, MyNotificationChannel.Events), + UserRemoved( + 7, R.string.user_removed, R.drawable.person_remove_fill0, MyNotificationChannel.Events + ), + BugReportShared( + 8, R.string.bug_report_shared, R.drawable.bug_report_fill0, MyNotificationChannel.Events + ), + BugReportSharingDeclined( + 9, R.string.bug_report_sharing_declined, R.drawable.bug_report_fill0, + MyNotificationChannel.Events + ), + BugReportFailed( + 10, R.string.bug_report_failed, R.drawable.bug_report_fill0, MyNotificationChannel.Events + ), + SystemUpdatePending( + 11, R.string.system_update_pending, R.drawable.system_update_fill0, + MyNotificationChannel.Events + ), + SecurityLogsCollected( + 12, R.string.security_logs_collected, R.drawable.description_fill0, + MyNotificationChannel.SecurityLogging + ), } enum class MyNotificationChannel(val id: String, val text: Int, val importance: Int) { diff --git a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt index 10c95ed..a47eba3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt +++ b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt @@ -10,10 +10,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -146,7 +145,7 @@ fun AppChooserScreen( colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn(Modifier.fillMaxSize().padding(paddingValues)) { if (progress < 1F) stickyHeader { @@ -174,7 +173,7 @@ fun AppChooserScreen( } } } - item { Spacer(Modifier.padding(vertical = 30.dp)) } + item { Spacer(Modifier.height(60.dp)) } } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/Settings.kt index 24e0185..a004147 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Settings.kt @@ -12,10 +12,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -39,9 +37,9 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -115,7 +113,7 @@ fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -231,22 +229,15 @@ data class AppLockConfig( @Composable fun AppLockSettingsScreen( - getConfig: () -> AppLockConfig, setConfig: (AppLockConfig) -> Unit, + config: AppLockConfig, setConfig: (AppLockConfig) -> Unit, onNavigateUp: () -> Unit ) = MyScaffold(R.string.app_lock, onNavigateUp) { - var password by remember { mutableStateOf("") } - var confirmPassword by remember { mutableStateOf("") } - var allowBiometrics by remember { mutableStateOf(false) } - var lockWhenLeaving by remember { mutableStateOf(false) } - var alreadySet by remember { mutableStateOf(false) } + var password by rememberSaveable { mutableStateOf(config.password ?: "") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + var allowBiometrics by rememberSaveable { mutableStateOf(config.biometrics) } + var lockWhenLeaving by rememberSaveable { mutableStateOf(config.whenLeaving) } + var alreadySet by rememberSaveable { mutableStateOf(config.password != null) } val isInputLegal = password.length !in 1..3 && (alreadySet || password.isNotBlank()) - LaunchedEffect(Unit) { - val config = getConfig() - password = config.password ?: "" - allowBiometrics = config.biometrics - lockWhenLeaving = config.whenLeaving - alreadySet = config.password != null - } OutlinedTextField( password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), label = { Text(stringResource(R.string.password)) }, @@ -305,7 +296,7 @@ fun ApiSettings( var alreadyEnabled by remember { mutableStateOf(getEnabled()) } MyScaffold(R.string.api, onNavigateUp) { var enabled by remember { mutableStateOf(alreadyEnabled) } - var key by remember { mutableStateOf("") } + var key by rememberSaveable { mutableStateOf("") } SwitchItem(R.string.enable, state = enabled, onCheckedChange = { enabled = it }, padding = false) @@ -339,15 +330,17 @@ fun ApiSettings( @Composable fun NotificationsScreen( - getState: () -> List, setNotification: (NotificationType, Boolean) -> Unit, - onNavigateUp: () -> Unit + enabledNotifications: StateFlow>, getState: () -> Unit, + setNotification: (NotificationType, Boolean) -> Unit, onNavigateUp: () -> Unit ) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { - val enabledNotifications = remember { mutableStateListOf(*getState().toTypedArray()) } - NotificationType.entries.forEach { type -> - SwitchItem(type.text, type in enabledNotifications, { - setNotification(type, it) - enabledNotifications.run { if (it) plusAssign(type) else minusAssign(type) } - }) + val notifications by enabledNotifications.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + getState() + } + NotificationType.entries.filter { + it.channel == MyNotificationChannel.Events + }.forEach { type -> + SwitchItem(type.text, type.id in notifications, { setNotification(type, it) }) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt index edc0056..efff2dd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt @@ -5,8 +5,6 @@ import android.content.Intent import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat -import java.security.SecureRandom -import kotlin.io.encoding.Base64 object ShortcutUtils { fun setAllShortcuts(context: Context, enabled: Boolean) { diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index ed81a05..197244d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -9,8 +9,22 @@ import android.net.Uri import android.os.Build import android.widget.Toast import androidx.annotation.StringRes +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.union +import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json @@ -91,6 +105,8 @@ fun exportLogs(context: Context, uri: Uri) { val HorizontalPadding = 16.dp +val BottomPadding = 60.dp + @OptIn(ExperimentalStdlibApi::class) fun String.hash(): String { val md = MessageDigest.getInstance("SHA-256") @@ -129,3 +145,18 @@ fun generateBase64Key(length: Int): String { SecureRandom().nextBytes(ba) return Base64.withPadding(Base64.PaddingOption.ABSENT).encode(ba) } + +fun Modifier.clickableTextField(onClick: () -> Unit) = + pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) onClick() + } + } + +@Composable +fun adaptiveInsets(): WindowInsets { + val navbar = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) + return WindowInsets.ime.union(navbar).union(WindowInsets.displayCutout) +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index b95f472..cb8f9a1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -15,15 +15,14 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -53,7 +52,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -73,10 +71,12 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.AppInfo import com.bintianqi.owndroid.AppInstallerActivity +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem import com.bintianqi.owndroid.ui.FunctionItem @@ -156,7 +156,7 @@ fun ApplicationsFeaturesScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Un scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( Modifier @@ -231,7 +231,7 @@ fun ApplicationDetailsScreen( ) { val packageName = param.packageName val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall + var dialog by rememberSaveable { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall val info = vm.getAppInfo(packageName) val status by vm.appStatus.collectAsStateWithLifecycle() LaunchedEffect(Unit) { vm.getAppStatus(packageName) } @@ -299,8 +299,8 @@ fun PermissionsManagerScreen( ) { val packageNameParam = param.packageName val privilege by Privilege.status.collectAsStateWithLifecycle() - var packageName by remember { mutableStateOf(packageNameParam ?: "") } - var selectedPermission by remember { mutableStateOf(null) } + var packageName by rememberSaveable { mutableStateOf(packageNameParam ?: "") } + var selectedPermission by rememberSaveable { mutableIntStateOf(-1) } val permissions by packagePermissions.collectAsStateWithLifecycle() LaunchedEffect(Unit) { packageName = chosenPackage.receive() @@ -316,19 +316,19 @@ fun PermissionsManagerScreen( Spacer(Modifier.padding(vertical = 4.dp)) } } - items(runtimePermissions, { it.permission }) { + itemsIndexed(runtimePermissions, { _, it -> it.id }) { index, it -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable(packageName.isValidPackageName) { - selectedPermission = it + selectedPermission = index } .padding(8.dp) ) { Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp)) Column { - val state = when(permissions[it.permission]) { + val state = when(permissions[it.id]) { PERMISSION_GRANT_STATE_DEFAULT -> R.string.default_stringres PERMISSION_GRANT_STATE_GRANTED -> R.string.granted PERMISSION_GRANT_STATE_DENIED -> R.string.denied @@ -340,17 +340,18 @@ fun PermissionsManagerScreen( } } item { - Spacer(Modifier.padding(vertical = 30.dp)) + Spacer(Modifier.height(BottomPadding)) } } - if(selectedPermission != null) { + if(selectedPermission != -1) { + val permission = runtimePermissions[selectedPermission] fun changeState(state: Int) { - val result = setPackagePermission(packageName, selectedPermission!!.permission, state) - if (result) selectedPermission = null + val result = setPackagePermission(packageName, permission.id, state) + if (result) selectedPermission = -1 } @Composable fun GrantPermissionItem(label: Int, status: Int) { - val selected = permissions[selectedPermission!!.permission] == status + val selected = permissions[permission.id] == status Row( Modifier .fillMaxWidth() @@ -365,14 +366,14 @@ fun PermissionsManagerScreen( } } AlertDialog( - onDismissRequest = { selectedPermission = null }, - confirmButton = { TextButton({ selectedPermission = null }) { Text(stringResource(R.string.cancel)) } }, - title = { Text(stringResource(selectedPermission!!.label)) }, + onDismissRequest = { selectedPermission = -1 }, + confirmButton = { TextButton({ selectedPermission = -1 }) { Text(stringResource(R.string.cancel)) } }, + title = { Text(stringResource(permission.label)) }, text = { Column { - Text(selectedPermission!!.permission) + Text(permission.id) Spacer(Modifier.padding(vertical = 4.dp)) - if(!(VERSION.SDK_INT >= 31 && selectedPermission!!.profileOwnerRestricted && privilege.profile)) { + if(!(VERSION.SDK_INT >= 31 && permission.profileOwnerRestricted && privilege.profile)) { GrantPermissionItem(R.string.granted, PERMISSION_GRANT_STATE_GRANTED) } GrantPermissionItem(R.string.denied, PERMISSION_GRANT_STATE_DENIED) @@ -393,8 +394,8 @@ fun ClearAppStorageScreen( chosenPackage: Channel, onChoosePackage: () -> Unit, onClear: (String, (Boolean) -> Unit) -> Unit, onNavigateUp: () -> Unit ) { - var dialog by remember { mutableStateOf(false) } - var packageName by remember { mutableStateOf("") } + var dialog by rememberSaveable { mutableStateOf(false) } + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { packageName = chosenPackage.receive() } @@ -418,7 +419,7 @@ private fun ClearAppStorageDialog( packageName: String, onClear: (String, (Boolean) -> Unit) -> Unit, onClose: () -> Unit ) { val context = LocalContext.current - var clearing by remember { mutableStateOf(false) } + var clearing by rememberSaveable { mutableStateOf(false) } AlertDialog( title = { Text(stringResource(R.string.clear_app_storage)) }, text = { @@ -457,8 +458,8 @@ fun UninstallAppScreen( chosenPackage: Channel, onChoosePackage: () -> Unit, onUninstall: (String, (String?) -> Unit) -> Unit, onNavigateUp: () -> Unit ) { - var dialog by remember { mutableStateOf(false) } - var packageName by remember { mutableStateOf("") } + var dialog by rememberSaveable { mutableStateOf(false) } + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { packageName = chosenPackage.receive() } @@ -483,8 +484,8 @@ fun UninstallAppScreen( private fun UninstallAppDialog( packageName: String, onUninstall: (String, (String?) -> Unit) -> Unit, onClose: () -> Unit ) { - var uninstalling by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } + var uninstalling by rememberSaveable { mutableStateOf(false) } + var errorMessage by rememberSaveable { mutableStateOf(null) } AlertDialog( title = { Text(stringResource(R.string.uninstall)) }, text = { @@ -525,7 +526,7 @@ fun InstallExistingAppScreen( onInstall: (String) -> Boolean, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var packageName by remember { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { packageName = chosenPackage.receive() } @@ -561,7 +562,7 @@ fun CredentialManagerPolicyScreen( val context = LocalContext.current var policy by rememberSaveable { mutableIntStateOf(getCmPolicy()) } val packages by cmPackages.collectAsStateWithLifecycle() - var packageName by remember { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { packageName = chosenPackage.receive() } @@ -623,7 +624,7 @@ fun PermittedAsAndImPackages( ) { val context = LocalContext.current val packages by packagesState.collectAsStateWithLifecycle() - var packageName by remember { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } var allowAll by rememberSaveable { mutableStateOf(getPackages()) } LaunchedEffect(Unit) { packageName = chosenPackage.receive() @@ -673,7 +674,7 @@ fun EnableSystemAppScreen( onEnable: (String) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var packageName by remember { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { packageName = chosenPackage.receive() } @@ -704,7 +705,7 @@ fun SetDefaultDialerScreen( chosenPackage: Channel, onChoosePackage: () -> Unit, onSet: (String) -> Unit, onNavigateUp: () -> Unit ) { - var packageName by remember { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { packageName = chosenPackage.receive() } @@ -743,7 +744,7 @@ fun PackageFunctionScreen( chosenPackage: Channel, onChoosePackage: () -> Unit, notes: Int? = null ) { val packages by packagesState.collectAsStateWithLifecycle() - var packageName by remember { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { onGet() packageName = chosenPackage.receive() diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index c075f31..89c9ef3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -15,7 +15,6 @@ import android.os.Build.VERSION import android.util.Log import androidx.annotation.RequiresApi import com.bintianqi.owndroid.MyApplication -import com.bintianqi.owndroid.MyNotificationChannel import com.bintianqi.owndroid.NotificationType import com.bintianqi.owndroid.NotificationUtils import com.bintianqi.owndroid.Privilege @@ -93,7 +92,7 @@ fun Context.getPackageInstaller(): PackageInstaller { val dhizukuErrorStatus = MutableStateFlow(0) data class PermissionItem( - val permission: String, + val id: String, val label: Int, val icon: Int, val profileOwnerRestricted: Boolean = false, @@ -497,7 +496,7 @@ fun retrieveSecurityLogs(app: MyApplication) { val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR) ?: return@launch app.myRepo.writeSecurityLogs(logs) NotificationUtils.sendBasicNotification( - app, NotificationType.SecurityLogsCollected, MyNotificationChannel.SecurityLogging, + app, NotificationType.SecurityLogsCollected, app.getString(R.string.n_logs_in_total, logs.size) ) } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 5be58f1..85368d9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -24,18 +24,14 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -65,6 +61,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon @@ -113,12 +110,13 @@ import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.formatFileSize +import com.bintianqi.owndroid.clickableTextField import com.bintianqi.owndroid.formatDate +import com.bintianqi.owndroid.formatFileSize +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.ErrorDialog -import com.bintianqi.owndroid.ui.ExpandExposedTextFieldIcon import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem import com.bintianqi.owndroid.ui.FunctionItem @@ -181,8 +179,8 @@ fun NetworkOptionsScreen( getLanEnabled: () -> Boolean, setLanEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit ) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } - var lanEnabled by remember { mutableStateOf(getLanEnabled()) } + var dialog by rememberSaveable { mutableIntStateOf(0) } + var lanEnabled by rememberSaveable { mutableStateOf(getLanEnabled()) } MyScaffold(R.string.options, onNavigateUp, 0.dp) { if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { SwitchItem(R.string.lockdown_admin_configured_network, icon = R.drawable.wifi_password_fill0, @@ -224,7 +222,7 @@ fun WifiScreen( colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier.fillMaxSize().padding(paddingValues) @@ -266,7 +264,7 @@ fun WifiOverviewScreen( ) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - var macDialog by remember { mutableStateOf(false) } + var macDialog by rememberSaveable { mutableStateOf(false) } Column(Modifier.fillMaxSize()) { Spacer(Modifier.height(10.dp)) Row( @@ -384,7 +382,7 @@ private fun SavedNetworks( removeNetwork: (Int) -> Boolean, editNetwork: (Int) -> Unit ) { val context = LocalContext.current - var dialog by remember { mutableIntStateOf(-1) } + var dialog by rememberSaveable { mutableIntStateOf(-1) } val list by configuredNetworks.collectAsStateWithLifecycle() LaunchedEffect(Unit) { getConfiguredNetworks() @@ -513,7 +511,7 @@ fun UpdateNetworkScreen(info: WifiInfo, setNetwork: (WifiInfo) -> Boolean, onNav colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier.fillMaxSize().padding(paddingValues) @@ -537,21 +535,21 @@ private fun AddNetworkScreen( val context = LocalContext.current val fm = LocalFocusManager.current /** 0: None, 1:Status, 2:Security, 3:MAC randomization, 4:Static IP, 5:Proxy, 6:Hidden SSID */ - var menu by remember { mutableIntStateOf(0) } - var status by remember { mutableStateOf(WifiStatus.Enabled) } - var ssid by remember { mutableStateOf("") } - var hiddenSsid by remember { mutableStateOf(false) } - var security by remember { mutableStateOf(WifiSecurity.Open) } - var password by remember { mutableStateOf("") } - var macRandomization by remember { mutableStateOf(WifiMacRandomization.None) } - var ipMode by remember { mutableStateOf(IpMode.Dhcp) } - var ipAddress by remember { mutableStateOf("") } - var gatewayAddress by remember { mutableStateOf("") } - var dnsServers by remember { mutableStateOf("") } - var proxyMode by remember { mutableStateOf(ProxyMode.None) } - var httpProxyHost by remember { mutableStateOf("") } - var httpProxyPort by remember { mutableStateOf("") } - var httpProxyExclList by remember { mutableStateOf("") } + var menu by rememberSaveable { mutableIntStateOf(0) } + var status by rememberSaveable { mutableStateOf(WifiStatus.Enabled) } + var ssid by rememberSaveable { mutableStateOf("") } + var hiddenSsid by rememberSaveable { mutableStateOf(false) } + var security by rememberSaveable { mutableStateOf(WifiSecurity.Open) } + var password by rememberSaveable { mutableStateOf("") } + var macRandomization by rememberSaveable { mutableStateOf(WifiMacRandomization.None) } + var ipMode by rememberSaveable { mutableStateOf(IpMode.Dhcp) } + var ipAddress by rememberSaveable { mutableStateOf("") } + var gatewayAddress by rememberSaveable { mutableStateOf("") } + var dnsServers by rememberSaveable { mutableStateOf("") } + var proxyMode by rememberSaveable { mutableStateOf(ProxyMode.None) } + var httpProxyHost by rememberSaveable { mutableStateOf("") } + var httpProxyPort by rememberSaveable { mutableStateOf("") } + var httpProxyExclList by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { if (updating) { hiddenSsid = null @@ -575,7 +573,7 @@ private fun AddNetworkScreen( Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.status)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == 1) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 1) }, ) ExposedDropdownMenu(menu == 1, { menu = 0 }) { WifiStatus.entries.forEach { @@ -600,7 +598,7 @@ private fun AddNetworkScreen( stringResource(hiddenSsid?.yesOrNo ?: R.string.unchanged), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.hidden_ssid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == 1) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 1) } ) DropdownMenu(menu == 6, { menu = 0 }) { if (updating) DropdownMenuItem( @@ -633,7 +631,7 @@ private fun AddNetworkScreen( stringResource(security?.text ?: R.string.unchanged), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.security)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == 1) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 1) } ) ExposedDropdownMenu(menu == 2, { menu = 0 }) { if (updating) UnchangedMenuItem { security = null } @@ -662,7 +660,7 @@ private fun AddNetworkScreen( stringResource(macRandomization?.text ?: R.string.unchanged), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.mac_randomization)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == 3) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 3) }, ) ExposedDropdownMenu(menu == 3, { menu = 0 }) { if (updating) UnchangedMenuItem { macRandomization = null } @@ -686,7 +684,7 @@ private fun AddNetworkScreen( stringResource(ipMode?.text ?: R.string.unchanged), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.ip_settings)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == 4) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 4) }, ) ExposedDropdownMenu(menu == 4, { menu = 0 }) { if (updating) UnchangedMenuItem { ipMode = null } @@ -737,7 +735,7 @@ private fun AddNetworkScreen( stringResource(proxyMode?.text ?: R.string.unchanged), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.proxy)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == 5) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 5) }, ) ExposedDropdownMenu(menu == 5, { menu = 0 }) { if (updating) UnchangedMenuItem { proxyMode = null } @@ -783,19 +781,29 @@ private fun AddNetworkScreen( } Button( onClick = { + val proxyConf = if (proxyMode == ProxyMode.Http) { + ProxyConf( + httpProxyHost, httpProxyPort.toInt(), + httpProxyExclList.lines().filter { it.isNotBlank() } + ) + } else null + val ipConf = if (ipMode == IpMode.Static) { + IpConf(ipAddress, gatewayAddress, dnsServers.lines().filter { it.isNotBlank() }) + } else null val result = setNetwork(WifiInfo( -1, ssid, hiddenSsid, "", macRandomization, status, security, password, ipMode, - IpConf(ipAddress, gatewayAddress, dnsServers.lines().filter { it.isNotBlank() }), - proxyMode, ProxyConf(httpProxyHost, httpProxyPort.toInt(), httpProxyExclList.lines().filter { it.isNotBlank() }) + ipConf, proxyMode, proxyConf )) context.showOperationResultToast(result) if (result) onNavigateUp() }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + enabled = (proxyMode != ProxyMode.Http || + (httpProxyPort.toIntOrNull() != null && httpProxyHost.isNotBlank())) ) { Text(stringResource(if (updating) R.string.update else R.string.add)) } - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(60.dp)) } } @@ -807,7 +815,7 @@ fun WifiSecurityLevelScreen( getLevel: () -> Int, setLevel: (Int) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var level by remember { mutableIntStateOf(getLevel()) } + var level by rememberSaveable { mutableIntStateOf(getLevel()) } MyScaffold(R.string.min_wifi_security_level, onNavigateUp, 0.dp) { FullWidthRadioButtonItem(R.string.wifi_security_open, level == WIFI_SECURITY_OPEN) { level = WIFI_SECURITY_OPEN } FullWidthRadioButtonItem("WEP, WPA(2)-PSK", level == WIFI_SECURITY_PERSONAL) { level = WIFI_SECURITY_PERSONAL } @@ -845,8 +853,8 @@ fun WifiSsidPolicyScreen( val context = LocalContext.current val focusMgr = LocalFocusManager.current MyScaffold(R.string.wifi_ssid_policy, onNavigateUp, 0.dp) { - var type by remember { mutableStateOf(SsidPolicyType.None) } - val list = remember { mutableStateListOf() } + var type by rememberSaveable { mutableStateOf(SsidPolicyType.None) } + val list = rememberSaveable { mutableStateListOf() } LaunchedEffect(Unit) { getPolicy().let { type = it.type @@ -959,11 +967,7 @@ fun NetworkStatsScreen( var uid by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.UID_ALL) } var tag by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.TAG_NONE) } var state by rememberSaveable { mutableStateOf(NetworkStatsState.All) } - val startTimeIs = remember { MutableInteractionSource() } - val endTimeIs = remember { MutableInteractionSource() } - if (startTimeIs.collectIsPressedAsState().value) menu = NetworkStatsMenu.StartTime - if (endTimeIs.collectIsPressedAsState().value) menu = NetworkStatsMenu.EndTime - var errorMessage by remember { mutableStateOf(null) } + var errorMessage by rememberSaveable { mutableStateOf(null) } MyScaffold(R.string.network_stats, onNavigateUp) { ExposedDropdownMenuBox( menu == NetworkStatsMenu.Type, @@ -974,7 +978,9 @@ fun NetworkStatsScreen( stringResource(type.text), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.type)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Type) } + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Type) + } ) ExposedDropdownMenu( menu == NetworkStatsMenu.Type, { menu = NetworkStatsMenu.None } @@ -1001,7 +1007,9 @@ fun NetworkStatsScreen( stringResource(target.text), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.target)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Target) } + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Target) + } ) ExposedDropdownMenu( menu == NetworkStatsMenu.Target, { menu = NetworkStatsMenu.None } @@ -1028,7 +1036,9 @@ fun NetworkStatsScreen( stringResource(networkType.text), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.network_type)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.NetworkType) } + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.NetworkType) + } ) ExposedDropdownMenu( menu == NetworkStatsMenu.NetworkType, { menu = NetworkStatsMenu.None } @@ -1045,18 +1055,22 @@ fun NetworkStatsScreen( } } OutlinedTextField( - value = startTime.let { if(it == -1L) "" else formatDate(it) }, onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.start_time)) }, - interactionSource = startTimeIs, - isError = startTime >= endTime, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + formatDate(startTime), {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = NetworkStatsMenu.StartTime } + .padding(bottom = 4.dp), + readOnly = true, label = { Text(stringResource(R.string.start_time)) }, + isError = startTime >= endTime ) OutlinedTextField( - value = formatDate(endTime), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.end_time)) }, - interactionSource = endTimeIs, - isError = startTime >= endTime, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + formatDate(endTime), {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = NetworkStatsMenu.EndTime } + .padding(bottom = 4.dp), + readOnly = true, label = { Text(stringResource(R.string.end_time)) }, + isError = startTime >= endTime ) if(target == NetworkStatsTarget.Uid || target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState) ExposedDropdownMenuBox( @@ -1077,7 +1091,7 @@ fun NetworkStatsScreen( it.toIntOrNull()?.let { num -> uid = num } }, readOnly = readOnly, label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Uid) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Uid) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = !readOnly && uidText.toIntOrNull() == null, modifier = Modifier @@ -1131,7 +1145,9 @@ fun NetworkStatsScreen( it.toIntOrNull()?.let { num -> tag = num } }, readOnly = readOnly, label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.Tag) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Tag) + }, isError = !readOnly && tagText.toIntOrNull() == null, modifier = Modifier .menuAnchor(if(readOnly) MenuAnchorType.PrimaryNotEditable else MenuAnchorType.PrimaryEditable) @@ -1168,7 +1184,9 @@ fun NetworkStatsScreen( stringResource(state.text), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), readOnly = true, label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == NetworkStatsMenu.State) } + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.State) + } ) ExposedDropdownMenu( menu == NetworkStatsMenu.State, { menu = NetworkStatsMenu.None } @@ -1251,7 +1269,7 @@ data class NetworkStatsData( fun NetworkStatsViewerScreen( data: List, clearData: () -> Unit, onNavigateUp: () -> Unit ) { - var index by remember { mutableIntStateOf(0) } + var index by rememberSaveable { mutableIntStateOf(0) } val size = data.size val ps = rememberPagerState { size } index = ps.currentPage @@ -1362,7 +1380,7 @@ fun PrivateDnsScreen( val context = LocalContext.current val focusMgr = LocalFocusManager.current var mode by remember { mutableStateOf(PrivateDnsMode.Opportunistic) } - var inputHost by remember { mutableStateOf("") } + var inputHost by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { val conf = getPrivateDns() mode = conf.mode @@ -1451,12 +1469,12 @@ fun RecommendedGlobalProxyScreen( ) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var type by remember { mutableStateOf(ProxyType.Off) } - var pacUrl by remember { mutableStateOf("") } - var specifyPort by remember { mutableStateOf(false) } - var host by remember { mutableStateOf("") } - var port by remember { mutableStateOf("") } - var exclList by remember { mutableStateOf("") } + var type by rememberSaveable { mutableStateOf(ProxyType.Off) } + var pacUrl by rememberSaveable { mutableStateOf("") } + var specifyPort by rememberSaveable { mutableStateOf(false) } + var host by rememberSaveable { mutableStateOf("") } + var port by rememberSaveable { mutableStateOf("") } + var exclList by rememberSaveable { mutableStateOf("") } MyScaffold(R.string.recommended_global_proxy, onNavigateUp, 0.dp) { ProxyType.entries.forEach { FullWidthRadioButtonItem(it.text, type == it) { type = it } @@ -1576,7 +1594,7 @@ fun PreferentialNetworkServiceScreen( pnsConfigs: StateFlow>, getConfigs: () -> Unit, onNavigateUp: () -> Unit, onNavigate: (AddPreferentialNetworkServiceConfig) -> Unit ) { - var masterEnabled by remember { mutableStateOf(getEnabled()) } + var masterEnabled by rememberSaveable { mutableStateOf(getEnabled()) } val configs by pnsConfigs.collectAsStateWithLifecycle() LaunchedEffect(Unit) { getConfigs() @@ -1633,12 +1651,12 @@ fun AddPreferentialNetworkServiceConfigScreen( setConfig: (PreferentialNetworkServiceInfo, Boolean) -> Unit, onNavigateUp: () -> Unit ) { val updateMode = origin.id != -1 - var enabled by remember { mutableStateOf(origin.enabled) } - var id by remember { mutableIntStateOf(origin.id) } - var allowFallback by remember { mutableStateOf(origin.allowFallback) } - var blockNonMatching by remember { mutableStateOf(origin.blockNonMatching) } - var excludedUids by remember { mutableStateOf(origin.excludedUids.joinToString("\n")) } - var includedUids by remember { mutableStateOf(origin.includedUids.joinToString("\n")) } + var enabled by rememberSaveable { mutableStateOf(origin.enabled) } + var id by rememberSaveable { mutableIntStateOf(origin.id) } + var allowFallback by rememberSaveable { mutableStateOf(origin.allowFallback) } + var blockNonMatching by rememberSaveable { mutableStateOf(origin.blockNonMatching) } + var excludedUids by rememberSaveable { mutableStateOf(origin.excludedUids.joinToString("\n")) } + var includedUids by rememberSaveable { mutableStateOf(origin.includedUids.joinToString("\n")) } var dropdown by remember { mutableStateOf(false) } MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp) { SwitchItem(title = R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false) @@ -1647,7 +1665,7 @@ fun AddPreferentialNetworkServiceConfigScreen( if (id == -1) "" else id.toString(), {}, Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), readOnly = true, label = { Text("id") }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdown) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } ) ExposedDropdownMenu(dropdown, { dropdown = false }) { for (i in 1..5) { @@ -1724,7 +1742,7 @@ fun OverrideApnScreen( apnConfigs: StateFlow>, getConfigs: () -> Unit, getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit, onNavigateToAddSetting: (Int) -> Unit ) { - var enabled by remember { mutableStateOf(getEnabled()) } + var enabled by rememberSaveable { mutableStateOf(getEnabled()) } val configs by apnConfigs.collectAsStateWithLifecycle() LaunchedEffect(Unit) { getConfigs() } MyScaffold(R.string.override_apn, onNavigateUp, 0.dp) { @@ -1859,30 +1877,30 @@ fun AddApnSettingScreen( ) { val context = LocalContext.current var menu by remember { mutableStateOf(ApnMenu.None) } - var enabled by remember { mutableStateOf(true) } - var entryName by remember { mutableStateOf(origin?.name ?: "") } - var apnName by remember { mutableStateOf(origin?.apn ?: "") } - var apnType by remember { mutableIntStateOf(origin?.apnType ?: 0) } - var profileId by remember { mutableStateOf(origin?.profileId?.toString() ?: "") } - var carrierId by remember { mutableStateOf(origin?.carrierId?.toString() ?: "") } - var authType by remember { mutableStateOf(ApnAuthType.None) } - var user by remember { mutableStateOf(origin?.username ?: "") } - var password by remember { mutableStateOf(origin?.password ?: "") } - var proxy by remember { mutableStateOf(origin?.proxy ?: "") } - var port by remember { mutableStateOf(origin?.port?.toString() ?: "") } - var mmsProxy by remember { mutableStateOf(origin?.mmsProxy ?: "") } - var mmsPort by remember { mutableStateOf(origin?.mmsPort?.toString() ?: "") } - var mmsc by remember { mutableStateOf(origin?.mmsc ?: "") } - var mtuV4 by remember { mutableStateOf(origin?.mtuV4?.toString() ?: "") } - var mtuV6 by remember { mutableStateOf(origin?.mtuV6?.toString() ?: "") } - var mvnoType by remember { mutableStateOf(origin?.mvno ?: ApnMvnoType.SPN) } - var networkType by remember { mutableIntStateOf(origin?.networkType ?: 0) } - var operatorNumeric by remember { mutableStateOf(origin?.operatorNumeric ?: "") } - var protocol by remember { mutableStateOf(origin?.protocol ?: ApnProtocol.Ip) } - var roamingProtocol by remember { mutableStateOf(origin?.roamingProtocol ?: ApnProtocol.Ip) } - var persistent by remember { mutableStateOf(origin?.persistent == true) } - var alwaysOn by remember { mutableStateOf(origin?.alwaysOn == true) } - var errorMessage: String? by remember { mutableStateOf(null) } + var enabled by rememberSaveable { mutableStateOf(true) } + var entryName by rememberSaveable { mutableStateOf(origin?.name ?: "") } + var apnName by rememberSaveable { mutableStateOf(origin?.apn ?: "") } + var apnType by rememberSaveable { mutableIntStateOf(origin?.apnType ?: 0) } + var profileId by rememberSaveable { mutableStateOf(origin?.profileId?.toString() ?: "") } + var carrierId by rememberSaveable { mutableStateOf(origin?.carrierId?.toString() ?: "") } + var authType by rememberSaveable { mutableStateOf(ApnAuthType.None) } + var user by rememberSaveable { mutableStateOf(origin?.username ?: "") } + var password by rememberSaveable { mutableStateOf(origin?.password ?: "") } + var proxy by rememberSaveable { mutableStateOf(origin?.proxy ?: "") } + var port by rememberSaveable { mutableStateOf(origin?.port?.toString() ?: "") } + var mmsProxy by rememberSaveable { mutableStateOf(origin?.mmsProxy ?: "") } + var mmsPort by rememberSaveable { mutableStateOf(origin?.mmsPort?.toString() ?: "") } + var mmsc by rememberSaveable { mutableStateOf(origin?.mmsc ?: "") } + var mtuV4 by rememberSaveable { mutableStateOf(origin?.mtuV4?.toString() ?: "") } + var mtuV6 by rememberSaveable { mutableStateOf(origin?.mtuV6?.toString() ?: "") } + var mvnoType by rememberSaveable { mutableStateOf(origin?.mvno ?: ApnMvnoType.SPN) } + var networkType by rememberSaveable { mutableIntStateOf(origin?.networkType ?: 0) } + var operatorNumeric by rememberSaveable { mutableStateOf(origin?.operatorNumeric ?: "") } + var protocol by rememberSaveable { mutableStateOf(origin?.protocol ?: ApnProtocol.Ip) } + var roamingProtocol by rememberSaveable { mutableStateOf(origin?.roamingProtocol ?: ApnProtocol.Ip) } + var persistent by rememberSaveable { mutableStateOf(origin?.persistent == true) } + var alwaysOn by rememberSaveable { mutableStateOf(origin?.alwaysOn == true) } + var errorMessage: String? by rememberSaveable { mutableStateOf(null) } MySmallTitleScaffold(R.string.apn_setting, onNavigateUp) { SwitchItem(R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false) OutlinedTextField( @@ -1950,7 +1968,7 @@ fun AddApnSettingScreen( OutlinedTextField( authType.text, {}, Modifier.fillMaxWidth(), label = { Text("Authentication type") }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.AuthType) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.AuthType) } ) ExposedDropdownMenu(menu == ApnMenu.AuthType, { menu = ApnMenu.None }) { ApnAuthType.entries.forEach { @@ -1970,7 +1988,7 @@ fun AddApnSettingScreen( OutlinedTextField( protocol.text, {}, Modifier.fillMaxWidth(), label = { Text("APN protocol") }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.Protocol) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.Protocol) } ) ExposedDropdownMenu(menu == ApnMenu.Protocol, { menu = ApnMenu.None }) { ApnProtocol.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { @@ -1991,7 +2009,9 @@ fun AddApnSettingScreen( OutlinedTextField( roamingProtocol.text, {}, Modifier.fillMaxWidth(), label = { Text("APN roaming protocol") }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.RoamingProtocol) } + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol) + } ) ExposedDropdownMenu(menu == ApnMenu.RoamingProtocol, { menu = ApnMenu.None }) { ApnProtocol.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { @@ -2050,7 +2070,9 @@ fun AddApnSettingScreen( mvnoType.text, {}, Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), readOnly = true, label = { Text("MVNO type") }, - trailingIcon = { ExpandExposedTextFieldIcon(menu == ApnMenu.RoamingProtocol) } + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol) + } ) ExposedDropdownMenu(menu == ApnMenu.MvnoType, { menu = ApnMenu.None }) { ApnMvnoType.entries.forEach { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt index 13bdc79..275362c 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -80,7 +81,7 @@ import kotlinx.serialization.Serializable fun PasswordScreen(vm: MyViewModel,onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.password_and_keyguard, onNavigateUp, 0.dp) { FunctionItem(R.string.password_info, icon = R.drawable.info_fill0) { onNavigate(PasswordInfo) } if (SP.displayDangerousFeatures) { @@ -210,7 +211,7 @@ fun PasswordInfoScreen( onNavigateUp: () -> Unit ) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } // 0:none, 1:password complexity + var dialog by rememberSaveable { mutableIntStateOf(0) } // 0:none, 1:password complexity MyScaffold(R.string.password_info, onNavigateUp, 0.dp) { if (VERSION.SDK_INT >= 29) { InfoItem(R.string.current_password_complexity, getComplexity().text, true) { dialog = 1 } @@ -242,8 +243,8 @@ fun ResetPasswordTokenScreen( clearToken: () -> Boolean, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var token by remember { mutableStateOf("") } - var state by remember { mutableStateOf(getState()) } + var token by rememberSaveable { mutableStateOf("") } + var state by rememberSaveable { mutableStateOf(getState()) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { context.popToast(R.string.token_activated) @@ -305,10 +306,10 @@ fun ResetPasswordTokenScreen( @Composable fun ResetPasswordScreen(resetPassword: (String, String, Int) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current - var password by remember { mutableStateOf("") } - var token by remember { mutableStateOf("") } - var flags by remember { mutableIntStateOf(0) } - var confirmPassword by remember { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var token by rememberSaveable { mutableStateOf("") } + var flags by rememberSaveable { mutableIntStateOf(0) } + var confirmPassword by rememberSaveable { mutableStateOf("") } MyScaffold(R.string.reset_password, onNavigateUp) { if (VERSION.SDK_INT >= 26) { OutlinedTextField( @@ -366,7 +367,7 @@ fun RequiredPasswordComplexityScreen( onNavigateUp: () -> Unit ) { val context = LocalContext.current - var complexity by remember { mutableStateOf(PasswordComplexity.None) } + var complexity by rememberSaveable { mutableStateOf(PasswordComplexity.None) } LaunchedEffect(Unit) { complexity = getComplexity() } MyScaffold(R.string.required_password_complexity, onNavigateUp, 0.dp) { PasswordComplexity.entries.forEach { @@ -415,8 +416,8 @@ fun KeyguardDisabledFeaturesScreen( onNavigateUp: () -> Unit ) { val context = LocalContext.current - var mode by remember { mutableStateOf(KeyguardDisableMode.None) } - var flags by remember { mutableIntStateOf(0) } + var mode by rememberSaveable { mutableStateOf(KeyguardDisableMode.None) } + var flags by rememberSaveable { mutableIntStateOf(0) } LaunchedEffect(Unit) { val config = getConfig() mode = config.mode @@ -462,7 +463,7 @@ fun RequiredPasswordQualityScreen(onNavigateUp: () -> Unit) { PASSWORD_QUALITY_BIOMETRIC_WEAK to R.string.password_quality_biometrics_weak, PASSWORD_QUALITY_NUMERIC_COMPLEX to R.string.password_quality_numeric_complex ) - var selectedItem by remember { mutableIntStateOf(PASSWORD_QUALITY_UNSPECIFIED) } + var selectedItem by rememberSaveable { mutableIntStateOf(PASSWORD_QUALITY_UNSPECIFIED) } LaunchedEffect(Unit) { selectedItem = Privilege.DPM.getPasswordQuality(Privilege.DAR) } MyScaffold(R.string.required_password_quality, onNavigateUp) { passwordQuality.forEach { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt index 9982458..55005d1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt @@ -16,11 +16,9 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items @@ -90,6 +88,7 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.Settings +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.InfoItem @@ -116,9 +115,9 @@ fun WorkModesScreen( ) { val privilege by Privilege.status.collectAsStateWithLifecycle() /** 0: none, 1: device owner, 2: circular progress indicator, 3: result, 4: deactivate, 5: command */ - var dialog by remember { mutableIntStateOf(0) } - var operationSucceed by remember { mutableStateOf(false) } - var resultText by remember { mutableStateOf("") } + var dialog by rememberSaveable { mutableIntStateOf(0) } + var operationSucceed by rememberSaveable { mutableStateOf(false) } + var resultText by rememberSaveable { mutableStateOf("") } LaunchedEffect(privilege) { if (!params.canNavigateUp && privilege.device) { delay(1000) @@ -182,7 +181,7 @@ fun WorkModesScreen( } ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> fun handleResult(succeeded: Boolean, output: String?) { operationSucceed = succeeded @@ -367,7 +366,7 @@ fun DhizukuServerSettingsScreen( getDhizukuClients: () -> Unit, updateDhizukuClient: (DhizukuClientInfo) -> Unit, getServerEnabled: () -> Boolean, setServerEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit ) { - var enabled by remember { mutableStateOf(getServerEnabled()) } + var enabled by rememberSaveable { mutableStateOf(getServerEnabled()) } val clients by dhizukuClients.collectAsStateWithLifecycle() LaunchedEffect(Unit) { getDhizukuClients() } MyLazyScaffold(R.string.dhizuku_server, onNavigateUp) { @@ -455,7 +454,7 @@ fun LockScreenInfoScreen( ) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var infoText by remember { mutableStateOf(getText()) } + var infoText by rememberSaveable { mutableStateOf(getText()) } MyScaffold(R.string.lock_screen_info, onNavigateUp) { OutlinedTextField( value = infoText, @@ -566,7 +565,7 @@ fun AddDelegatedAdminScreen( setDelegatedAdmin: (String, List) -> Unit, onNavigateUp: () -> Unit ) { val updateMode = data.pkg.isNotEmpty() - var input by remember { mutableStateOf(data.pkg) } + var input by rememberSaveable { mutableStateOf(data.pkg) } val scopes = rememberSaveable { mutableStateListOf(*data.scopes.toTypedArray()) } LaunchedEffect(Unit) { input = chosenPackage.receive() @@ -625,7 +624,7 @@ fun AddDelegatedAdminScreen( @Composable fun DeviceInfoScreen(vm: MyViewModel, onNavigateUp: () -> Unit) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.device_info, onNavigateUp, 0.dp) { if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.org)) { InfoItem(R.string.financed_device, vm.getDeviceFinanced().yesOrNo) @@ -666,8 +665,8 @@ fun SupportMessageScreen( setLongMessage: (String?) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var shortMsg by remember { mutableStateOf("") } - var longMsg by remember { mutableStateOf("") } + var shortMsg by rememberSaveable { mutableStateOf("") } + var longMsg by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { shortMsg = getShortMessage() longMsg = getLongMessage() @@ -750,8 +749,8 @@ fun TransferOwnershipScreen( transferOwnership: (ComponentName) -> Unit, onNavigateUp: () -> Unit, onTransferred: () -> Unit ) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var selectedIndex by remember { mutableIntStateOf(-1) } - var dialog by remember { mutableStateOf(false) } + var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } + var dialog by rememberSaveable { mutableStateOf(false) } val receivers by deviceAdmins.collectAsStateWithLifecycle() LaunchedEffect(Unit) { getDeviceAdmins() } MyLazyScaffold(R.string.transfer_ownership, onNavigateUp) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 45b3555..db4dcac 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -31,17 +31,13 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -110,7 +106,9 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP +import com.bintianqi.owndroid.clickableTextField import com.bintianqi.owndroid.formatDate +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -146,7 +144,7 @@ fun SystemManagerScreen( val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() /** 1: reboot, 2: bug report, 3: org name, 4: org id, 5: enrollment specific id*/ - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.system, onNavigateUp, 0.dp) { FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(SystemOptions) } FunctionItem(R.string.keyguard, icon = R.drawable.screen_lock_portrait_fill0) { onNavigate(Keyguard) } @@ -242,7 +240,7 @@ fun SystemManagerScreen( modifier = Modifier.fillMaxWidth() ) if(dialog in 3..5) { - var input by remember { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } AlertDialog( text = { val focusMgr = LocalFocusManager.current @@ -323,7 +321,7 @@ data class SystemOptionsStatus( @Composable fun SystemOptionsScreen(vm: MyViewModel, onNavigateUp: () -> Unit) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } val status by vm.systemOptionsStatus.collectAsStateWithLifecycle() LaunchedEffect(Unit) { vm.getSystemOptionsStatus() } MyScaffold(R.string.options, onNavigateUp, 0.dp) { @@ -436,7 +434,7 @@ fun KeyguardScreen( } if(VERSION.SDK_INT >= 23) Text(text = stringResource(R.string.lock_now), style = typography.headlineLarge) Spacer(Modifier.padding(vertical = 2.dp)) - var evictKey by remember { mutableStateOf(false) } + var evictKey by rememberSaveable { mutableStateOf(false) } Button( onClick = { lock(evictKey) }, modifier = Modifier.fillMaxWidth() @@ -475,7 +473,7 @@ fun HardwareMonitorScreen( onNavigateUp: () -> Unit ) { val properties by hardwareProperties.collectAsStateWithLifecycle() - var refreshInterval by remember { mutableFloatStateOf(1F) } + var refreshInterval by rememberSaveable { mutableFloatStateOf(1F) } val refreshIntervalMs = (refreshInterval * 1000).roundToLong() LaunchedEffect(Unit) { getHardwareProperties() @@ -540,18 +538,14 @@ fun HardwareMonitorScreen( fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var tab by remember { mutableIntStateOf(0) } + var tab by rememberSaveable { mutableIntStateOf(0) } val pagerState = rememberPagerState { 2 } tab = pagerState.currentPage val coroutine = rememberCoroutineScope() - var picker by remember { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker - var useCurrentTz by remember { mutableStateOf(true) } + var picker by rememberSaveable { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker + var useCurrentTz by rememberSaveable { mutableStateOf(true) } val datePickerState = rememberDatePickerState() val timePickerState = rememberTimePickerState(is24Hour = true) - val dateInteractionSource = remember { MutableInteractionSource() } - val timeInteractionSource = remember { MutableInteractionSource() } - if(dateInteractionSource.collectIsPressedAsState().value) picker = 1 - if(timeInteractionSource.collectIsPressedAsState().value) picker = 2 Scaffold( topBar = { TopAppBar( @@ -560,7 +554,7 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( Modifier @@ -592,17 +586,16 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un value = datePickerState.selectedDateMillis?.let { formatDate(it) } ?: "", onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.date)) }, - interactionSource = dateInteractionSource, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().clickableTextField { picker = 1 } ) OutlinedTextField( value = timePickerState.hour.toString().padStart(2, '0') + ":" + timePickerState.minute.toString().padStart(2, '0'), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.time)) }, - interactionSource = timeInteractionSource, modifier = Modifier .fillMaxWidth() + .clickableTextField { picker = 2 } .padding(vertical = 4.dp) ) CheckBoxItem(R.string.use_current_timezone, useCurrentTz) { @@ -620,13 +613,12 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un Text(stringResource(R.string.apply)) } } else { - var inputTime by remember { mutableStateOf("") } + var inputTime by rememberSaveable { mutableStateOf("") } OutlinedTextField( value = inputTime, label = { Text(stringResource(R.string.time_unit_ms)) }, onValueChange = { inputTime = it }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), modifier = Modifier.fillMaxWidth() ) Button( @@ -673,8 +665,8 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var inputTimezone by remember { mutableStateOf("") } - var dialog by remember { mutableStateOf(false) } + var inputTimezone by rememberSaveable { mutableStateOf("") } + var dialog by rememberSaveable { mutableStateOf(false) } val availableIds = TimeZone.getAvailableIDs() val validInput = inputTimezone in availableIds MyScaffold(R.string.change_timezone, onNavigateUp) { @@ -741,7 +733,7 @@ fun AutoTimePolicyScreen( getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit ) = MyScaffold(R.string.auto_time_policy, onNavigateUp, 0.dp) { val context = LocalContext.current - var policy by remember { mutableIntStateOf(getPolicy()) } + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } listOf( DevicePolicyManager.AUTO_TIME_ENABLED to R.string.enable, DevicePolicyManager.AUTO_TIME_DISABLED to R.string.disabled, @@ -772,7 +764,7 @@ fun AutoTimeZonePolicyScreen( getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit ) = MyScaffold(R.string.auto_timezone_policy, onNavigateUp, 0.dp) { val context = LocalContext.current - var policy by remember { mutableIntStateOf(getPolicy()) } + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } listOf( DevicePolicyManager.AUTO_TIME_ZONE_ENABLED to R.string.enable, DevicePolicyManager.AUTO_TIME_ZONE_DISABLED to R.string.disabled, @@ -980,7 +972,7 @@ fun ContentProtectionPolicyScreen( getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var policy by remember { mutableIntStateOf(getPolicy()) } + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.content_protection_policy, onNavigateUp, 0.dp) { mapOf( DevicePolicyManager.CONTENT_PROTECTION_NOT_CONTROLLED_BY_POLICY to R.string.not_controlled_by_policy, @@ -1012,7 +1004,7 @@ fun PermissionPolicyScreen( getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var selectedPolicy by remember { mutableIntStateOf(getPolicy()) } + var selectedPolicy by rememberSaveable { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.permission_policy, onNavigateUp, 0.dp) { FullWidthRadioButtonItem(R.string.default_stringres, selectedPolicy == PERMISSION_POLICY_PROMPT) { selectedPolicy = PERMISSION_POLICY_PROMPT @@ -1045,7 +1037,7 @@ fun PermissionPolicyScreen( fun MtePolicyScreen( getPolicy: () -> Int, setPolicy: (Int) -> Boolean, onNavigateUp: () -> Unit ) { - var policy by remember { mutableIntStateOf(getPolicy()) } + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.mte_policy, onNavigateUp, 0.dp) { FullWidthRadioButtonItem(R.string.decide_by_user, policy == MTE_NOT_CONTROLLED_BY_POLICY) { policy = MTE_NOT_CONTROLLED_BY_POLICY @@ -1075,7 +1067,7 @@ fun NearbyStreamingPolicyScreen( setNotificationPolicy: (Int) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var appPolicy by remember { mutableIntStateOf(getAppPolicy()) } + var appPolicy by rememberSaveable { mutableIntStateOf(getAppPolicy()) } MySmallTitleScaffold(R.string.nearby_streaming_policy, onNavigateUp, 0.dp) { Text( stringResource(R.string.nearby_app_streaming), @@ -1104,7 +1096,7 @@ fun NearbyStreamingPolicyScreen( } Notes(R.string.info_nearby_app_streaming_policy, HorizontalPadding) Spacer(Modifier.height(20.dp)) - var notificationPolicy by remember { mutableIntStateOf(getNotificationPolicy()) } + var notificationPolicy by rememberSaveable { mutableIntStateOf(getNotificationPolicy()) } Text( stringResource(R.string.nearby_notification_streaming), Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), style = typography.titleLarge @@ -1153,7 +1145,7 @@ fun LockTaskModeScreen( ) { val coroutine = rememberCoroutineScope() val pagerState = rememberPagerState { 3 } - var tabIndex by remember { mutableIntStateOf(0) } + var tabIndex by rememberSaveable { mutableIntStateOf(0) } tabIndex = pagerState.targetPage LaunchedEffect(Unit) { getLockTaskPackages() @@ -1166,7 +1158,7 @@ fun LockTaskModeScreen( colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -1301,8 +1293,8 @@ private fun LockTaskFeatures( getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String? ) { val context = LocalContext.current - var flags by remember { mutableIntStateOf(getLockTaskFeatures()) } - var errorMessage by remember { mutableStateOf(null) } + var flags by rememberSaveable { mutableIntStateOf(getLockTaskFeatures()) } + var errorMessage by rememberSaveable { mutableStateOf(null) } Column( Modifier .fillMaxWidth() @@ -1366,9 +1358,9 @@ fun CaCertScreen( ) { val context = LocalContext.current /** 0:none, 1:install, 2:info, 3:uninstall all */ - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } val caCerts by caCertificates.collectAsStateWithLifecycle() - var selectedCaCert by remember { mutableStateOf(null) } + var selectedCaCert by rememberSaveable { mutableStateOf(null) } val getCertLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument()) { uri -> if(uri != null) { @@ -1400,7 +1392,8 @@ fun CaCertScreen( }) { Icon(Icons.Default.Add, stringResource(R.string.install)) } - } + }, + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn( Modifier @@ -1534,14 +1527,10 @@ fun SecurityLoggingScreen( exportPRLogs: (Uri, () -> Unit) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var enabled by remember { mutableStateOf(false) } - var logsCount by remember { mutableIntStateOf(0) } - var exporting by remember { mutableStateOf(false) } - var dialog by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - enabled = getEnabled() - logsCount = getCount() - } + var enabled by rememberSaveable { mutableStateOf(getEnabled()) } + var logsCount by rememberSaveable { mutableIntStateOf(getCount()) } + var exporting by rememberSaveable { mutableStateOf(false) } + var dialog by rememberSaveable { mutableStateOf(false) } val exportLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("application/json") ) { @@ -1686,24 +1675,16 @@ data class FrpPolicyInfo( @RequiresApi(30) @Composable fun FrpPolicyScreen( - getFrpPolicy: () -> FrpPolicyInfo, setFrpPolicy: (FrpPolicyInfo) -> Unit, + frpPolicy: FrpPolicyInfo, setFrpPolicy: (FrpPolicyInfo) -> Unit, onNavigateUp: () -> Unit ) { + val context = LocalContext.current val focusMgr = LocalFocusManager.current - var usePolicy by remember { mutableStateOf(false) } - var enabled by remember { mutableStateOf(false) } - var supported by remember { mutableStateOf(false) } - val accountList = remember { mutableStateListOf() } - var inputAccount by remember { mutableStateOf("") } - LaunchedEffect(Unit) { - val info = getFrpPolicy() - supported = info.supported - if (info.supported) { - usePolicy = info.usePolicy - enabled = info.enabled - accountList.addAll(info.accounts) - } - } + var usePolicy by rememberSaveable { mutableStateOf(frpPolicy.usePolicy) } + var enabled by rememberSaveable { mutableStateOf(frpPolicy.enabled) } + var supported by rememberSaveable { mutableStateOf(frpPolicy.supported) } + val accountList = rememberSaveable { mutableStateListOf(*frpPolicy.accounts.toTypedArray()) } + var inputAccount by rememberSaveable { mutableStateOf("") } MyScaffold(R.string.frp_policy, onNavigateUp, 0.dp) { if (!supported) { Column( @@ -1751,6 +1732,7 @@ fun FrpPolicyScreen( onClick = { focusMgr.clearFocus() setFrpPolicy(FrpPolicyInfo(true, usePolicy, enabled, accountList)) + context.showOperationResultToast(true) }, modifier = Modifier .fillMaxWidth() @@ -1774,9 +1756,9 @@ fun WipeDataScreen( val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager val privilege by Privilege.status.collectAsStateWithLifecycle() val focusMgr = LocalFocusManager.current - var flag by remember { mutableIntStateOf(WIPE_SILENTLY) } - var dialog by remember { mutableIntStateOf(0) } // 0: none, 1: wipe data, 2: wipe device - var reason by remember { mutableStateOf("") } + var flag by rememberSaveable { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } // 0: none, 1: wipe data, 2: wipe device + var reason by rememberSaveable { mutableStateOf("") } MyScaffold(R.string.wipe_data, onNavigateUp, 0.dp) { FullWidthCheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { flag = flag xor WIPE_EXTERNAL_STORAGE @@ -1978,8 +1960,8 @@ fun InstallSystemUpdateScreen( installSystemUpdate: (Uri, (String) -> Unit) -> Unit, onNavigateUp: () -> Unit ) { var uri by remember { mutableStateOf(null) } - var installing by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } + var installing by rememberSaveable { mutableStateOf(false) } + var errorMessage by rememberSaveable { mutableStateOf(null) } val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri = it } MyScaffold(R.string.install_system_update, onNavigateUp) { Button( diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index 6662ba9..497e23c 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -7,11 +7,9 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -41,7 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,11 +51,13 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.UserRestrictionCategory import com.bintianqi.owndroid.UserRestrictionsRepository +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.FunctionItem @@ -99,7 +99,7 @@ fun UserRestrictionScreen( scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -183,7 +183,7 @@ fun UserRestrictionOptionsScreen( } } item { - Spacer(Modifier.padding(vertical = 30.dp)) + Spacer(Modifier.height(BottomPadding)) } } } @@ -207,7 +207,7 @@ fun UserRestrictionEditorScreen( navigationIcon = { NavIcon(onNavigateUp) } ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn(Modifier.fillMaxSize().padding(paddingValues)) { items(list, { it }) { @@ -224,7 +224,7 @@ fun UserRestrictionEditorScreen( } } item { - var input by remember { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } fun add() { if (!setRestriction(input, false)) context.showOperationResultToast(false) } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt index 30fd057..5c53b99 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -81,7 +82,7 @@ fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() /** 1: secondary users, 2: logout*/ - var dialog by remember { mutableIntStateOf(0) } + 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 } @@ -194,7 +195,7 @@ data class UserInformation( @Composable fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { var info by remember { mutableStateOf(UserInformation()) } - var infoDialog by remember { mutableIntStateOf(0) } + var infoDialog by rememberSaveable { mutableIntStateOf(0) } LaunchedEffect(Unit) { info = getInfo() } @@ -234,10 +235,10 @@ fun UserOperationScreen( stopUser: (Int, Boolean) -> Int, deleteUser: (Int, Boolean) -> Boolean, onNavigateUp: () -> Unit ) { val context = LocalContext.current - var input by remember { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } val focusMgr = LocalFocusManager.current - var useUserId by remember { mutableStateOf(false) } - var dialog by remember { mutableStateOf(false) } + var useUserId by rememberSaveable { mutableStateOf(false) } + var dialog by rememberSaveable { mutableStateOf(false) } val legalInput = input.toIntOrNull() != null MyScaffold(R.string.user_operation, onNavigateUp) { if(VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { @@ -335,9 +336,9 @@ fun CreateUserScreen( ) { var result by remember { mutableStateOf(null) } val focusMgr = LocalFocusManager.current - var userName by remember { mutableStateOf("") } - var creating by remember { mutableStateOf(false) } - var flags by remember { mutableIntStateOf(0) } + var userName by rememberSaveable { mutableStateOf("") } + var creating by rememberSaveable { mutableStateOf(false) } + var flags by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.create_user, onNavigateUp, 0.dp) { OutlinedTextField( userName, { userName= it }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), @@ -401,7 +402,7 @@ fun AffiliationIdScreen( onNavigateUp: () -> Unit ) { val focusMgr = LocalFocusManager.current - var input by remember { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } val list by affiliationIds.collectAsStateWithLifecycle() LaunchedEffect(Unit) { getIds() } MyScaffold(R.string.affiliation_id, onNavigateUp) { @@ -440,7 +441,7 @@ fun AffiliationIdScreen( fun ChangeUsernameScreen(setName: (String) -> Unit, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var inputUsername by remember { mutableStateOf("") } + var inputUsername by rememberSaveable { mutableStateOf("") } MyScaffold(R.string.change_username, onNavigateUp) { OutlinedTextField( value = inputUsername, @@ -473,8 +474,8 @@ fun UserSessionMessageScreen( ) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var start by remember { mutableStateOf("") } - var end by remember { mutableStateOf("") } + var start by rememberSaveable { mutableStateOf("") } + var end by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { val messages = getMessages() start = messages.first diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt index 642920d..180deed 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MenuAnchorType @@ -55,7 +56,6 @@ import com.bintianqi.owndroid.R import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem import com.bintianqi.owndroid.ui.CircularProgressDialog -import com.bintianqi.owndroid.ui.ExpandExposedTextFieldIcon import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyScaffold @@ -301,7 +301,7 @@ fun CrossProfileIntentFilterScreen( stringResource(direction.text), {}, Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), label = { Text(stringResource(R.string.direction)) }, readOnly = true, - trailingIcon = { ExpandExposedTextFieldIcon(dropdown) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } ) ExposedDropdownMenu(dropdown, { dropdown = false }) { IntentFilterDirection.entries.forEach { diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt index 6b68957..3d52595 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -102,7 +101,7 @@ fun AppInstaller( ) } ) { paddingValues -> - var tab by remember { mutableIntStateOf(0) } + var tab by rememberSaveable { mutableIntStateOf(0) } val pagerState = rememberPagerState { 2 } val scrollState = rememberScrollState() tab = pagerState.targetPage diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt index 8c6507c..d0cb905 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt @@ -2,7 +2,6 @@ package com.bintianqi.owndroid.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,10 +9,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -23,7 +20,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -51,7 +47,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -60,6 +55,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.zhCN @Composable @@ -313,7 +309,7 @@ fun MyScaffold( scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -345,7 +341,7 @@ fun MyLazyScaffold( scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn(Modifier.fillMaxSize().padding(paddingValues), content = content) } @@ -367,7 +363,7 @@ fun MySmallTitleScaffold( colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -382,15 +378,6 @@ fun MySmallTitleScaffold( } } -@Composable -fun ExpandExposedTextFieldIcon(active: Boolean) { - val degrees by animateFloatAsState(if(active) 180F else 0F) - Icon( - imageVector = Icons.Default.ArrowDropDown, contentDescription = null, - modifier = Modifier.rotate(degrees) - ) -} - @Composable fun ErrorDialog(message: String?, onDismiss: () -> Unit) { if(!message.isNullOrEmpty()) AlertDialog( From 5b9ce9f984cd2d2f40c3451d3a2c8e6c2afceaf3 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Thu, 16 Oct 2025 17:56:16 +0800 Subject: [PATCH 15/26] Change the default readme to English, close #184 --- Readme-ja.md | 2 +- Readme-en.md => Readme-zh_CN.md | 82 ++++++++++++++++----------------- Readme.md | 82 +++++++++++++++++---------------- 3 files changed, 83 insertions(+), 83 deletions(-) rename Readme-en.md => Readme-zh_CN.md (57%) diff --git a/Readme-ja.md b/Readme-ja.md index b72641b..4a8c3d5 100644 --- a/Readme-ja.md +++ b/Readme-ja.md @@ -1,4 +1,4 @@ -[English](Readme-en.md) | [简体中文](Readme.md) +[English](Readme.md) | [简体中文](Readme-zh_CN.md) > [!important] > The Japanese readme need update diff --git a/Readme-en.md b/Readme-zh_CN.md similarity index 57% rename from Readme-en.md rename to Readme-zh_CN.md index ee1cd8f..01e7af0 100644 --- a/Readme-en.md +++ b/Readme-zh_CN.md @@ -1,69 +1,69 @@ -[日本語](Readme-ja.md) | [简体中文](Readme.md) +[English](Readme.md) | [日本語](Readme-ja.md) # OwnDroid -Use Android's DevicePolicyManager API to manage your device. +使用安卓的设备策略管理器API管理你的设备。 -## Download +## 下载 - [IzzyOnDroid F-Droid Repository](https://apt.izzysoft.de/fdroid/index/apk/com.bintianqi.owndroid) - [Releases on GitHub](https://github.com/BinTianqi/OwnDroid/releases) > [!NOTE] -> ColorOS users should download testkey version from releases on GitHub +> ColorOS用户应在GitHub上的releases下载testkey版本 -## Features +## 功能 -- System: disable camera, disable screenshot, master volume mute, disable USB signal, lock task mode, wipe data... -- Network: add/modify/delete Wi-Fi, network stats, network logging... -- Applications: suspend/hide app, block app uninstallation, grant/revoke permissions, clear app storage, install/uninstall app... -- User restriction: disable SMS, disable outgoing call, disable bluetooth, disable NFC, disable USB file transfer, disable app installing/uninstalling... -- Users: user information, create/start/switch/stop/delete user... -- Password and keyguard: reset password, set screen timeout... +- 系统:禁用摄像头、禁止截屏、全局静音、禁用USB信号、锁定任务模式、清除数据... +- 网络:添加/修改/删除 Wi-Fi、网络统计、网络日志... +- 应用:挂起/隐藏应用、阻止应用卸载、授予/撤销权限、清除应用存储、安装/卸载应用... +- 用户限制:禁止发送短信、禁止拨出电话、禁用蓝牙、禁用NFC、禁用USB文件传输、禁止安装/卸载应用... +- 用户:用户信息、创建/启动/切换/停止/删除用户... +- 密码与锁屏:重置密码、设置屏幕超时... -## Working modes +## 工作模式 -- Device owner (recommended) +- Device owner(推荐) - Activating methods: + 激活方式: - Shizuku - Dhizuku - Root - - ADB shell command `dpm set-device-owner com.bintianqi.owndroid/.Receiver` + - ADB shell命令 `dpm set-device-owner com.bintianqi.owndroid/.Receiver` - [Dhizuku](https://github.com/iamr0s/Dhizuku) -- Work profile +- 工作资料 ## FAQ -### Already some accounts on the device +### 设备上有账号 ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already some accounts on the device ``` -Solutions: -- Freeze apps who hold those accounts. -- Delete these accounts. +解决办法: +- 冻结持有这些账号的app。 +- 删除这些账号。 -### Already several users on the device +### 设备上有多个用户 ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already several users on the device ``` -Solutions: -- Delete secondary users. +解决办法: +- 删除次级用户。 > [!NOTE] -> Some systems have features such as app cloning and children space, which are usually users. +> 一些系统有应用克隆、儿童空间等功能,它们通常是用户。 -#### Device owner is already set +#### Device owner 已存在 ```text java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.owndroid/.Receiver), but device owner (xxx) is already set. ``` -Only 1 device owner can exist on a device. Please deactivate the existing device owner first. +一个设备只能存在一个device owner,请先停用已存在的device owner。 ### MIUI & HyperOS @@ -71,9 +71,7 @@ Only 1 device owner can exist on a device. Please deactivate the existing device java.lang.SecurityException: Neither user 2000 nor current process has android.permission.MANAGE_DEVICE_ADMINS. ``` -Solutions: -- Enable `USB debugging (Security setting)` in developer options. -- Or execute activating command in root shell. +解决办法: 在开发者设置中打开`USB调试(安全设置)`,或在root命令行中执行激活命令。 ### ColorOS @@ -81,19 +79,19 @@ Solutions: java.lang.IllegalStateException: Unexpected @ProvisioningPreCondition ``` -Solution: Use OwnDroid testkey version +解决办法:使用 OwnDroid testkey 版本 -### Samsung +### 三星 ```text user limit reached ``` -Samsung restricts Android's multiple users feature. There is currently no solution. +三星限制了多用户功能,暂无解决办法。 ## API -OwnDroid provides an Intent-based API. You need to set the API key in settings and enable the API. The numbers in brackets represent the minimum Android version required. +OwnDroid提供了一个基于Intent的API。你需要在设置中设置密钥并启用API。括号中的数字是最小的安卓版本。 - HIDE(package: String) - UNHIDE(package: String) @@ -114,12 +112,12 @@ OwnDroid provides an Intent-based API. You need to set the API key in settings a - REBOOT() (7) ```shell -# An example of hiding app in ADB shell +# 一个在ADB shell中隐藏app的示例 am broadcast -a com.bintianqi.owndroid.action.HIDE -n com.bintianqi.owndroid/.ApiReceiver --es key abcdefg --es package com.example.app ``` ```kotlin -// An example of hiding app in Kotlin +// 一个在Kotlin中隐藏app的示例 val intent = Intent("com.bintianqi.owndroid.action.HIDE") .setComponent(ComponentName("com.bintianqi.owndroid", "com.bintianqi.owndroid.ApiReceiver")) .putExtra("key", "abcdefg") @@ -127,20 +125,20 @@ val intent = Intent("com.bintianqi.owndroid.action.HIDE") context.sendBroadcast(intent) ``` -[Available user restrictions](https://developer.android.com/reference/android/os/UserManager#constants_1) +[可用的用户限制](https://developer.android.google.cn/reference/android/os/UserManager#constants_1) -## Build +## 构建 -You can use Gradle in command line to build OwnDroid. +你可以在命令行中使用Gradle以构建OwnDroid ```shell -# Use testkey for signing (default) +# 使用testkey签名(默认) ./gradlew build -# Use your custom .jks key for signing +# 使用你的jks密钥签名 ./gradlew build -PStoreFile="/path/to/your/jks/file" -PStorePassword="YOUR_KEYSTORE_PASSWORD" -PKeyPassword="YOUR_KEY_PASSWORD" -PKeyAlias="YOUR_KEY_ALIAS" ``` -(Use `./gradlew.bat` instead on Windows) +(在Windows系统中应使用`./gradlew.bat`) -## License +## 许可证 [License.md](LICENSE.md) diff --git a/Readme.md b/Readme.md index 92454c1..2a2b9a0 100644 --- a/Readme.md +++ b/Readme.md @@ -1,69 +1,69 @@ -[English](Readme-en.md) | [日本語](Readme-ja.md) +[简体中文](Readme-zh_CN.md) | [日本語](Readme-ja.md) # OwnDroid -使用安卓的设备策略管理器API管理你的设备。 +Use Android's DevicePolicyManager API to manage your device. -## 下载 +## Download - [IzzyOnDroid F-Droid Repository](https://apt.izzysoft.de/fdroid/index/apk/com.bintianqi.owndroid) - [Releases on GitHub](https://github.com/BinTianqi/OwnDroid/releases) > [!NOTE] -> ColorOS用户应在GitHub上的releases下载testkey版本 +> ColorOS users should download testkey version from releases on GitHub -## 功能 +## Features -- 系统:禁用摄像头、禁止截屏、全局静音、禁用USB信号、锁定任务模式、清除数据... -- 网络:添加/修改/删除 Wi-Fi、网络统计、网络日志... -- 应用:挂起/隐藏应用、阻止应用卸载、授予/撤销权限、清除应用存储、安装/卸载应用... -- 用户限制:禁止发送短信、禁止拨出电话、禁用蓝牙、禁用NFC、禁用USB文件传输、禁止安装/卸载应用... -- 用户:用户信息、创建/启动/切换/停止/删除用户... -- 密码与锁屏:重置密码、设置屏幕超时... +- System: disable camera, disable screenshot, master volume mute, disable USB signal, lock task mode, wipe data... +- Network: add/modify/delete Wi-Fi, network stats, network logging... +- Applications: suspend/hide app, block app uninstallation, grant/revoke permissions, clear app storage, install/uninstall app... +- User restriction: disable SMS, disable outgoing call, disable bluetooth, disable NFC, disable USB file transfer, disable app installing/uninstalling... +- Users: user information, create/start/switch/stop/delete user... +- Password and keyguard: reset password, set screen timeout... -## 工作模式 +## Working modes -- Device owner(推荐) +- Device owner (recommended) - 激活方式: + Activating methods: - Shizuku - Dhizuku - Root - - ADB shell命令 `dpm set-device-owner com.bintianqi.owndroid/.Receiver` + - ADB shell command `dpm set-device-owner com.bintianqi.owndroid/.Receiver` - [Dhizuku](https://github.com/iamr0s/Dhizuku) -- 工作资料 +- Work profile ## FAQ -### 设备上有账号 +### Already some accounts on the device ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already some accounts on the device ``` -解决办法: -- 冻结持有这些账号的app。 -- 删除这些账号。 +Solutions: +- Freeze apps who hold those accounts. +- Delete these accounts. -### 设备上有多个用户 +### Already several users on the device ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already several users on the device ``` -解决办法: -- 删除次级用户。 +Solutions: +- Delete secondary users. > [!NOTE] -> 一些系统有应用克隆、儿童空间等功能,它们通常是用户。 +> Some systems have features such as app cloning and children space, which are usually users. -#### Device owner 已存在 +#### Device owner is already set ```text java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.owndroid/.Receiver), but device owner (xxx) is already set. ``` -一个设备只能存在一个device owner,请先停用已存在的device owner。 +Only 1 device owner can exist on a device. Please deactivate the existing device owner first. ### MIUI & HyperOS @@ -71,7 +71,9 @@ java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.o java.lang.SecurityException: Neither user 2000 nor current process has android.permission.MANAGE_DEVICE_ADMINS. ``` -解决办法: 在开发者设置中打开`USB调试(安全设置)`,或在root命令行中执行激活命令。 +Solutions: +- Enable `USB debugging (Security setting)` in developer options. +- Or execute activating command in root shell. ### ColorOS @@ -79,19 +81,19 @@ java.lang.SecurityException: Neither user 2000 nor current process has android.p java.lang.IllegalStateException: Unexpected @ProvisioningPreCondition ``` -解决办法:使用 OwnDroid testkey 版本 +Solution: Use OwnDroid testkey version -### 三星 +### Samsung ```text user limit reached ``` -三星限制了多用户功能,暂无解决办法。 +Samsung restricts Android's multiple users feature. There is currently no solution. ## API -OwnDroid提供了一个基于Intent的API。你需要在设置中设置密钥并启用API。括号中的数字是最小的安卓版本。 +OwnDroid provides an Intent-based API. You need to set the API key in settings and enable the API. The numbers in brackets represent the minimum Android version required. - HIDE(package: String) - UNHIDE(package: String) @@ -112,12 +114,12 @@ OwnDroid提供了一个基于Intent的API。你需要在设置中设置密钥并 - REBOOT() (7) ```shell -# 一个在ADB shell中隐藏app的示例 +# An example of hiding app in ADB shell am broadcast -a com.bintianqi.owndroid.action.HIDE -n com.bintianqi.owndroid/.ApiReceiver --es key abcdefg --es package com.example.app ``` ```kotlin -// 一个在Kotlin中隐藏app的示例 +// An example of hiding app in Kotlin val intent = Intent("com.bintianqi.owndroid.action.HIDE") .setComponent(ComponentName("com.bintianqi.owndroid", "com.bintianqi.owndroid.ApiReceiver")) .putExtra("key", "abcdefg") @@ -125,20 +127,20 @@ val intent = Intent("com.bintianqi.owndroid.action.HIDE") context.sendBroadcast(intent) ``` -[可用的用户限制](https://developer.android.google.cn/reference/android/os/UserManager#constants_1) +[Available user restrictions](https://developer.android.com/reference/android/os/UserManager#constants_1) -## 构建 +## Build -你可以在命令行中使用Gradle以构建OwnDroid +You can use Gradle in command line to build OwnDroid. ```shell -# 使用testkey签名(默认) +# Use testkey for signing (default) ./gradlew build -# 使用你的jks密钥签名 +# Use your custom .jks key for signing ./gradlew build -PStoreFile="/path/to/your/jks/file" -PStorePassword="YOUR_KEYSTORE_PASSWORD" -PKeyPassword="YOUR_KEY_PASSWORD" -PKeyAlias="YOUR_KEY_ALIAS" ``` -(在Windows系统中应使用`./gradlew.bat`) +(Use `./gradlew.bat` instead on Windows) -## 许可证 +## License [License.md](LICENSE.md) From fde191adc589f57f91d1148eb1f2812e2472e7aa Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Fri, 17 Oct 2025 12:54:47 +0800 Subject: [PATCH 16/26] Refactor network logging --- .../com/bintianqi/owndroid/MainActivity.kt | 5 +- .../java/com/bintianqi/owndroid/MyDbHelper.kt | 9 +- .../com/bintianqi/owndroid/MyRepository.kt | 70 +++++++++++ .../com/bintianqi/owndroid/MyViewModel.kt | 22 ++++ .../bintianqi/owndroid/NotificationUtils.kt | 7 +- .../com/bintianqi/owndroid/PackageChooser.kt | 2 +- .../java/com/bintianqi/owndroid/Receiver.kt | 11 +- .../bintianqi/owndroid/dpm/Applications.kt | 8 +- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 64 +++++----- .../com/bintianqi/owndroid/dpm/Network.kt | 114 ++++++++++++------ .../com/bintianqi/owndroid/dpm/Permissions.kt | 3 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 7 +- .../bintianqi/owndroid/dpm/UserRestriction.kt | 2 +- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 17 files changed, 232 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 8e0fbcc..1b7bd02 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -440,7 +440,10 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { RecommendedGlobalProxyScreen(vm::setRecommendedGlobalProxy, ::navigateUp) } - composable { NetworkLoggingScreen(::navigateUp) } + composable { + NetworkLoggingScreen(vm::getNetworkLoggingEnabled, vm::setNetworkLoggingEnabled, + vm::getNetworkLogsCount, vm::exportNetworkLogs, vm::deleteNetworkLogs, ::navigateUp) + } //composable { WifiAuthKeypairScreen(::navigateUp) } composable { PreferentialNetworkServiceScreen(vm::getPnsEnabled, vm::setPnsEnabled, vm.pnsConfigs, diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt index f49867f..4e5544b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -4,7 +4,7 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 2) { +class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 3) { override fun onCreate(db: SQLiteDatabase) { db.execSQL("CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + "signature TEXT, permissions TEXT)") @@ -14,5 +14,12 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 2) { db.execSQL("CREATE TABLE security_logs (id INTEGER, tag INTEGER, level INTEGER," + "time INTEGER, data TEXT)") } + if (oldVersion < 3) { + db.execSQL( + "CREATE TABLE network_logs (id INTEGER, package INTEGER, time INTEGER," + + "type TEXT, host TEXT, count INTEGER, addresses TEXT, address TEXT," + + "port INTEGER)" + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt index 4dc4b57..8e6af20 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt @@ -6,7 +6,10 @@ import android.database.DatabaseUtils import android.database.sqlite.SQLiteDatabase import android.os.Build.VERSION import androidx.annotation.RequiresApi +import androidx.core.database.getIntOrNull +import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull +import com.bintianqi.owndroid.dpm.NetworkLog import com.bintianqi.owndroid.dpm.SecurityEvent import com.bintianqi.owndroid.dpm.SecurityEventWithData import com.bintianqi.owndroid.dpm.transformSecurityEventData @@ -154,4 +157,71 @@ class MyRepository(val dbHelper: MyDbHelper) { fun deleteSecurityLogs() { dbHelper.writableDatabase.execSQL("DELETE FROM security_logs") } + + fun getNetworkLogsCount(): Long { + return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "network_logs") + } + fun writeNetworkLogs(logs: List) { + val db = dbHelper.writableDatabase + val statement = db.compileStatement( + "INSERT INTO network_logs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + db.beginTransaction() + logs.forEach { event -> + if (event.id == null) statement.bindNull(1) + else statement.bindLong(1, event.id) + statement.bindString(2, event.packageName) + statement.bindLong(3, event.time) + statement.bindString(4, event.type) + if (event.host == null) statement.bindNull(5) + else statement.bindString(5, event.host) + if (event.count == null) statement.bindNull(6) + else statement.bindLong(6, event.count.toLong()) + if (event.addresses == null) statement.bindNull(7) + else statement.bindString(7, event.addresses.joinToString(",")) + if (event.address == null) statement.bindNull(8) + else statement.bindString(8, event.address) + if (event.port == null) statement.bindNull(9) + else statement.bindLong(9, event.port.toLong()) + statement.executeInsert() + statement.clearBindings() + } + db.setTransactionSuccessful() + db.endTransaction() + statement.close() + } + fun exportNetworkLogs(stream: OutputStream) { + val bw = stream.bufferedWriter() + val json = Json { + explicitNulls = false + } + var offset = 0 + var addComma = false + bw.write("[") + while (true) { + val cursor = dbHelper.readableDatabase.rawQuery( + "SELECT * FROM network_logs LIMIT ? OFFSET ?", + arrayOf(100.toString(), offset.toString()) + ) + if (cursor.count == 0) break + while (cursor.moveToNext()) { + if (addComma) bw.write(",") + addComma = true + val log = NetworkLog( + cursor.getLongOrNull(0), cursor.getString(1), cursor.getLong(2), + cursor.getString(3), cursor.getStringOrNull(4), cursor.getIntOrNull(5), + cursor.getStringOrNull(6)?.split(',')?.filter { it.isNotEmpty() }, + cursor.getStringOrNull(7), cursor.getIntOrNull(8) + ) + bw.write(json.encodeToString(log)) + offset += 100 + } + cursor.close() + } + bw.write("]") + bw.close() + } + fun deleteNetworkLogs() { + dbHelper.writableDatabase.execSQL("DELETE FROM network_logs") + } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 5083798..4be2f61 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1753,6 +1753,28 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun removeApnConfig(id: Int): Boolean { return DPM.removeOverrideApn(DAR, id) } + @RequiresApi(26) + fun getNetworkLoggingEnabled(): Boolean { + return DPM.isNetworkLoggingEnabled(DAR) + } + @RequiresApi(26) + fun setNetworkLoggingEnabled(enabled: Boolean) { + DPM.setNetworkLoggingEnabled(DAR, enabled) + } + fun getNetworkLogsCount(): Int { + return myRepo.getNetworkLogsCount().toInt() + } + fun exportNetworkLogs(uri: Uri, callback: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + application.contentResolver.openOutputStream(uri)?.use { + myRepo.exportNetworkLogs(it) + } + withContext(Dispatchers.Main) { callback() } + } + } + fun deleteNetworkLogs() { + myRepo.deleteNetworkLogs() + } @RequiresApi(29) fun getPasswordComplexity(): PasswordComplexity { diff --git a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt index 2b664c8..2c326f8 100644 --- a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt @@ -72,10 +72,15 @@ enum class NotificationType( 12, R.string.security_logs_collected, R.drawable.description_fill0, MyNotificationChannel.SecurityLogging ), + NetworkLogsCollected( + 13, R.string.network_logs_collected, R.drawable.description_fill0, + MyNotificationChannel.NetworkLogging + ), } enum class MyNotificationChannel(val id: String, val text: Int, val importance: Int) { LockTaskMode("LockTaskMode", R.string.lock_task_mode, NotificationManagerCompat.IMPORTANCE_HIGH), Events("Events", R.string.events, NotificationManagerCompat.IMPORTANCE_LOW), - SecurityLogging("SecurityLogging", R.string.security_logging, NotificationManagerCompat.IMPORTANCE_MIN) + SecurityLogging("SecurityLogging", R.string.security_logging, NotificationManagerCompat.IMPORTANCE_MIN), + NetworkLogging("NetworkLogging", R.string.network_logging, NotificationManagerCompat.IMPORTANCE_MIN) } diff --git a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt index a47eba3..ec7b1f7 100644 --- a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt +++ b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt @@ -173,7 +173,7 @@ fun AppChooserScreen( } } } - item { Spacer(Modifier.height(60.dp)) } + item { Spacer(Modifier.height(BottomPadding)) } } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index 117f185..652f894 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -11,12 +11,9 @@ import android.os.Build.VERSION import android.os.UserHandle import android.os.UserManager import androidx.core.app.NotificationCompat -import com.bintianqi.owndroid.dpm.handleNetworkLogs import com.bintianqi.owndroid.dpm.handlePrivilegeChange +import com.bintianqi.owndroid.dpm.retrieveNetworkLogs import com.bintianqi.owndroid.dpm.retrieveSecurityLogs -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch class Receiver : DeviceAdminReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -47,10 +44,8 @@ class Receiver : DeviceAdminReceiver() { override fun onNetworkLogsAvailable(context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int) { super.onNetworkLogsAvailable(context, intent, batchToken, networkLogsCount) - if(VERSION.SDK_INT >= 26) { - CoroutineScope(Dispatchers.IO).launch { - handleNetworkLogs(context, batchToken) - } + if (VERSION.SDK_INT >= 26) { + retrieveNetworkLogs(context.applicationContext as MyApplication, batchToken) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index cb8f9a1..dfb1691 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -273,7 +273,7 @@ fun ApplicationDetailsScreen( ) if(VERSION.SDK_INT >= 28) FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { dialog = 1 } FunctionItem(R.string.uninstall, icon = R.drawable.delete_fill0) { dialog = 2 } - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } if(dialog == 1 && VERSION.SDK_INT >= 28) ClearAppStorageDialog(packageName, vm::clearAppData) { dialog = 0 } @@ -606,7 +606,7 @@ fun CredentialManagerPolicyScreen( ) { Text(stringResource(R.string.apply)) } - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } } } @@ -661,7 +661,7 @@ fun PermittedAsAndImPackages( } Spacer(Modifier.height(10.dp)) Notes(note, HorizontalPadding) - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } } } @@ -771,7 +771,7 @@ fun PackageFunctionScreen( Text(stringResource(R.string.add)) } if (notes != null) Notes(notes, HorizontalPadding) - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index 89c9ef3..fb64d9b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -29,12 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray @SuppressLint("PrivateApi") fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager? { @@ -137,39 +132,37 @@ val runtimePermissions = listOf( PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true, 29) ).filter { VERSION.SDK_INT >= it.requiresApi } +@Serializable +class NetworkLog( + val id: Long?, @SerialName("package") val packageName: String, val time: Long, val type: String, + val host: String?, val count: Int?, val addresses: List?, + val address: String?, val port: Int? +) + @RequiresApi(26) -fun handleNetworkLogs(context: Context, batchToken: Long) { - val networkEvents = Privilege.DPM.retrieveNetworkLogs(Privilege.DAR, batchToken) ?: return - val file = context.filesDir.resolve("NetworkLogs.json") - val fileExist = file.exists() - val json = Json { ignoreUnknownKeys = true; explicitNulls = false } - val buffer = file.bufferedWriter() - networkEvents.forEachIndexed { index, event -> - if(fileExist && index == 0) buffer.write(",") - val item = buildJsonObject { - if(VERSION.SDK_INT >= 28) put("id", event.id) - put("time", event.timestamp) - put("package", event.packageName) - if(event is DnsEvent) { - put("type", "dns") - put("host", event.hostname) - put("count", event.totalResolvedAddressCount) - putJsonArray("addresses") { - event.inetAddresses.forEach { inetAddresses -> - add(inetAddresses.hostAddress) - } - } - } - if(event is ConnectEvent) { - put("type", "connect") - put("address", event.inetAddress.hostAddress) - put("port", event.port) +fun retrieveNetworkLogs(app: MyApplication, token: Long) { + CoroutineScope(Dispatchers.IO).launch { + val logs = Privilege.DPM.retrieveNetworkLogs(Privilege.DAR, token)?.mapNotNull { + when (it) { + is DnsEvent -> NetworkLog( + if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, "dns", + it.hostname, it.totalResolvedAddressCount, + it.inetAddresses.mapNotNull { address -> address.hostAddress }, null, null + ) + is ConnectEvent -> NetworkLog( + if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, + "connect", null, null, null, it.inetAddress.hostAddress, it.port + ) + else -> null } } - buffer.write(json.encodeToString(item)) - if(index < networkEvents.size - 1) buffer.write(",") + if (logs.isNullOrEmpty()) return@launch + app.myRepo.writeNetworkLogs(logs) + NotificationUtils.sendBasicNotification( + app, NotificationType.NetworkLogsCollected, + app.getString(R.string.n_logs_in_total, logs.size) + ) } - buffer.close() } @Serializable @@ -493,7 +486,8 @@ fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? { @RequiresApi(24) fun retrieveSecurityLogs(app: MyApplication) { CoroutineScope(Dispatchers.IO).launch { - val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR) ?: return@launch + val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR) + if (logs.isNullOrEmpty()) return@launch app.myRepo.writeSecurityLogs(logs) NotificationUtils.sendBasicNotification( app, NotificationType.SecurityLogsCollected, diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 85368d9..5e1abd2 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -10,6 +10,7 @@ import android.app.admin.DevicePolicyManager.WIFI_SECURITY_PERSONAL import android.app.admin.WifiSsidPolicy import android.app.usage.NetworkStats import android.net.ConnectivityManager +import android.net.Uri import android.net.wifi.WifiConfiguration import android.os.Build.VERSION import android.provider.Telephony @@ -106,6 +107,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege @@ -116,6 +118,7 @@ import com.bintianqi.owndroid.formatFileSize import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast +import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.ErrorDialog import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem @@ -135,6 +138,9 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Serializable object Network @@ -803,7 +809,7 @@ private fun AddNetworkScreen( ) { Text(stringResource(if (updating) R.string.update else R.string.add)) } - Spacer(Modifier.height(60.dp)) + Spacer(Modifier.height(BottomPadding)) } } @@ -1539,50 +1545,82 @@ fun RecommendedGlobalProxyScreen( @RequiresApi(26) @Composable -fun NetworkLoggingScreen(onNavigateUp: () -> Unit) { +fun NetworkLoggingScreen( + getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, getCount: () -> Int, + exportLogs: (Uri, () -> Unit) -> Unit, deleteLogs: () -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val logFile = context.filesDir.resolve("NetworkLogs.json") - var fileSize by remember { mutableLongStateOf(0) } - LaunchedEffect(Unit) { fileSize = logFile.length() } - val exportNetworkLogsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - if(uri != null) context.contentResolver.openOutputStream(uri)?.use { outStream -> - outStream.write("[".encodeToByteArray()) - logFile.inputStream().use { it.copyTo(outStream) } - outStream.write("]".encodeToByteArray()) - context.showOperationResultToast(true) + var enabled by remember { mutableStateOf(false) } + var count by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableStateOf(false) } + var exporting by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + enabled = getEnabled() + count = getCount() + } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + if (uri != null) { + exporting = true + exportLogs(uri) { + exporting = false + context.showOperationResultToast(true) + } } } - MyScaffold(R.string.network_logging, onNavigateUp) { + MyScaffold(R.string.network_logging, onNavigateUp, 0.dp) { SwitchItem( - R.string.enable, - getState = { Privilege.DPM.isNetworkLoggingEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setNetworkLoggingEnabled(Privilege.DAR, it) }, - padding = false + R.string.enable, enabled, { + setEnabled(it) + enabled = it + } ) - Text(stringResource(R.string.log_file_size_is, formatFileSize(fileSize))) - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - exportNetworkLogsLauncher.launch("NetworkLogs.json") - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.export_logs)) - } - Button( - onClick = { - logFile.delete() - fileSize = logFile.length() - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.delete_logs)) - } + Text( + stringResource(R.string.n_logs_in_total, count), + Modifier.padding(HorizontalPadding, 5.dp) + ) + Button( + { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportLauncher.launch("network_logs_$date") + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + count > 0 + ) { + Text(stringResource(R.string.export_logs)) } - Notes(R.string.info_network_log) + if (count > 0) Button( + { + dialog = true + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + ) { + Text(stringResource(R.string.delete_logs)) + } + Spacer(Modifier.height(10.dp)) + Notes(R.string.info_network_log, HorizontalPadding) } + if (exporting) CircularProgressDialog { exporting = false } + if (dialog) AlertDialog( + text = { + Text(stringResource(R.string.delete_logs)) + }, + confirmButton = { + TextButton({ + deleteLogs() + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) } @Serializable object PreferentialNetworkService diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt index 55005d1..059071d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt @@ -81,6 +81,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.AppInfo +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.DhizukuClientInfo import com.bintianqi.owndroid.DhizukuPermissions import com.bintianqi.owndroid.HorizontalPadding @@ -615,7 +616,7 @@ fun AddDelegatedAdminScreen( ) { Text(stringResource(R.string.delete)) } - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index db4dcac..12407c9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -101,6 +101,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.AppInfo +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege @@ -1281,7 +1282,7 @@ private fun LockTaskPackages( Text(stringResource(R.string.add)) } Notes(R.string.info_lock_task_packages) - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } } } @@ -1331,7 +1332,7 @@ private fun LockTaskFeatures( ) { Text(stringResource(R.string.apply)) } - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) ErrorDialog(errorMessage) { errorMessage = null } } } @@ -1417,7 +1418,7 @@ fun CaCertScreen( HorizontalDivider() } item { - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } } if (selectedCaCert != null && (dialog == 1 || dialog == 2)) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index 497e23c..8fe03b4 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -239,7 +239,7 @@ fun UserRestrictionEditorScreen( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), keyboardActions = KeyboardActions { add() } ) - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(BottomPadding)) } } } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5012319..236ebe7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -277,7 +277,6 @@ Неверная конфигурация Исключить хосты Сетевой журнал - Размер файла журнала: %1$s Удалить журналы Экспортировать журналы Пара ключей Wi-Fi diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d593b89..664810e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -306,7 +306,6 @@ Geçersiz yapılandırma Hariç tutulan ana bilgisayarlar Ağ Kayıtları - Kayıt dosyası boyutu: %1$s Kayıtları Sil Kayıtları Dışa Aktar Wi-Fi Kimlik Doğrulama Anahtar Çifti diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 81262df..a33c9b5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -291,7 +291,7 @@ 无效配置 排除的主机 网络日志 - 日志文件大小:%1$s + 网络日志已收集 删除日志 导出日志 Wi-Fi密钥对 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55e9949..19b6ea4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,7 +325,7 @@ Invalid config Excluded hosts Network logging - Log file size: %1$s + Network logs collected Delete logs Export logs Wi-Fi keypair From a57b3b3a8e8c0179f6e12286198a0f8ee735073b Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Mon, 20 Oct 2025 17:35:58 +0800 Subject: [PATCH 17/26] User operation shortcuts Update dependencies --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 +- .../com/bintianqi/owndroid/MainActivity.kt | 3 +- .../com/bintianqi/owndroid/MyViewModel.kt | 52 +++--- .../java/com/bintianqi/owndroid/Receiver.kt | 4 + .../com/bintianqi/owndroid/ShortcutUtils.kt | 43 ++++- .../owndroid/ShortcutsReceiverActivity.kt | 13 +- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 36 +++- .../java/com/bintianqi/owndroid/dpm/Users.kt | 157 +++++++++++------- app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 15 +- 12 files changed, 232 insertions(+), 100 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f405d2d..88b5533 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) + implementation(libs.material.icons.core) implementation(libs.shizuku.provider) implementation(libs.shizuku.api) implementation(libs.dhizuku.api) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c316c33..506c110 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ - + Unit) { UsersOptionsScreen(vm::getLogoutEnabled, vm::setLogoutEnabled, ::navigateUp) } composable { - UserOperationScreen(vm::startUser, vm::switchUser, vm::stopUser, vm::deleteUser, ::navigateUp) + UserOperationScreen(vm::getUserIdentifiers, vm::doUserOperation, + vm::createUserOperationShortcut, ::navigateUp) } composable { CreateUserScreen(vm::createUser, ::navigateUp) } composable { ChangeUsernameScreen(vm::setProfileName, ::navigateUp) } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 4be2f61..20333e4 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -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 { + 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 { - return DPM.getSecondaryUsers(DAR).map { UM.getSerialNumberForUser(it) } - } - @RequiresApi(28) fun getUserSessionMessages(): Pair { return (DPM.getStartUserSessionMessage(DAR)?.toString() ?: "") to (DPM.getEndUserSessionMessage(DAR)?.toString() ?: "") diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index 652f894..408c569 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -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) { diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt index efff2dd..0d4c2fa 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt @@ -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) diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt index 7c1c7cd..5d0c1f4 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt @@ -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() } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index fb64d9b..b0932b6 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt index 5c53b99..2177563 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt @@ -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, 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() } + @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)) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a33c9b5..ad9d6cc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -469,6 +469,9 @@ 次要用户 无次要用户 用户操作 + 启动用户 %1$d + 切换到用户 %1$d + 停止用户 %1$d 用户不存在 序列号 登出 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19b6ea4..452520e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -505,6 +505,9 @@ Secondary users No secondary users User operation + Start user %1$d + Switch to user %1$d + Stop user %1$d User does not exist Serial number Logout diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 267a1b5..1b8a2f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -agp = "8.12.2" -kotlin = "2.2.10" +agp = "8.13.0" +kotlin = "2.2.20" -navigation-compose = "2.9.3" -composeBom = "2025.08.01" +navigation-compose = "2.9.5" +composeBom = "2025.10.00" accompanist-drawablepainter = "0.37.3" accompanist-permissions = "0.37.3" shizuku = "13.1.5" fragment = "1.8.9" -dhizuku = "2.5.3" -dhizuku-server = "0.0.5" +dhizuku = "2.5.4" +dhizuku-server = "0.0.6" hiddenApiBypass = "6.1" libsu = "6.0.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-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } 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-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" } kotlin-android = { id = "org.jetbrains.kotlin.android", 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" } From 6b6884911dfbb40ed64ae1e2841168eb8757b7e9 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Wed, 22 Oct 2025 23:37:43 +0800 Subject: [PATCH 18/26] Fix Dhizuku initialization error (#186) --- .../java/com/bintianqi/owndroid/Privilege.kt | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt index 190f05a..c68fc80 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt @@ -13,21 +13,19 @@ import kotlinx.coroutines.flow.MutableStateFlow object Privilege { fun initialize(context: Context) { if (SP.dhizuku) { - Dhizuku.init(context) - val hasPermission = try { - Dhizuku.isPermissionGranted() + if (Dhizuku.init(context)) try { + if (Dhizuku.isPermissionGranted()) { + val dhizukuDpm = binderWrapperDevicePolicyManager(context) + if (dhizukuDpm != null) { + DPM = dhizukuDpm + DAR = Dhizuku.getOwnerComponent() + updateStatus() + return + } + } } catch(_: Exception) { false } - if (hasPermission) { - val dhizukuDpm = binderWrapperDevicePolicyManager(context) - if (dhizukuDpm != null) { - DPM = dhizukuDpm - DAR = Dhizuku.getOwnerComponent() - updateStatus() - return - } - } dhizukuErrorStatus.value = 2 } DPM = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager From fccac1111836bbf3e0cc4c75b690f58f41d7c38a Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Fri, 24 Oct 2025 13:21:59 +0800 Subject: [PATCH 19/26] Optimize navigation transition (#45, #167, #178) --- app/build.gradle.kts | 4 ++ .../com/bintianqi/owndroid/MainActivity.kt | 16 +++--- .../com/bintianqi/owndroid/ui/Animations.kt | 52 ------------------- .../bintianqi/owndroid/ui/NavTransition.kt | 45 ++++++++++++++++ 4 files changed, 59 insertions(+), 58 deletions(-) delete mode 100644 app/src/main/java/com/bintianqi/owndroid/ui/Animations.kt create mode 100644 app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 88b5533..84c2009 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,6 +44,10 @@ android { debug { signingConfig = signingConfigs.getByName("defaultSignature") } + create("fastDebug") { + initWith(getByName("debug")) + isDebuggable = false + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index cffa309..c090955 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -228,7 +228,7 @@ import com.bintianqi.owndroid.dpm.WorkModesScreen import com.bintianqi.owndroid.dpm.WorkProfile import com.bintianqi.owndroid.dpm.WorkProfileScreen import com.bintianqi.owndroid.dpm.dhizukuErrorStatus -import com.bintianqi.owndroid.ui.Animations +import com.bintianqi.owndroid.ui.NavTransition import com.bintianqi.owndroid.ui.theme.OwnDroidTheme import kotlinx.serialization.Serializable import java.util.Locale @@ -275,7 +275,11 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { val focusMgr = LocalFocusManager.current val lifecycleOwner = LocalLifecycleOwner.current fun navigateUp() { navController.navigateUp() } - fun navigate(destination: Any) { navController.navigate(destination) } + fun navigate(destination: Any) { + navController.navigate(destination) { + launchSingleTop = true + } + } fun choosePackage() { navController.navigate(ApplicationsList(false)) } @@ -293,10 +297,10 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { .fillMaxSize() .background(colorScheme.background) .pointerInput(Unit) { detectTapGestures(onTap = { focusMgr.clearFocus() }) }, - enterTransition = Animations.navHostEnterTransition, - exitTransition = Animations.navHostExitTransition, - popEnterTransition = Animations.navHostPopEnterTransition, - popExitTransition = Animations.navHostPopExitTransition + enterTransition = { NavTransition.enterTransition }, + exitTransition = { NavTransition.exitTransition }, + popEnterTransition = { NavTransition.popEnterTransition }, + popExitTransition = { NavTransition.popExitTransition } ) { composable { HomeScreen(::navigate) } composable { diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/Animations.kt b/app/src/main/java/com/bintianqi/owndroid/ui/Animations.kt deleted file mode 100644 index 90c364a..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/ui/Animations.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.bintianqi.owndroid.ui - -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.ui.unit.IntOffset -import androidx.navigation.NavBackStackEntry - -object Animations { - private const val INITIAL_OFFSET_VALUE = 96 - private const val TARGET_OFFSET_VALUE = 96 - - private val bezier = CubicBezierEasing(0.20f, 0.85f, 0.0f, 1f) - - private val tween: FiniteAnimationSpec = tween(durationMillis = 550, easing = bezier, delayMillis = 50) - - val navHostEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { - fadeIn(tween(100, easing = LinearEasing)) + - slideIntoContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.End, - initialOffset = { INITIAL_OFFSET_VALUE } - ) - } - - val navHostExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { - fadeOut(tween(100, easing = LinearEasing)) + - slideOutOfContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.Start, - targetOffset = { -TARGET_OFFSET_VALUE } - ) - } - - val navHostPopEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { - fadeIn(tween(100, easing = LinearEasing)) + - slideIntoContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.End, - initialOffset = { -INITIAL_OFFSET_VALUE } - ) - } - - val navHostPopExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { - fadeOut(tween(100, easing = LinearEasing)) + - slideOutOfContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.Start, - targetOffset = { TARGET_OFFSET_VALUE } - ) - } - -} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt b/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt new file mode 100644 index 0000000..d9dd4df --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt @@ -0,0 +1,45 @@ +package com.bintianqi.owndroid.ui + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally + +/** + * Learned from AOSP's Activity animation + * `frameworks/base/core/res/res/anim/activity_xxx_xxx.xml` + */ +object NavTransition { + val StandardAccelerateEasing = CubicBezierEasing(0.3F, 0F, 1F, 1F) + + val enterTransition: EnterTransition = slideInHorizontally( + tween(450, easing = FastOutSlowInEasing), + { 96 } + ) + fadeIn( + tween(83, 50, LinearEasing) + ) + + val exitTransition: ExitTransition = slideOutHorizontally( + tween(450, easing = StandardAccelerateEasing), + { -96 } + ) + fadeOut(tween(100, 200, LinearEasing)) + + val popEnterTransition: EnterTransition = slideInHorizontally( + tween(450, easing = FastOutSlowInEasing), + { -96 } + ) + + val popExitTransition: ExitTransition = + slideOutHorizontally( + tween(450, easing = FastOutSlowInEasing), + { 96 } + ) + fadeOut( + tween(83, 35, LinearEasing) + ) +} From 13a69d05e891de3f40cca082228e989022bd8947 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sat, 25 Oct 2025 13:54:30 +0800 Subject: [PATCH 20/26] Add getOrganizationName workaround (#174) Disable user operation shortcuts when user removed Fix crash when getting password complexity (#178) Update dependencies --- .../java/com/bintianqi/owndroid/MainActivity.kt | 1 + .../main/java/com/bintianqi/owndroid/MyViewModel.kt | 12 +++++++++++- .../main/java/com/bintianqi/owndroid/Receiver.kt | 2 +- .../java/com/bintianqi/owndroid/ShortcutUtils.kt | 13 ++++++------- .../java/com/bintianqi/owndroid/dpm/Password.kt | 2 +- gradle/libs.versions.toml | 6 +++--- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index c090955..30d6063 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 20333e4..a838e96 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,6 +1,7 @@ package com.bintianqi.owndroid import android.accounts.Account +import android.annotation.SuppressLint import android.app.ActivityOptions import android.app.Application import android.app.KeyguardManager @@ -521,12 +522,21 @@ class MyViewModel(application: Application): AndroidViewModel(application) { false } } + @SuppressLint("PrivateApi") @RequiresApi(24) fun getOrgName(): String { return try { DPM.getOrganizationName(DAR)?.toString() ?: "" } catch (_: Exception) { - "" + try { + val method = DevicePolicyManager::class.java.getDeclaredMethod( + "getDeviceOwnerOrganizationName" + ) + method.isAccessible = true + (method.invoke(DPM) as CharSequence).toString() + } catch (_: Exception) { + "" + } } } @RequiresApi(24) diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index 408c569..b542132 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -104,7 +104,7 @@ class Receiver : DeviceAdminReceiver() { super.onUserRemoved(context, intent, removedUser) sendUserRelatedNotification(context, removedUser, NotificationType.UserRemoved) val um = context.getSystemService(Context.USER_SERVICE) as UserManager - ShortcutUtils.deleteUserOperationShortcut( + ShortcutUtils.disableUserOperationShortcut( context, um.getSerialNumberForUser(removedUser).toInt() ) } diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt index 0d4c2fa..21e4e00 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt @@ -111,14 +111,13 @@ object ShortcutUtils { 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 + fun disableUserOperationShortcut(context: Context, serial: Int) { + val shortcuts = UserOperationType.entries.map { + "USER_OPERATION-${it.name}-$serial" + } + ShortcutManagerCompat.disableShortcuts( + context, shortcuts, context.getString(R.string.user_removed) ) - 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()) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt index 275362c..6173111 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -213,7 +213,7 @@ fun PasswordInfoScreen( val privilege by Privilege.status.collectAsStateWithLifecycle() var dialog by rememberSaveable { mutableIntStateOf(0) } // 0:none, 1:password complexity MyScaffold(R.string.password_info, onNavigateUp, 0.dp) { - if (VERSION.SDK_INT >= 29) { + if (VERSION.SDK_INT >= 31) { InfoItem(R.string.current_password_complexity, getComplexity().text, true) { dialog = 1 } } InfoItem(R.string.password_sufficient, isSufficient().yesOrNo) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b8a2f1..40d89a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] agp = "8.13.0" -kotlin = "2.2.20" +kotlin = "2.2.21" navigation-compose = "2.9.5" -composeBom = "2025.10.00" +composeBom = "2025.10.01" accompanist-drawablepainter = "0.37.3" accompanist-permissions = "0.37.3" shizuku = "13.1.5" @@ -40,4 +40,4 @@ serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.20" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.21" } From de43df7f59dd99da370cdc049d89b8d81fd913af Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sun, 26 Oct 2025 14:57:12 +0800 Subject: [PATCH 21/26] Fix UI issues (#185, #188) --- .../com/bintianqi/owndroid/MainActivity.kt | 2 +- .../com/bintianqi/owndroid/MyViewModel.kt | 23 +++++---- .../com/bintianqi/owndroid/dpm/Password.kt | 2 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 50 +++++++++++-------- 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 30d6063..31742c1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -384,7 +384,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { vm:: getLockTaskFeatures, vm::setLockTaskFeatures, ::navigateUp) } composable { - CaCertScreen(vm.installedCaCerts, vm::getCaCerts, vm::installCaCert, vm::parseCaCert, + CaCertScreen(vm.installedCaCerts, vm::getCaCerts, vm.selectedCaCert, vm::selectCaCert, vm::installCaCert, vm::parseCaCert, vm::exportCaCert, vm::uninstallCaCert, vm::uninstallAllCaCerts, ::navigateUp) } composable { diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index a838e96..86a8051 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -797,17 +797,20 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } } val installedCaCerts = MutableStateFlow(emptyList()) + val selectedCaCert = MutableStateFlow(null) fun getCaCerts() { installedCaCerts.value = DPM.getInstalledCaCerts(DAR).mapNotNull { parseCaCert(it) } } - fun parseCaCert(uri: Uri): CaCertInfo? { - return try { + fun selectCaCert(cert: CaCertInfo) { + selectedCaCert.value = cert + } + fun parseCaCert(uri: Uri) { + try { application.contentResolver.openInputStream(uri)?.use { - parseCaCert(it.readBytes()) + selectedCaCert.value = parseCaCert(it.readBytes()) } } catch(e: Exception) { e.printStackTrace() - null } } fun parseCaCert(bytes: ByteArray): CaCertInfo? { @@ -825,22 +828,22 @@ class MyViewModel(application: Application): AndroidViewModel(application) { null } } - fun installCaCert(cert: CaCertInfo): Boolean { - val result = DPM.installCaCert(DAR, cert.bytes) + fun installCaCert(): Boolean { + val result = DPM.installCaCert(DAR, selectedCaCert.value!!.bytes) if (result) getCaCerts() return result } - fun uninstallCaCert(cert: CaCertInfo) { - DPM.uninstallCaCert(DAR, cert.bytes) + fun uninstallCaCert() { + DPM.uninstallCaCert(DAR, selectedCaCert.value!!.bytes) getCaCerts() } fun uninstallAllCaCerts() { DPM.uninstallAllUserCaCerts(DAR) getCaCerts() } - fun exportCaCert(uri: Uri, cert: CaCertInfo) { + fun exportCaCert(uri: Uri) { application.contentResolver.openOutputStream(uri)?.use { - it.write(cert.bytes) + it.write(selectedCaCert.value!!.bytes) } } val mdAccountTypes = MutableStateFlow(emptyList()) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt index 6173111..fea9381 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -244,7 +244,7 @@ fun ResetPasswordTokenScreen( ) { val context = LocalContext.current var token by rememberSaveable { mutableStateOf("") } - var state by rememberSaveable { mutableStateOf(getState()) } + var state by remember { mutableStateOf(getState()) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { context.popToast(R.string.token_activated) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 12407c9..777e865 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -68,6 +68,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Tab @@ -75,6 +76,7 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDatePickerState @@ -127,6 +129,7 @@ import com.bintianqi.owndroid.ui.SwitchItem import com.bintianqi.owndroid.yesOrNo import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -562,7 +565,7 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un .fillMaxSize() .padding(paddingValues) ) { - TabRow(tab) { + PrimaryTabRow(tab) { Tab( tab == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, text = { Text(stringResource(R.string.selector)) } @@ -579,6 +582,7 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un Column( Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(top = 8.dp) .padding(horizontal = HorizontalPadding) ) { @@ -634,6 +638,7 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un Text(stringResource(R.string.apply)) } } + Spacer(Modifier.height(BottomPadding)) } } } @@ -646,17 +651,21 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un }, onDismissRequest = { picker = 0; focusMgr.clearFocus() } ) { - DatePicker(datePickerState) + Column(Modifier.verticalScroll(rememberScrollState())) { + DatePicker(datePickerState) + } } - if(picker == 2) AlertDialog( - text = { TimePicker(timePickerState) }, + if (picker == 2) TimePickerDialog( + title = {}, confirmButton = { - TextButton(onClick = { picker = 0; focusMgr.clearFocus() } ) { + TextButton({ picker = 0 }) { Text(stringResource(R.string.confirm)) } }, - onDismissRequest = { picker = 0; focusMgr.clearFocus() } - ) + onDismissRequest = { picker = 0 } + ) { + TimePicker(timePickerState) + } } @Serializable object ChangeTimeZone @@ -1353,25 +1362,26 @@ data class CaCertInfo( @Composable fun CaCertScreen( caCertificates: StateFlow>, getCerts: () -> Unit, - installCert: (CaCertInfo) -> Boolean, parseCert: (Uri) -> CaCertInfo?, - exportCert: (Uri, CaCertInfo) -> Unit, uninstallCert: (CaCertInfo) -> Unit, + selectedCaCert: MutableStateFlow, selectCaCert: (CaCertInfo) -> Unit, + installCert: () -> Boolean, parseCert: (Uri) -> Unit, + exportCert: (Uri) -> Unit, uninstallCert: () -> Unit, uninstallAllCerts: () -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current /** 0:none, 1:install, 2:info, 3:uninstall all */ var dialog by rememberSaveable { mutableIntStateOf(0) } val caCerts by caCertificates.collectAsStateWithLifecycle() - var selectedCaCert by rememberSaveable { mutableStateOf(null) } + val selectedCert by selectedCaCert.collectAsStateWithLifecycle() val getCertLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument()) { uri -> - if(uri != null) { - selectedCaCert = parseCert(uri) + if (uri != null) { + parseCert(uri) dialog = 1 } } val exportCertLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument()) { uri -> - if(uri != null) exportCert(uri, selectedCaCert!!) + if (uri != null) exportCert(uri) } LaunchedEffect(Unit) { getCerts() } Scaffold( @@ -1407,7 +1417,7 @@ fun CaCertScreen( Modifier .fillMaxWidth() .clickable { - selectedCaCert = cert + selectCaCert(cert) dialog = 2 } .animateItem() @@ -1421,11 +1431,11 @@ fun CaCertScreen( Spacer(Modifier.height(BottomPadding)) } } - if (selectedCaCert != null && (dialog == 1 || dialog == 2)) { - val cert = selectedCaCert!! + if (selectedCert != null && (dialog == 1 || dialog == 2)) { + val cert = selectedCert!! AlertDialog( text = { - Column { + Column(Modifier.verticalScroll(rememberScrollState())) { Text("Serial number", style = typography.labelLarge) SelectionContainer { Text(cert.serialNumber) } Text("Subject", style = typography.labelLarge) @@ -1445,7 +1455,7 @@ fun CaCertScreen( ) { TextButton( onClick = { - uninstallCert(cert) + uninstallCert() dialog = 0 }, modifier = Modifier.fillMaxWidth(0.49F), @@ -1467,7 +1477,7 @@ fun CaCertScreen( confirmButton = { if (dialog == 1) { TextButton({ - context.showOperationResultToast(installCert(cert)) + context.showOperationResultToast(installCert()) dialog = 0 }) { Text(stringResource(R.string.install)) @@ -1489,7 +1499,7 @@ fun CaCertScreen( } } }, - onDismissRequest = {} + onDismissRequest = { dialog = 0 } ) } if (dialog == 3) { From b870025f3d6c239b1fdddd0ee8bcc0dddc392f44 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Tue, 11 Nov 2025 00:04:51 +0800 Subject: [PATCH 22/26] Fix database initialization error (#189) --- .../java/com/bintianqi/owndroid/MyDbHelper.kt | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt index 4e5544b..3504c09 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -6,20 +6,25 @@ import android.database.sqlite.SQLiteOpenHelper class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 3) { override fun onCreate(db: SQLiteDatabase) { - db.execSQL("CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + - "signature TEXT, permissions TEXT)") + db.execSQL(DHIZUKU_CLIENTS_TABLE) + db.execSQL(SECURITY_LOGS_TABLE) + db.execSQL(NETWORK_LOGS_TABLE) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (oldVersion < 2) { - db.execSQL("CREATE TABLE security_logs (id INTEGER, tag INTEGER, level INTEGER," + - "time INTEGER, data TEXT)") + db.execSQL(SECURITY_LOGS_TABLE) } if (oldVersion < 3) { - db.execSQL( - "CREATE TABLE network_logs (id INTEGER, package INTEGER, time INTEGER," + - "type TEXT, host TEXT, count INTEGER, addresses TEXT, address TEXT," + - "port INTEGER)" - ) + db.execSQL(NETWORK_LOGS_TABLE) } } + companion object { + const val DHIZUKU_CLIENTS_TABLE = "CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + + "signature TEXT, permissions TEXT)" + const val SECURITY_LOGS_TABLE = "CREATE TABLE security_logs (id INTEGER, tag INTEGER," + + "level INTEGER, time INTEGER, data TEXT)" + const val NETWORK_LOGS_TABLE = "CREATE TABLE network_logs (id INTEGER, package INTEGER," + + "time INTEGER, type TEXT, host TEXT, count INTEGER, addresses TEXT," + + "address TEXT, port INTEGER)" + } } \ No newline at end of file From 97721892f367cd7ba83df9d4d63e4a1df7ff6e24 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Mon, 17 Nov 2025 13:07:02 +0800 Subject: [PATCH 23/26] Update dependencies --- gradle/libs.versions.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40d89a8..1a68b36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -agp = "8.13.0" +agp = "8.13.1" kotlin = "2.2.21" -navigation-compose = "2.9.5" -composeBom = "2025.10.01" +navigation-compose = "2.9.6" +composeBom = "2025.11.00" accompanist-drawablepainter = "0.37.3" accompanist-permissions = "0.37.3" shizuku = "13.1.5" fragment = "1.8.9" dhizuku = "2.5.4" -dhizuku-server = "0.0.6" +dhizuku-server = "0.0.10" hiddenApiBypass = "6.1" libsu = "6.0.0" serialization = "1.9.0" From 1dab0a08d2d7782ccb573c2a7be9d58cc5264ebb Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Wed, 19 Nov 2025 18:30:12 +0800 Subject: [PATCH 24/26] App grouping (#195) --- .../com/bintianqi/owndroid/ApiReceiver.kt | 1 - .../com/bintianqi/owndroid/MainActivity.kt | 41 ++- .../java/com/bintianqi/owndroid/MyDbHelper.kt | 9 +- .../com/bintianqi/owndroid/MyRepository.kt | 24 ++ .../com/bintianqi/owndroid/MyViewModel.kt | 19 ++ .../java/com/bintianqi/owndroid/Privilege.kt | 4 +- .../main/java/com/bintianqi/owndroid/Utils.kt | 2 +- .../bintianqi/owndroid/dpm/Applications.kt | 253 ++++++++++++++++-- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- gradle.properties | 1 + 13 files changed, 325 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt index 550df66..eca67d7 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt @@ -66,7 +66,6 @@ class ApiReceiver: BroadcastReceiver() { } else -> { log += "\nInvalid action" - false } } } catch(e: Exception) { diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 31742c1..446d856 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -114,6 +113,8 @@ import com.bintianqi.owndroid.dpm.DisableAccountManagement import com.bintianqi.owndroid.dpm.DisableAccountManagementScreen import com.bintianqi.owndroid.dpm.DisableMeteredData import com.bintianqi.owndroid.dpm.DisableUserControl +import com.bintianqi.owndroid.dpm.EditAppGroup +import com.bintianqi.owndroid.dpm.EditAppGroupScreen import com.bintianqi.owndroid.dpm.EnableSystemApp import com.bintianqi.owndroid.dpm.EnableSystemAppScreen import com.bintianqi.owndroid.dpm.FrpPolicy @@ -134,6 +135,8 @@ import com.bintianqi.owndroid.dpm.LockScreenInfo import com.bintianqi.owndroid.dpm.LockScreenInfoScreen import com.bintianqi.owndroid.dpm.LockTaskMode import com.bintianqi.owndroid.dpm.LockTaskModeScreen +import com.bintianqi.owndroid.dpm.ManageAppGroups +import com.bintianqi.owndroid.dpm.ManageAppGroupsScreen import com.bintianqi.owndroid.dpm.MtePolicy import com.bintianqi.owndroid.dpm.MtePolicyScreen import com.bintianqi.owndroid.dpm.NearbyStreamingPolicy @@ -284,6 +287,9 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { fun choosePackage() { navController.navigate(ApplicationsList(false)) } + fun navigateToAppGroups() { + navController.navigate(ManageAppGroups) + } LaunchedEffect(Unit) { if(!Privilege.status.value.activated) { navController.navigate(WorkModes(false)) { @@ -522,20 +528,20 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { PackageFunctionScreen(R.string.suspend, vm.suspendedPackages, vm::getSuspendedPackaged, vm::setPackageSuspended, ::navigateUp, vm.chosenPackage, ::choosePackage, - R.string.info_suspend_app) + ::navigateToAppGroups, vm.appGroups, R.string.info_suspend_app) } composable { PackageFunctionScreen(R.string.hide, vm.hiddenPackages, vm::getHiddenPackages, - vm::setPackageHidden, ::navigateUp, vm.chosenPackage, ::choosePackage) + vm::setPackageHidden, ::navigateUp, vm.chosenPackage, ::choosePackage, ::navigateToAppGroups, vm.appGroups) } composable { PackageFunctionScreenWithoutResult(R.string.block_uninstall, vm.ubPackages, - vm::getUbPackages, vm::setPackageUb, ::navigateUp, vm.chosenPackage, ::choosePackage) + vm::getUbPackages, vm::setPackageUb, ::navigateUp, vm.chosenPackage, ::choosePackage, ::navigateToAppGroups, vm.appGroups) } composable { PackageFunctionScreenWithoutResult(R.string.disable_user_control, vm.ucdPackages, vm::getUcdPackages, vm::setPackageUcd, ::navigateUp, vm.chosenPackage, - ::choosePackage, R.string.info_disable_user_control) + ::choosePackage, ::navigateToAppGroups, vm.appGroups, R.string.info_disable_user_control) } composable { PermissionsManagerScreen(vm.packagePermissions, vm::getPackagePermissions, @@ -543,7 +549,8 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } composable { PackageFunctionScreen(R.string.disable_metered_data, vm.mddPackages, - vm::getMddPackages, vm::setPackageMdd, ::navigateUp, vm.chosenPackage, ::choosePackage) + vm::getMddPackages, vm::setPackageMdd, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups) } composable { ClearAppStorageScreen(vm.chosenPackage, ::choosePackage, vm::clearAppData, ::navigateUp) @@ -554,7 +561,8 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { PackageFunctionScreenWithoutResult(R.string.keep_uninstalled_packages, vm.kuPackages, vm::getKuPackages, vm::setPackageKu, ::navigateUp, vm.chosenPackage, - ::choosePackage, R.string.info_keep_uninstalled_apps) + ::choosePackage, ::navigateToAppGroups, vm.appGroups, + R.string.info_keep_uninstalled_apps) } composable { InstallExistingAppScreen(vm.chosenPackage, ::choosePackage, @@ -562,11 +570,13 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } composable { PackageFunctionScreenWithoutResult(R.string.cross_profile_apps, vm.cpPackages, - vm::getCpPackages, vm::setPackageCp, ::navigateUp, vm.chosenPackage, ::choosePackage) + vm::getCpPackages, vm::setPackageCp, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups) } composable { PackageFunctionScreen(R.string.cross_profile_widget, vm.cpwProviders, - vm::getCpwProviders, vm::setCpwProvider, ::navigateUp, vm.chosenPackage, ::choosePackage) + vm::getCpwProviders, vm::setCpwProvider, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups) } composable { CredentialManagerPolicyScreen(vm.chosenPackage, ::choosePackage, @@ -588,6 +598,19 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { SetDefaultDialerScreen(vm.chosenPackage, ::choosePackage, vm::setDefaultDialer, ::navigateUp) } + composable { + ManageAppGroupsScreen( + vm.appGroups, + { id, name, apps -> navController.navigate(EditAppGroup(id, name, apps)) }, + ::navigateUp + ) + } + composable { + EditAppGroupScreen( + it.toRoute(), vm::getAppInfo, ::navigateUp, vm::setAppGroup, + vm::deleteAppGroup, ::choosePackage, vm.chosenPackage + ) + } composable { UserRestrictionScreen(vm::getUserRestrictions, ::navigateUp, ::navigate) diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt index 3504c09..dfa0805 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -4,11 +4,12 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 3) { +class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { override fun onCreate(db: SQLiteDatabase) { db.execSQL(DHIZUKU_CLIENTS_TABLE) db.execSQL(SECURITY_LOGS_TABLE) db.execSQL(NETWORK_LOGS_TABLE) + db.execSQL(APP_GROUPS_TABLE) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (oldVersion < 2) { @@ -17,6 +18,9 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 3) { if (oldVersion < 3) { db.execSQL(NETWORK_LOGS_TABLE) } + if (oldVersion < 4) { + db.execSQL(APP_GROUPS_TABLE) + } } companion object { const val DHIZUKU_CLIENTS_TABLE = "CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + @@ -26,5 +30,8 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 3) { const val NETWORK_LOGS_TABLE = "CREATE TABLE network_logs (id INTEGER, package INTEGER," + "time INTEGER, type TEXT, host TEXT, count INTEGER, addresses TEXT," + "address TEXT, port INTEGER)" + const val APP_GROUPS_TABLE = "CREATE TABLE app_groups(" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "name TEXT, apps TEXT)" } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt index 8e6af20..62f7c57 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt @@ -9,6 +9,7 @@ import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull +import com.bintianqi.owndroid.dpm.AppGroup import com.bintianqi.owndroid.dpm.NetworkLog import com.bintianqi.owndroid.dpm.SecurityEvent import com.bintianqi.owndroid.dpm.SecurityEventWithData @@ -224,4 +225,27 @@ class MyRepository(val dbHelper: MyDbHelper) { fun deleteNetworkLogs() { dbHelper.writableDatabase.execSQL("DELETE FROM network_logs") } + + fun getAppGroups(): List { + val list = mutableListOf() + dbHelper.readableDatabase.rawQuery("SELECT * FROM app_groups", null).use { + while (it.moveToNext()) { + list += AppGroup(it.getInt(0), it.getString(1), it.getString(2).split(',')) + } + } + return list + } + fun setAppGroup(id: Int?, name: String, apps: List) { + val cv = ContentValues() + cv.put("name", name) + cv.put("apps", apps.joinToString(",")) + if (id == null) { + dbHelper.writableDatabase.insert("app_groups", null, cv) + } else { + dbHelper.writableDatabase.update("app_groups", cv, "id = ?", arrayOf(id.toString())) + } + } + fun deleteAppGroup(id: Int) { + dbHelper.writableDatabase.delete("app_groups", "id = ?", arrayOf(id.toString())) + } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 86a8051..5bc3510 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -59,6 +59,7 @@ import com.bintianqi.owndroid.dpm.ApnAuthType import com.bintianqi.owndroid.dpm.ApnConfig import com.bintianqi.owndroid.dpm.ApnMvnoType import com.bintianqi.owndroid.dpm.ApnProtocol +import com.bintianqi.owndroid.dpm.AppGroup import com.bintianqi.owndroid.dpm.AppStatus import com.bintianqi.owndroid.dpm.CaCertInfo import com.bintianqi.owndroid.dpm.CreateUserResult @@ -509,6 +510,24 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } } + val appGroups = MutableStateFlow(emptyList()) + init { + getAppGroups() + } + fun getAppGroups() { + appGroups.value = myRepo.getAppGroups() + } + fun setAppGroup(id: Int?, name: String, apps: List) { + myRepo.setAppGroup(id, name, apps) + getAppGroups() + } + fun deleteAppGroup(id: Int) { + myRepo.deleteAppGroup(id) + appGroups.update { group -> + group.filter { it.id != id } + } + } + @RequiresApi(24) fun reboot() { DPM.reboot(DAR) diff --git a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt index c68fc80..5b2f284 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt @@ -23,8 +23,8 @@ object Privilege { return } } - } catch(_: Exception) { - false + } catch(e: Exception) { + e.printStackTrace() } dhizukuErrorStatus.value = 2 } diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index 197244d..700658d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -135,7 +135,7 @@ class SerializableSaver(val serializer: KSerializer) : Saver { override fun restore(value: String): T? { return Json.decodeFromString(serializer, value) } - override fun SaverScope.save(value: T): String? { + override fun SaverScope.save(value: T): String { return Json.encodeToString(serializer, value) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index dfb1691..1920f3f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -30,12 +32,20 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar @@ -46,12 +56,15 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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 import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -89,6 +102,7 @@ import com.bintianqi.owndroid.ui.SwitchItem import com.google.accompanist.drawablepainter.rememberDrawablePainter import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable val String.isValidPackageName @@ -729,49 +743,238 @@ fun SetDefaultDialerScreen( fun PackageFunctionScreenWithoutResult( title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, onSet: (String, Boolean) -> Unit, onNavigateUp: () -> Unit, - chosenPackage: Channel, onChoosePackage: () -> Unit, notes: Int? = null + chosenPackage: Channel, onChoosePackage: () -> Unit, + navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null ) { PackageFunctionScreen( title, packagesState, onGet, { name, status -> onSet(name, status); null }, - onNavigateUp, chosenPackage, onChoosePackage, notes + onNavigateUp, chosenPackage, onChoosePackage, navigateToGroups, appGroups, notes ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PackageFunctionScreen( title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, onSet: (String, Boolean) -> Boolean?, onNavigateUp: () -> Unit, - chosenPackage: Channel, onChoosePackage: () -> Unit, notes: Int? = null + chosenPackage: Channel, onChoosePackage: () -> Unit, + navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null ) { + val groups by appGroups.collectAsStateWithLifecycle() val packages by packagesState.collectAsStateWithLifecycle() var packageName by rememberSaveable { mutableStateOf("") } + var selectedGroup by remember { mutableStateOf(null) } LaunchedEffect(Unit) { onGet() packageName = chosenPackage.receive() } - MyLazyScaffold(title, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - onSet(it.name, false) - } - } - item { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } - Button( - { - if (onSet(packageName, true) != false) { - println("reset") - packageName = "" + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(title)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + actions = { + var expand by remember { mutableStateOf(false) } + Box { + IconButton({ + expand = true + }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(expand, { expand = false }) { + groups.forEach { + DropdownMenuItem( + { Text("(${it.apps.size}) ${it.name}") }, + { + selectedGroup = it + expand = false + } + ) + } + if (groups.isNotEmpty()) HorizontalDivider() + DropdownMenuItem( + { Text(stringResource(R.string.manage_app_groups)) }, + { + navigateToGroups() + expand = false + } + ) + } } - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) + } + ) + } + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(packages, { it.name }) { + ApplicationItem(it) { + onSet(it.name, false) + } + } + item { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + Button( + { + if (onSet(packageName, true) != false) { + packageName = "" + } + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } + if (notes != null) Notes(notes, HorizontalPadding) + Spacer(Modifier.height(BottomPadding)) } - if (notes != null) Notes(notes, HorizontalPadding) - Spacer(Modifier.height(BottomPadding)) } } -} \ No newline at end of file + if (selectedGroup != null) AlertDialog( + text = { + Column { + Button({ + selectedGroup!!.apps.forEach { + onSet(it, true) + } + selectedGroup = null + }) { + Text(stringResource(R.string.add_to_list)) + } + Button({ + selectedGroup!!.apps.forEach { + onSet(it, false) + } + selectedGroup = null + }) { + Text(stringResource(R.string.remove_from_list)) + } + } + }, + confirmButton = { + TextButton({ selectedGroup = null }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { selectedGroup = null } + ) +} + +class AppGroup(val id: Int, val name: String, val apps: List) + +@Serializable object ManageAppGroups + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageAppGroupsScreen( + appGroups: StateFlow>, + navigateToEditScreen: (Int?, String, List) -> Unit, navigateUp: () -> Unit +) { + val groups by appGroups.collectAsStateWithLifecycle() + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.app_group)) }, + navigationIcon = { NavIcon(navigateUp) } + ) + }, + floatingActionButton = { + FloatingActionButton({ + navigateToEditScreen(null, "", emptyList()) + }) { + Icon(Icons.Default.Add, null) + } + } + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(groups, { it.id }) { + Column( + Modifier.fillMaxWidth().clickable { + navigateToEditScreen(it.id, it.name, it.apps) + }.padding(HorizontalPadding, 8.dp) + ) { + Text(it.name) + Text( + it.apps.size.toString() + " apps", Modifier.alpha(0.7F), + style = typography.bodyMedium + ) + } + } + } + } +} + +@Serializable class EditAppGroup(val id: Int?, val name: String, val apps: List) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditAppGroupScreen( + params: EditAppGroup, getAppInfo: (String) -> AppInfo, navigateUp: () -> Unit, + setGroup: (Int?, String, List) -> Unit, deleteGroup: (Int) -> Unit, + onChoosePackage: () -> Unit, chosenPackage: Channel +) { + var name by rememberSaveable { mutableStateOf(params.name) } + val list = rememberSaveable { mutableStateListOf(*params.apps.toTypedArray()) } + val appInfoList = list.map { getAppInfo(it) } + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.edit_app_group)) }, + navigationIcon = { + NavIcon(navigateUp) + }, + actions = { + if (params.id != null) IconButton({ + deleteGroup(params.id) + navigateUp() + }) { + Icon(Icons.Outlined.Delete, null) + } + IconButton( + { + setGroup(params.id, name, list) + navigateUp() + }, + enabled = name.isNotBlank() && list.isNotEmpty() + ) { + Icon(Icons.Default.Check, null) + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + item { + OutlinedTextField( + name, { name = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + label = { Text(stringResource(R.string.name)) } + ) + } + items(appInfoList, { it.name }) { + ApplicationItem(it) { + list -= it.name + } + } + item { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + Button( + { + list += packageName + packageName = "" + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } + Spacer(Modifier.height(BottomPadding)) + } + } + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 236ebe7..258abcd 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -112,7 +112,6 @@ Не удалось инициализировать Dhizuku Разрешение Dhizuku не предоставлено - Режим Dhizuku отключен Система diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 664810e..33d5f12 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -118,7 +118,6 @@ Dhizuku Başlatılamadı Dhizuku İzni Verilmedi - Dhizuku Modu Devre Dışı Sistem diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ad9d6cc..18c10cc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -115,7 +115,6 @@ Dhizuku初始化失败 Dhizuku未授权 - Dhizuku模式已禁用 系统 @@ -370,6 +369,11 @@ 启用系统应用 卸载后保留 搜索 + 应用组 + 管理组 + 编辑组 + 添加到列表 + 从列表中移除 用户限制 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 452520e..e3baaaf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,7 +122,6 @@ Dhizuku Failed to initialize Dhizuku Dhizuku permission not granted - Dhizuku mode disabled Shizuku @@ -404,6 +403,11 @@ Install existing app Keep after uninstall Search + App group + Manage groups + Edit group + Add to list + Remove from list User restriction diff --git a/gradle.properties b/gradle.properties index 643d242..7d47327 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,3 +7,4 @@ kotlin.code.style=official org.gradle.parallel=true org.gradle.caching=true org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx1536M" +org.gradle.configuration-cache=true From aa022aaee567e12ba8927c2833d446d29b85a86e Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Thu, 20 Nov 2025 12:58:01 +0800 Subject: [PATCH 25/26] Fix app group bugs (#195) --- .../java/com/bintianqi/owndroid/MyViewModel.kt | 14 +++++++------- .../com/bintianqi/owndroid/dpm/Applications.kt | 17 ++++++++++------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 5bc3510..654ee05 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -249,7 +249,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val ucdPackages = MutableStateFlow(emptyList()) @RequiresApi(30) fun getUcdPackages() { - ucdPackages.value = DPM.getUserControlDisabledPackages(DAR).map { + ucdPackages.value = DPM.getUserControlDisabledPackages(DAR).distinct().map { getAppInfo(it) } } @@ -284,7 +284,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val mddPackages = MutableStateFlow(emptyList()) @RequiresApi(28) fun getMddPackages() { - mddPackages.value = DPM.getMeteredDataDisabledPackages(DAR).map { getAppInfo(it) } + mddPackages.value = DPM.getMeteredDataDisabledPackages(DAR).distinct().map { getAppInfo(it) } } @RequiresApi(28) fun setPackageMdd(name: String, status: Boolean): Boolean { @@ -299,7 +299,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val kuPackages = MutableStateFlow(emptyList()) @RequiresApi(28) fun getKuPackages() { - kuPackages.value = DPM.getKeepUninstalledPackages(DAR)?.map { getAppInfo(it) } ?: emptyList() + kuPackages.value = DPM.getKeepUninstalledPackages(DAR)?.distinct()?.map { getAppInfo(it) } ?: emptyList() } @RequiresApi(28) fun setPackageKu(name: String, status: Boolean) { @@ -327,7 +327,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { // Cross-profile widget providers val cpwProviders = MutableStateFlow(emptyList()) fun getCpwProviders() { - cpwProviders.value = DPM.getCrossProfileWidgetProviders(DAR).map { getAppInfo(it) } + cpwProviders.value = DPM.getCrossProfileWidgetProviders(DAR).distinct().map { getAppInfo(it) } } fun setCpwProvider(name: String, status: Boolean): Boolean { val result = if (status) { @@ -388,7 +388,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { @RequiresApi(34) fun getCmPolicy(): Int { return DPM.credentialManagerPolicy?.let { policy -> - cmPackages.value = policy.packageNames.map { getAppInfo(it) } + cmPackages.value = policy.packageNames.distinct().map { getAppInfo(it) } policy.policyType } ?: -1 } @@ -409,7 +409,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val pimPackages = MutableStateFlow(emptyList()) fun getPimPackages(): Boolean { return DPM.getPermittedInputMethods(DAR).let { packages -> - pimPackages.value = packages?.map { getAppInfo(it) } ?: emptyList() + pimPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList() packages == null } } @@ -429,7 +429,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val pasPackages = MutableStateFlow(emptyList()) fun getPasPackages(): Boolean { return DPM.getPermittedAccessibilityServices(DAR).let { packages -> - pasPackages.value = packages?.map { getAppInfo(it) } ?: emptyList() + pasPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList() packages == null } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index 1920f3f..c4ffd1b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -763,6 +763,7 @@ fun PackageFunctionScreen( val groups by appGroups.collectAsStateWithLifecycle() val packages by packagesState.collectAsStateWithLifecycle() var packageName by rememberSaveable { mutableStateOf("") } + var dialog by remember { mutableStateOf(false) } var selectedGroup by remember { mutableStateOf(null) } LaunchedEffect(Unit) { onGet() @@ -787,6 +788,7 @@ fun PackageFunctionScreen( { Text("(${it.apps.size}) ${it.name}") }, { selectedGroup = it + dialog = true expand = false } ) @@ -821,7 +823,8 @@ fun PackageFunctionScreen( } }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), - packageName.isValidPackageName + packageName.isValidPackageName && + packages.find { it.name == packageName } == null ) { Text(stringResource(R.string.add)) } @@ -830,14 +833,14 @@ fun PackageFunctionScreen( } } } - if (selectedGroup != null) AlertDialog( + if (dialog) AlertDialog( text = { Column { Button({ selectedGroup!!.apps.forEach { onSet(it, true) } - selectedGroup = null + dialog = false }) { Text(stringResource(R.string.add_to_list)) } @@ -845,18 +848,18 @@ fun PackageFunctionScreen( selectedGroup!!.apps.forEach { onSet(it, false) } - selectedGroup = null + dialog = false }) { Text(stringResource(R.string.remove_from_list)) } } }, confirmButton = { - TextButton({ selectedGroup = null }) { + TextButton({ dialog = false }) { Text(stringResource(R.string.cancel)) } }, - onDismissRequest = { selectedGroup = null } + onDismissRequest = { dialog = false } ) } @@ -969,7 +972,7 @@ fun EditAppGroupScreen( packageName = "" }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), - packageName.isValidPackageName + packageName.isValidPackageName && packageName !in list ) { Text(stringResource(R.string.add)) } From d375a9bae6087dee269b67c0344f264ad5f377a5 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sat, 22 Nov 2025 21:33:58 +0800 Subject: [PATCH 26/26] Update release workflow Automatically show keyboard in AppLockDialog --- .github/workflows/release.yml | 2 ++ app/src/main/java/com/bintianqi/owndroid/AppLock.kt | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a48c02..4e2288c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + ref: 'master' - name: Set up JDK 21 uses: actions/setup-java@v4 diff --git a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt index e25a6c3..820c554 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt @@ -25,10 +25,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -44,6 +47,7 @@ import androidx.compose.ui.window.DialogProperties fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismiss, DialogProperties(true, false)) { val context = LocalContext.current val fm = LocalFocusManager.current + val fr = remember { FocusRequester() } var input by rememberSaveable { mutableStateOf("") } var isError by rememberSaveable { mutableStateOf(false) } fun unlock() { @@ -55,14 +59,18 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi } } LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= 28 && SP.biometricsUnlock) startBiometricsUnlock(context, onSucceed) + if (Build.VERSION.SDK_INT >= 28 && SP.biometricsUnlock) { + startBiometricsUnlock(context, onSucceed) + } else { + fr.requestFocus() + } } BackHandler(onBack = onDismiss) Card(Modifier.pointerInput(Unit) { detectTapGestures(onTap = { fm.clearFocus() }) }, shape = RoundedCornerShape(16.dp)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( - input, { input = it; isError = false }, Modifier.width(200.dp), + input, { input = it; isError = false }, Modifier.width(200.dp).focusRequester(fr), label = { Text(stringResource(R.string.password)) }, isError = isError, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = if(input.length >= 4) ImeAction.Go else ImeAction.Done