diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 06b7c4e..5eb926a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,12 +72,6 @@ jobs: with: path: artifacts - - name: Download telegram-bot-api - run: | - mkdir ./binaries - wget "https://github.com/jakbin/telegram-bot-api-binary/releases/download/latest/telegram-bot-api" -O ./binaries/telegram-bot-api - chmod +x ./binaries/telegram-bot-api - - name: Start API Server & Upload env: COMMIT_MESSAGE: |+ @@ -91,8 +85,7 @@ jobs: mv ./$RELEASE_TEST_PWD/app-release.apk ./$RELEASE_TEST_PWD.apk && rm -rf ./$RELEASE_TEST_PWD export RELEASE_SIGNED_PWD=$(find . -name "*release-signed*") mv ./$RELEASE_SIGNED_PWD/app-release.apk ./$RELEASE_SIGNED_PWD.apk && rm -rf ./$RELEASE_SIGNED_PWD - ../binaries/telegram-bot-api --api-id=${{ secrets.TELEGRAM_API_APP_ID }} --api-hash=${{ secrets.TELEGRAM_API_HASH }} --local 2>&1 > /dev/null & export token=${{ secrets.TELEGRAM_BOT_KEY }} - curl -v "http://127.0.0.1:8081/bot$token/sendMediaGroup?chat_id=-1002203528169&media=%5B%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseTest%22%7D%2C%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseSigned%22%2C%22parse_mode%22%3A%22HTML%22%2C%22caption%22%3A${ESCAPED}%7D%5D" \ + curl -v "http://api.telegram.org/bot$token/sendMediaGroup?chat_id=-1002203528169&media=%5B%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseTest%22%7D%2C%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseSigned%22%2C%22parse_mode%22%3A%22HTML%22%2C%22caption%22%3A${ESCAPED}%7D%5D" \ -F releaseTest="@$RELEASE_TEST_PWD.apk" \ -F releaseSigned="@$RELEASE_SIGNED_PWD.apk" diff --git a/Readme-zh_CN.md b/Readme-zh_CN.md index 01e7af0..349d306 100644 --- a/Readme-zh_CN.md +++ b/Readme-zh_CN.md @@ -57,7 +57,7 @@ java.lang.IllegalStateException: Not allowed to set the device owner because the > [!NOTE] > 一些系统有应用克隆、儿童空间等功能,它们通常是用户。 -#### Device owner 已存在 +### Device owner 已存在 ```text java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.owndroid/.Receiver), but device owner (xxx) is already set. @@ -142,7 +142,7 @@ context.sendBroadcast(intent) [License.md](LICENSE.md) -> Copyright (C) 2024 BinTianqi +> Copyright (C) 2026 BinTianqi > > This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. > diff --git a/Readme.md b/Readme.md index 2a2b9a0..a462f4d 100644 --- a/Readme.md +++ b/Readme.md @@ -57,7 +57,7 @@ Solutions: > [!NOTE] > Some systems have features such as app cloning and children space, which are usually users. -#### Device owner is already set +### 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. @@ -144,7 +144,7 @@ You can use Gradle in command line to build OwnDroid. [License.md](LICENSE.md) -> Copyright (C) 2024 BinTianqi +> Copyright (C) 2026 BinTianqi > > This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. > diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 84c2009..fa9225d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,10 +24,10 @@ android { defaultConfig { applicationId = "com.bintianqi.owndroid" - minSdk = 21 + minSdk = 23 targetSdk = 36 - versionCode = 41 - versionName = "7.2" + versionCode = 42 + versionName = "7.3" multiDexEnabled = false } @@ -103,6 +103,7 @@ dependencies { implementation(libs.androidx.fragment) implementation(libs.hiddenApiBypass) implementation(libs.libsu) + implementation(libs.reoderable) implementation(libs.serialization) implementation(kotlin("reflect")) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 506c110..e328d50 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + + android:exported="false"> @@ -99,6 +101,12 @@ android:name=".ApiReceiver" android:exported="true"> + + + () vm.initialize(intent) + vm.registerInstallerReceiver(this) val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) setContent { OwnDroidTheme(theme) { diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt index db89514..75cc03c 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt @@ -36,13 +36,19 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat 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) { + for(i in 0..) { uiState.update { it.copy(packages = it.packages.plus(packages).distinct()) @@ -93,17 +99,14 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat uiState.update { it.copy(installing = false, packageWriting = -1) } return } - ContextCompat.registerReceiver( - application, Receiver(), IntentFilter(ACTION), null, - null, ContextCompat.RECEIVER_EXPORTED - ) + val intent = Intent(ACTION).setPackage(application.packageName) val pi = if(Build.VERSION.SDK_INT >= 34) { PendingIntent.getBroadcast( - application, sessionId, Intent(ACTION), + application, sessionId, intent, PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE ).intentSender } else { - PendingIntent.getBroadcast(application, sessionId, Intent(ACTION), PendingIntent.FLAG_MUTABLE).intentSender + PendingIntent.getBroadcast(application, sessionId, intent, PendingIntent.FLAG_MUTABLE).intentSender } session.commit(pi) } @@ -119,7 +122,6 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat ) } else { uiState.update { it.copy(result = intent) } - context.unregisterReceiver(this) } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt index 820c554..98535c9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt @@ -3,6 +3,7 @@ package com.bintianqi.owndroid import android.content.Context import android.hardware.biometrics.BiometricPrompt import android.hardware.biometrics.BiometricPrompt.AuthenticationCallback +import android.hardware.fingerprint.FingerprintManager import android.os.Build import android.os.CancellationSignal import androidx.activity.compose.BackHandler @@ -19,6 +20,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,6 +41,8 @@ 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.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -50,6 +54,7 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi val fr = remember { FocusRequester() } var input by rememberSaveable { mutableStateOf("") } var isError by rememberSaveable { mutableStateOf(false) } + var showPassword by remember { mutableStateOf(false) } fun unlock() { if(input.hash() == SP.lockPasswordHash) { fm.clearFocus() @@ -75,7 +80,18 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = if(input.length >= 4) ImeAction.Go else ImeAction.Done ), - keyboardActions = KeyboardActions({ fm.clearFocus() }, { unlock() }) + keyboardActions = KeyboardActions({ fm.clearFocus() }, { unlock() }), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + painter = painterResource( + id = if (showPassword) R.drawable.visibility_fill0 else R.drawable.visibility_off_fill0 + ), + contentDescription = if (showPassword) "Hide password" else "Show password" + ) + } + } ) if(Build.VERSION.SDK_INT >= 28 && SP.biometricsUnlock) { FilledTonalIconButton({ startBiometricsUnlock(context, onSucceed) }, Modifier.padding(start = 4.dp)) { @@ -83,7 +99,7 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi } } } - Button(::unlock, Modifier.align(Alignment.End).padding(top = 8.dp), input.length >= 4) { + Button(::unlock, Modifier.align(Alignment.End).padding(top = 8.dp)) { Text(stringResource(R.string.unlock)) } } @@ -92,6 +108,7 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi @RequiresApi(28) fun startBiometricsUnlock(context: Context, onSucceed: () -> Unit) { + context.getSystemService(FingerprintManager::class.java) ?: return val callback = object : AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { super.onAuthenticationSucceeded(result) diff --git a/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt b/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt new file mode 100644 index 0000000..301117d --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt @@ -0,0 +1,84 @@ +package com.bintianqi.owndroid + +import android.app.ActivityManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@RequiresApi(28) +class LockTaskService: Service() { + val coroutineScope = CoroutineScope(Dispatchers.IO) + + override fun onBind(intent: Intent?): IBinder? = null + + val stopReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + coroutineScope.cancel() + stopLockTask() + stop() + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val filter = IntentFilter(STOP_ACTION) + ContextCompat.registerReceiver( + this, stopReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED + ) + val pendingIntent = PendingIntent.getBroadcast( + this, 0, Intent(STOP_ACTION).setPackage(this.packageName), PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(this, MyNotificationChannel.LockTaskMode.id) + .setContentTitle(getText(R.string.lock_task_mode)) + .setSmallIcon(R.drawable.lock_fill0) + .addAction(NotificationCompat.Action.Builder(null, getString(R.string.stop), pendingIntent).build()) + .setOngoing(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + ServiceCompat.startForeground( + this, NotificationType.LockTaskMode.id, notification, + if (Build.VERSION.SDK_INT < 34) 0 else ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST + ) + coroutineScope.launch { + val am = getSystemService(ActivityManager::class.java) + delay(3000) + while (am.lockTaskModeState == ActivityManager.LOCK_TASK_MODE_LOCKED) { + delay(1000) + } + stop() + } + return START_NOT_STICKY + } + + fun stop() { + unregisterReceiver(stopReceiver) + stopSelf() + } + + fun stopLockTask() { + val features = Privilege.DPM.getLockTaskFeatures(Privilege.DAR) + val packages = Privilege.DPM.getLockTaskPackages(Privilege.DAR) + Privilege.DPM.setLockTaskPackages(Privilege.DAR, arrayOf()) + Privilege.DPM.setLockTaskPackages(Privilege.DAR, packages) + Privilege.DPM.setLockTaskFeatures(Privilege.DAR, features) + } + + companion object { + const val STOP_ACTION = "com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 446d856..5aedebe 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -137,6 +137,8 @@ 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.ManagedConfiguration +import com.bintianqi.owndroid.dpm.ManagedConfigurationScreen import com.bintianqi.owndroid.dpm.MtePolicy import com.bintianqi.owndroid.dpm.MtePolicyScreen import com.bintianqi.owndroid.dpm.NearbyStreamingPolicy @@ -155,7 +157,6 @@ import com.bintianqi.owndroid.dpm.OrganizationOwnedProfileScreen import com.bintianqi.owndroid.dpm.OverrideApn import com.bintianqi.owndroid.dpm.OverrideApnScreen import com.bintianqi.owndroid.dpm.PackageFunctionScreen -import com.bintianqi.owndroid.dpm.PackageFunctionScreenWithoutResult import com.bintianqi.owndroid.dpm.Password import com.bintianqi.owndroid.dpm.PasswordInfo import com.bintianqi.owndroid.dpm.PasswordInfoScreen @@ -253,6 +254,9 @@ class MainActivity : FragmentActivity() { val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } + registerPackageRemovedReceiver(this) { + vm.onPackageRemoved(it) + } setContent { var appLockDialog by rememberSaveable { mutableStateOf(false) } val theme by vm.theme.collectAsStateWithLifecycle() @@ -265,10 +269,6 @@ class MainActivity : FragmentActivity() { } } - override fun onResume() { - super.onResume() - } - } @ExperimentalMaterial3Api @@ -285,7 +285,10 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } } fun choosePackage() { - navController.navigate(ApplicationsList(false)) + navController.navigate(ApplicationsList(false, true)) + } + fun chooseSinglePackage() { + navController.navigate(ApplicationsList(false, false)) } fun navigateToAppGroups() { navController.navigate(ManageAppGroups) @@ -331,7 +334,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { DelegatedAdminsScreen(vm.delegatedAdmins, vm::getDelegatedAdmins, ::navigateUp, ::navigate) } composable{ - AddDelegatedAdminScreen(vm.chosenPackage, ::choosePackage, it.toRoute(), + AddDelegatedAdminScreen(vm.chosenPackage, ::chooseSinglePackage, it.toRoute(), vm::setDelegatedAdmin, ::navigateUp) } composable { DeviceInfoScreen(vm, ::navigateUp) } @@ -385,9 +388,11 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { vm::getNsNotificationPolicy, vm::setNsNotificationPolicy, ::navigateUp) } composable { - LockTaskModeScreen(vm.chosenPackage, ::choosePackage, vm.lockTaskPackages, + LockTaskModeScreen( + vm.chosenPackage, ::chooseSinglePackage, ::choosePackage, vm.lockTaskPackages, vm::getLockTaskPackages, vm::setLockTaskPackage, vm::startLockTaskMode, - vm:: getLockTaskFeatures, vm::setLockTaskFeatures, ::navigateUp) + vm:: getLockTaskFeatures, vm::setLockTaskFeatures, ::navigateUp + ) } composable { CaCertScreen(vm.installedCaCerts, vm::getCaCerts, vm.selectedCaCert, vm::selectCaCert, vm::installCaCert, vm::parseCaCert, @@ -435,7 +440,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { WifiSsidPolicyScreen(vm::getSsidPolicy, vm::setSsidPolicy, ::navigateUp) } composable { - NetworkStatsScreen(vm.chosenPackage, ::choosePackage, vm::getPackageUid, + NetworkStatsScreen(vm.chosenPackage, ::chooseSinglePackage, vm::getPackageUid, vm::queryNetworkStats, ::navigateUp) { navController.navigate(NetworkStatsViewer) } } composable { @@ -446,7 +451,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } composable { AlwaysOnVpnPackageScreen(vm::getAlwaysOnVpnPackage, vm::getAlwaysOnVpnLockdown, - vm::setAlwaysOnVpn, vm.chosenPackage, ::choosePackage, ::navigateUp) + vm::setAlwaysOnVpn, vm.chosenPackage, ::chooseSinglePackage, ::navigateUp) } composable { RecommendedGlobalProxyScreen(vm::setRecommendedGlobalProxy, ::navigateUp) @@ -494,10 +499,10 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { DeleteWorkProfileScreen(vm::wipeData, ::navigateUp) } composable { - val canSwitchView = (it.toRoute() as ApplicationsList).canSwitchView + val params = it.toRoute() AppChooserScreen( - canSwitchView, vm.installedPackages, vm.refreshPackagesProgress, { name -> - if (canSwitchView) { + params, vm.installedPackages, vm.refreshPackagesProgress, { name -> + if (params.canSwitchView) { if (name == null) { navigateUp() } else { @@ -512,12 +517,12 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { navController.navigate(ApplicationsFeatures) { popUpTo(Home) } - }, vm::refreshPackageList) + }, vm::refreshPackageList, vm::setPackageSuspended, vm::setPackageHidden) } composable { ApplicationsFeaturesScreen(::navigateUp, ::navigate) { SP.applicationsListView = true - navController.navigate(ApplicationsList(true)) { + navController.navigate(ApplicationsList(true, true)) { popUpTo(Home) } } @@ -526,52 +531,72 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { ApplicationDetailsScreen(it.toRoute(), vm, ::navigateUp, ::navigate) } composable { - PackageFunctionScreen(R.string.suspend, vm.suspendedPackages, vm::getSuspendedPackaged, + PackageFunctionScreen( + R.string.suspend, vm.suspendedPackages, vm::getSuspendedPackaged, vm::setPackageSuspended, ::navigateUp, vm.chosenPackage, ::choosePackage, - ::navigateToAppGroups, vm.appGroups, 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, ::navigateToAppGroups, vm.appGroups) + PackageFunctionScreen( + R.string.hide, vm.hiddenPackages, vm::getHiddenPackages, 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, ::navigateToAppGroups, vm.appGroups) + PackageFunctionScreen( + R.string.block_uninstall, vm.ubPackages, 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, ::navigateToAppGroups, vm.appGroups, R.string.info_disable_user_control) + PackageFunctionScreen( + R.string.disable_user_control, vm.ucdPackages, vm::getUcdPackages, + vm::setPackageUcd, ::navigateUp, vm.chosenPackage, ::choosePackage, + ::navigateToAppGroups, vm.appGroups, R.string.info_disable_user_control + ) } composable { - PermissionsManagerScreen(vm.packagePermissions, vm::getPackagePermissions, - vm::setPackagePermission, ::navigateUp, it.toRoute(), vm.chosenPackage, ::choosePackage) + PermissionsManagerScreen( + vm.packagePermissions, vm::getPackagePermissions, vm::setPackagePermission, + ::navigateUp, it.toRoute(), vm.chosenPackage, ::chooseSinglePackage + ) } composable { - PackageFunctionScreen(R.string.disable_metered_data, vm.mddPackages, - vm::getMddPackages, vm::setPackageMdd, ::navigateUp, vm.chosenPackage, - ::choosePackage, ::navigateToAppGroups, vm.appGroups) + PackageFunctionScreen( + R.string.disable_metered_data, vm.mddPackages, vm::getMddPackages, + vm::setPackageMdd, ::navigateUp, vm.chosenPackage, ::choosePackage, + ::navigateToAppGroups, vm.appGroups + ) } composable { - ClearAppStorageScreen(vm.chosenPackage, ::choosePackage, vm::clearAppData, ::navigateUp) + ClearAppStorageScreen( + vm.chosenPackage, ::chooseSinglePackage, vm::clearAppData, ::navigateUp + ) } composable { - UninstallAppScreen(vm.chosenPackage, ::choosePackage, vm::uninstallPackage, ::navigateUp) + UninstallAppScreen( + vm.chosenPackage, ::chooseSinglePackage, vm::uninstallPackage, ::navigateUp + ) } composable { - PackageFunctionScreenWithoutResult(R.string.keep_uninstalled_packages, vm.kuPackages, - vm::getKuPackages, vm::setPackageKu, ::navigateUp, vm.chosenPackage, - ::choosePackage, ::navigateToAppGroups, vm.appGroups, - R.string.info_keep_uninstalled_apps) + PackageFunctionScreen( + R.string.keep_uninstalled_packages, vm.kuPackages, vm::getKuPackages, + vm::setPackageKu, ::navigateUp, vm.chosenPackage, ::choosePackage, + ::navigateToAppGroups, vm.appGroups, R.string.info_keep_uninstalled_apps + ) } composable { - InstallExistingAppScreen(vm.chosenPackage, ::choosePackage, - vm::installExistingApp, ::navigateUp) + InstallExistingAppScreen( + vm.chosenPackage, ::chooseSinglePackage, vm::installExistingApp, ::navigateUp + ) } composable { - PackageFunctionScreenWithoutResult(R.string.cross_profile_apps, vm.cpPackages, + PackageFunctionScreen( + R.string.cross_profile_apps, vm.cpPackages, vm::getCpPackages, vm::setPackageCp, ::navigateUp, vm.chosenPackage, - ::choosePackage, ::navigateToAppGroups, vm.appGroups) + ::choosePackage, ::navigateToAppGroups, vm.appGroups + ) } composable { PackageFunctionScreen(R.string.cross_profile_widget, vm.cpwProviders, @@ -579,28 +604,45 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { ::choosePackage, ::navigateToAppGroups, vm.appGroups) } composable { - CredentialManagerPolicyScreen(vm.chosenPackage, ::choosePackage, - vm.cmPackages, vm::getCmPolicy, vm::setCmPackage, vm::setCmPolicy, ::navigateUp) + CredentialManagerPolicyScreen( + vm.chosenPackage, ::choosePackage, vm.cmPackages, vm::getCmPolicy, + vm::setCmPackage, vm::setCmPolicy, ::navigateUp + ) } composable { - PermittedAsAndImPackages(R.string.permitted_accessibility_services, + PermittedAsAndImPackages( + R.string.permitted_accessibility_services, R.string.system_accessibility_always_allowed, vm.chosenPackage, ::choosePackage, - vm.pasPackages, vm::getPasPackages, vm::setPasPackage, vm::setPasPolicy, ::navigateUp) + vm.pasPackages, vm::getPasPackages, vm::setPasPackage, vm::setPasPolicy, + ::navigateUp + ) } composable { - PermittedAsAndImPackages(R.string.permitted_ime, R.string.system_ime_always_allowed, + PermittedAsAndImPackages( + R.string.permitted_ime, R.string.system_ime_always_allowed, vm.chosenPackage, ::choosePackage, vm.pimPackages, vm::getPimPackages, - vm::setPimPackage, vm::setPimPolicy, ::navigateUp) + vm::setPimPackage, vm::setPimPolicy, ::navigateUp + ) } composable { - EnableSystemAppScreen(vm.chosenPackage, ::choosePackage, vm::enableSystemApp, ::navigateUp) + EnableSystemAppScreen( + vm.chosenPackage, ::chooseSinglePackage, vm::enableSystemApp, ::navigateUp + ) } composable { - SetDefaultDialerScreen(vm.chosenPackage, ::choosePackage, vm::setDefaultDialer, ::navigateUp) + SetDefaultDialerScreen( + vm.chosenPackage, ::chooseSinglePackage, vm::setDefaultDialer, ::navigateUp + ) + } + composable { + ManagedConfigurationScreen( + it.toRoute(), vm.appRestrictions, vm::setAppRestrictions, + vm::clearAppRestrictions, ::navigateUp + ) } composable { ManageAppGroupsScreen( - vm.appGroups, + vm.appGroups, vm::exportAppGroups, vm::importAppGroups, { id, name, apps -> navController.navigate(EditAppGroup(id, name, apps)) }, ::navigateUp ) @@ -751,7 +793,10 @@ private fun HomeScreen(onNavigate: (Any) -> Unit) { } if(privilege.device || privilege.profile) { HomePageItem(R.string.applications, R.drawable.apps_fill0) { - onNavigate(if(SP.applicationsListView) ApplicationsList(true) else ApplicationsFeatures) + onNavigate( + if (SP.applicationsListView) ApplicationsList(true, true) + else ApplicationsFeatures + ) } if(VERSION.SDK_INT >= 24) { HomePageItem(R.string.user_restriction, R.drawable.person_off) { onNavigate(UserRestriction) } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 654ee05..47b1b95 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -25,6 +25,8 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.RestrictionEntry +import android.content.RestrictionsManager import android.content.pm.ApplicationInfo import android.content.pm.PackageInstaller import android.content.pm.PackageManager @@ -39,6 +41,7 @@ import android.net.wifi.WifiManager import android.net.wifi.WifiSsid import android.os.Binder import android.os.Build.VERSION +import android.os.Bundle import android.os.HardwarePropertiesManager import android.os.UserHandle import android.os.UserManager @@ -60,7 +63,9 @@ 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.AppRestriction import com.bintianqi.owndroid.dpm.AppStatus +import com.bintianqi.owndroid.dpm.BasicAppGroup import com.bintianqi.owndroid.dpm.CaCertInfo import com.bintianqi.owndroid.dpm.CreateUserResult import com.bintianqi.owndroid.dpm.CreateWorkProfileOptions @@ -115,6 +120,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.put import java.net.InetAddress import java.security.MessageDigest import java.security.cert.CertificateException @@ -154,10 +163,10 @@ class MyViewModel(application: Application): AndroidViewModel(application) { return AppLockConfig(passwordHash?.ifEmpty { null }, SP.biometricsUnlock, SP.lockWhenLeaving) } fun setAppLockConfig(config: AppLockConfig) { - SP.lockPasswordHash = if (config.password == null) { - "" - } else { - config.password.hash() + if (config.password == null) { + SP.lockPasswordHash = "" + } else if (!config.password.isEmpty()) { + SP.lockPasswordHash = config.password.hash() } SP.biometricsUnlock = config.biometrics SP.lockWhenLeaving = config.whenLeaving @@ -196,6 +205,11 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } } } + fun onPackageRemoved(name: String) { + installedPackages.update { list -> + list.filter { it.name != name } + } + } fun getAppInfo(info: ApplicationInfo) = AppInfo(info.packageName, info.loadLabel(PM).toString(), info.loadIcon(PM), info.flags) fun getAppInfo(name: String): AppInfo { @@ -215,10 +229,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { suspendedPackages.value = packages.map { getAppInfo(it) } } @RequiresApi(24) - fun setPackageSuspended(name: String, status: Boolean): Boolean { - val result = DPM.setPackagesSuspended(DAR, arrayOf(name), status) + fun setPackageSuspended(packages: List, status: Boolean) { + DPM.setPackagesSuspended(DAR, packages.toTypedArray(), status) getSuspendedPackaged() - return result.isEmpty() } val hiddenPackages = MutableStateFlow(emptyList()) @@ -227,10 +240,11 @@ class MyViewModel(application: Application): AndroidViewModel(application) { DPM.isApplicationHidden(DAR, it.packageName) }.map { getAppInfo(it) } } - fun setPackageHidden(name: String, status: Boolean): Boolean { - val result = DPM.setApplicationHidden(DAR, name, status) + fun setPackageHidden(packages: List, status: Boolean) { + for (name in packages) { + DPM.setApplicationHidden(DAR, name, status) + } getHiddenPackages() - return result } // Uninstall blocked packages @@ -240,8 +254,10 @@ class MyViewModel(application: Application): AndroidViewModel(application) { DPM.isUninstallBlocked(DAR, it.packageName) }.map { getAppInfo(it) } } - fun setPackageUb(name: String, status: Boolean) { - DPM.setUninstallBlocked(DAR, name, status) + fun setPackageUb(packages: List, status: Boolean) { + for (name in packages) { + DPM.setUninstallBlocked(DAR, name, status) + } getUbPackages() } @@ -254,16 +270,17 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } } @RequiresApi(30) - fun setPackageUcd(name: String, status: Boolean) { + fun setPackageUcd(packages: List, status: Boolean) { DPM.setUserControlDisabledPackages( DAR, - ucdPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + ucdPackages.value.map { it.name }.run { + if (status) plus(packages) else minus(packages) + } ) getUcdPackages() } val packagePermissions = MutableStateFlow(emptyMap()) - @RequiresApi(23) fun getPackagePermissions(name: String) { if (name.isValidPackageName) { packagePermissions.value = runtimePermissions.associate { @@ -273,7 +290,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) { packagePermissions.value = emptyMap() } } - @RequiresApi(23) fun setPackagePermission(name: String, permission: String, status: Int): Boolean { val result = DPM.setPermissionGrantState(DAR, name, permission, status) getPackagePermissions(name) @@ -287,12 +303,13 @@ class MyViewModel(application: Application): AndroidViewModel(application) { mddPackages.value = DPM.getMeteredDataDisabledPackages(DAR).distinct().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) } + fun setPackageMdd(packages: List, status: Boolean) { + DPM.setMeteredDataDisabledPackages( + DAR, mddPackages.value.map { it.name }.run { + if (status) plus(packages) else minus(packages) + } ) getMddPackages() - return result.isEmpty() } // Keep uninstalled packages @@ -302,9 +319,11 @@ class MyViewModel(application: Application): AndroidViewModel(application) { kuPackages.value = DPM.getKeepUninstalledPackages(DAR)?.distinct()?.map { getAppInfo(it) } ?: emptyList() } @RequiresApi(28) - fun setPackageKu(name: String, status: Boolean) { + fun setPackageKu(packages: List, status: Boolean) { DPM.setKeepUninstalledPackages( - DAR, kuPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + DAR, kuPackages.value.map { it.name }.run { + if (status) plus(packages) else minus(packages) + } ) getKuPackages() } @@ -316,10 +335,12 @@ class MyViewModel(application: Application): AndroidViewModel(application) { cpPackages.value = DPM.getCrossProfilePackages(DAR).map { getAppInfo(it) } } @RequiresApi(30) - fun setPackageCp(name: String, status: Boolean) { + fun setPackageCp(packages: List, status: Boolean) { DPM.setCrossProfilePackages( DAR, - cpPackages.value.map { it.name }.toSet().run { if (status) plus(name) else minus(name) } + cpPackages.value.map { it.name }.toSet().run { + if (status) plus(packages) else minus(packages) + } ) getCpPackages() } @@ -329,14 +350,15 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun getCpwProviders() { cpwProviders.value = DPM.getCrossProfileWidgetProviders(DAR).distinct().map { getAppInfo(it) } } - fun setCpwProvider(name: String, status: Boolean): Boolean { - val result = if (status) { - DPM.addCrossProfileWidgetProvider(DAR, name) - } else { - DPM.removeCrossProfileWidgetProvider(DAR, name) + fun setCpwProvider(packages: List, status: Boolean) { + for (name in packages) { + if (status) { + DPM.addCrossProfileWidgetProvider(DAR, name) + } else { + DPM.removeCrossProfileWidgetProvider(DAR, name) + } } getCpwProviders() - return result } @RequiresApi(28) @@ -347,6 +369,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } fun uninstallPackage(packageName: String, onComplete: (String?) -> Unit) { + val action = "com.bintianqi.owndroid.action.PACKAGE_UNINSTALLED" val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) @@ -355,7 +378,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) } else { context.unregisterReceiver(this) - if(statusExtra == PackageInstaller.STATUS_SUCCESS) { + if (statusExtra == PackageInstaller.STATUS_SUCCESS) { onComplete(null) } else { onComplete(parsePackageInstallerMessage(context, intent)) @@ -364,16 +387,17 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } } ContextCompat.registerReceiver( - application, receiver, IntentFilter(AppInstallerViewModel.ACTION), null, - null, ContextCompat.RECEIVER_EXPORTED + application, receiver, IntentFilter(action), null, + null, ContextCompat.RECEIVER_NOT_EXPORTED ) + val intent = Intent(action).setPackage(application.packageName) val pi = if(VERSION.SDK_INT >= 34) { PendingIntent.getBroadcast( - application, 0, Intent(AppInstallerViewModel.ACTION), + application, 0, intent, PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE ).intentSender } else { - PendingIntent.getBroadcast(application, 0, Intent(AppInstallerViewModel.ACTION), PendingIntent.FLAG_MUTABLE).intentSender + PendingIntent.getBroadcast(application, 0, intent, PendingIntent.FLAG_MUTABLE).intentSender } application.getPackageInstaller().uninstall(packageName, pi) } @@ -392,9 +416,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { policy.policyType } ?: -1 } - fun setCmPackage(name: String, status: Boolean) { - cmPackages.update { list -> - if (status) list + getAppInfo(name) else list.filter { it.name != name } + fun setCmPackage(packages: List, status: Boolean) { + cmPackages.update { + updateAppInfoList(it, packages, status) } } @RequiresApi(34) @@ -405,6 +429,16 @@ class MyViewModel(application: Application): AndroidViewModel(application) { getCmPolicy() } + fun updateAppInfoList( + origin: List, input: List, status: Boolean + ): List { + return if (status) { + origin + input.map { getAppInfo(it) } + } else { + origin.filter { it.name !in input } + } + } + // Permitted input method val pimPackages = MutableStateFlow(emptyList()) fun getPimPackages(): Boolean { @@ -413,9 +447,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { packages == null } } - fun setPimPackage(name: String, status: Boolean) { - pimPackages.update { packages -> - if (status) packages + getAppInfo(name) else packages.filter { it.name != name } + fun setPimPackage(packages: List, status: Boolean) { + pimPackages.update { + updateAppInfoList(it, packages, status) } } fun setPimPolicy(allowAll: Boolean): Boolean { @@ -433,9 +467,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { packages == null } } - fun setPasPackage(name: String, status: Boolean) { - pasPackages.update { packages -> - if (status) packages + getAppInfo(name) else packages.filter { it.name != name } + fun setPasPackage(packages: List, status: Boolean) { + pasPackages.update { + updateAppInfoList(it, packages, status) } } fun setPasPolicy(allowAll: Boolean): Boolean { @@ -457,14 +491,18 @@ class MyViewModel(application: Application): AndroidViewModel(application) { 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 + if (VERSION.SDK_INT >= 28 && Privilege.status.value.device) + 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)) } + try { + DPM.setPackagesSuspended(DAR, arrayOf(name), status) + appStatus.update { it.copy(suspend = DPM.isPackageSuspended(DAR, name)) } + } catch (_: Exception) {} } fun adSetPackageHidden(name: String, status: Boolean) { DPM.setApplicationHidden(DAR, name, status) @@ -510,6 +548,79 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } } + val appRestrictions = MutableStateFlow(emptyList()) + + fun getAppRestrictions(name: String) { + val rm = application.getSystemService(RestrictionsManager::class.java) + try { + val bundle = DPM.getApplicationRestrictions(DAR, name) + appRestrictions.value = rm.getManifestRestrictions(name)?.mapNotNull { + transformRestrictionEntry(it) + }?.map { + if (bundle.containsKey(it.key)) { + when (it) { + is AppRestriction.BooleanItem -> it.value = bundle.getBoolean(it.key) + is AppRestriction.StringItem -> it.value = bundle.getString(it.key) + is AppRestriction.IntItem -> it.value = bundle.getInt(it.key) + is AppRestriction.ChoiceItem -> it.value = bundle.getString(it.key) + is AppRestriction.MultiSelectItem -> it.value = bundle.getStringArray(it.key) + } + } + it + } ?: emptyList() + } catch (e: Exception) { + e.printStackTrace() + appRestrictions.value = emptyList() + } + } + + fun setAppRestrictions(name: String, item: AppRestriction) { + viewModelScope.launch(Dispatchers.IO) { + val bundle = transformAppRestriction( + appRestrictions.value.filter { it.key != item.key }.plus(item) + ) + DPM.setApplicationRestrictions(DAR, name, bundle) + getAppRestrictions(name) + } + } + + fun clearAppRestrictions(name: String) { + viewModelScope.launch(Dispatchers.IO) { + DPM.setApplicationRestrictions(DAR, name, Bundle()) + getAppRestrictions(name) + } + } + + fun transformRestrictionEntry(e: RestrictionEntry): AppRestriction? { + return when (e.type) { + RestrictionEntry.TYPE_INTEGER -> + AppRestriction.IntItem(e.key, e.title, e.description, null) + RestrictionEntry.TYPE_STRING -> + AppRestriction.StringItem(e.key, e.title, e.description, null) + RestrictionEntry.TYPE_BOOLEAN -> + AppRestriction.BooleanItem(e.key, e.title, e.description, null) + RestrictionEntry.TYPE_CHOICE -> AppRestriction.ChoiceItem(e.key, e.title, + e.description, e.choiceEntries, e.choiceValues, null) + RestrictionEntry.TYPE_MULTI_SELECT -> AppRestriction.MultiSelectItem(e.key, e.title, + e.description, e.choiceEntries, e.choiceValues, null) + else -> null + } + } + + fun transformAppRestriction(list: List): Bundle { + val b = Bundle() + for (r in list) { + when (r) { + is AppRestriction.IntItem -> r.value?.let { b.putInt(r.key, it) } + is AppRestriction.StringItem -> r.value?.let { b.putString(r.key, it) } + is AppRestriction.BooleanItem -> r.value?.let { b.putBoolean(r.key, it) } + is AppRestriction.ChoiceItem -> r.value?.let { b.putString(r.key, it) } + is AppRestriction.MultiSelectItem -> r.value?.let { b.putStringArray(r.key, r.value) } + } + } + return b + } + val appGroups = MutableStateFlow(emptyList()) init { getAppGroups() @@ -527,6 +638,20 @@ class MyViewModel(application: Application): AndroidViewModel(application) { group.filter { it.id != id } } } + fun exportAppGroups(uri: Uri) { + application.contentResolver.openOutputStream(uri)!!.use { + val list: List = appGroups.value + it.write(Json.encodeToString(list).encodeToByteArray()) + } + } + fun importAppGroups(uri: Uri) { + application.contentResolver.openInputStream(uri)!!.use { + Json.decodeFromString>(it.readBytes().decodeToString()) + }.forEach { + myRepo.setAppGroup(null, it.name, it.apps) + } + getAppGroups() + } @RequiresApi(24) fun reboot() { @@ -584,16 +709,17 @@ class MyViewModel(application: Application): AndroidViewModel(application) { 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 }) + autoTimeEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) DPM.getAutoTimeEnabled(DAR) else false, - autoTimeZoneEnabled = if (VERSION.SDK_INT >= 30 && privilege.run { device || org }) + autoTimeZoneEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.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) + btContactSharingDisabled = if (privilege.work) DPM.getBluetoothContactSharingDisabled(DAR) else false, - commonCriteriaMode = if (VERSION.SDK_INT >= 30) DPM.isCommonCriteriaModeEnabled(DAR) else false, + commonCriteriaMode = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) + 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 ) @@ -609,7 +735,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) { 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) } @@ -643,7 +768,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) { it.copy(backupServiceEnabled = DPM.isBackupServiceEnabled(DAR)) } } - @RequiresApi(23) fun setBtContactSharingDisabled(disabled: Boolean) { DPM.setBluetoothContactSharingDisabled(DAR, disabled) systemOptionsStatus.update { @@ -662,7 +786,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) { DPM.isUsbDataSignalingEnabled = enabled systemOptionsStatus.update { it.copy(usbSignalEnabled = DPM.isUsbDataSignalingEnabled) } } - @RequiresApi(23) fun setKeyguardDisabled(disabled: Boolean): Boolean { return DPM.setKeyguardDisabled(DAR, disabled) } @@ -732,11 +855,9 @@ class MyViewModel(application: Application): AndroidViewModel(application) { 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) } @@ -784,19 +905,35 @@ class MyViewModel(application: Application): AndroidViewModel(application) { getLockTaskPackages() } @RequiresApi(28) - fun startLockTaskMode(packageName: String, activity: String): Boolean { + fun startLockTaskMode( + packageName: String, activity: String, clearTask: Boolean, showNotification: Boolean + ): Boolean { if (!DPM.isLockTaskPermitted(packageName)) { val list = lockTaskPackages.value.map { it.name } + packageName DPM.setLockTaskPackages(DAR, list.toTypedArray()) getLockTaskPackages() } + if (showNotification) { + DPM.setLockTaskFeatures( + DAR, + DPM.getLockTaskFeatures(DAR) or + DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS or + DevicePolicyManager.LOCK_TASK_FEATURE_HOME + ) + } 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) + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or (if (clearTask) Intent.FLAG_ACTIVITY_CLEAR_TASK else 0) + ) application.startActivity(intent, options.toBundle()) + if (showNotification) { + application.startForegroundService(Intent(application, LockTaskService::class.java)) + } return true } else { return false @@ -906,14 +1043,12 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } } } - @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() @@ -1227,18 +1362,11 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } 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_DEVICE_ADMIN_COMPONENT_NAME, + MyAdminComponent + ) + if (options.migrateAccount) { intent.putExtra( DevicePolicyManager.EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE, Account(options.accountName, options.accountType) @@ -1315,10 +1443,10 @@ class MyViewModel(application: Application): AndroidViewModel(application) { 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, + UM.isSystemUser, 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, + UM.getUserCreationTime(uh), 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, @@ -1354,6 +1482,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { 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_MAX_USERS -> R.string.limit_reached UserManager.USER_OPERATION_ERROR_CURRENT_USER -> R.string.fail_current_user else -> R.string.unknown } @@ -1392,7 +1521,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun setProfileName(name: String) { DPM.setProfileName(DAR, name) } - @RequiresApi(23) fun setUserIcon(bitmap: Bitmap) { DPM.setUserIcon(DAR, bitmap) } @@ -1530,7 +1658,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) { return PM.getPackageUid(name, 0) } var networkStatsData = emptyList() - @RequiresApi(23) fun readNetworkStats(stats: NetworkStats): List { val list = mutableListOf() while (stats.hasNextBucket()) { @@ -1541,7 +1668,6 @@ class MyViewModel(application: Application): AndroidViewModel(application) { stats.close() return list } - @RequiresApi(23) fun readNetworkStatsBucket(bucket: NetworkStats.Bucket): NetworkStatsData { return NetworkStatsData( bucket.rxBytes, bucket.rxPackets, bucket.txBytes, bucket.txPackets, @@ -1823,7 +1949,12 @@ class MyViewModel(application: Application): AndroidViewModel(application) { } @RequiresApi(26) fun setRpToken(token: String): Boolean { - return DPM.setResetPasswordToken(DAR, token.encodeToByteArray()) + return try { + DPM.setResetPasswordToken(DAR, token.encodeToByteArray()) + } catch (e: Exception) { + e.printStackTrace() + false + } } @RequiresApi(26) fun clearRpToken(): Boolean { diff --git a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt index ec7b1f7..4f34429 100644 --- a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt +++ b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt @@ -6,7 +6,9 @@ import android.graphics.drawable.Drawable import android.os.Build import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -22,7 +24,14 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -36,6 +45,7 @@ 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 @@ -45,8 +55,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -66,17 +78,20 @@ data class AppInfo( private fun searchInString(query: String, content: String) = query.split(' ').all { content.contains(it, true) } -@Serializable data class ApplicationsList(val canSwitchView: Boolean) +@Serializable data class ApplicationsList(val canSwitchView: Boolean, val multiSelect: Boolean) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun AppChooserScreen( - canSwitchView: Boolean, packageList: MutableStateFlow>, + params: ApplicationsList, packageList: MutableStateFlow>, refreshProgress: MutableStateFlow, onChoosePackage: (String?) -> Unit, - onSwitchView: () -> Unit, onRefresh: () -> Unit + onSwitchView: () -> Unit, onRefresh: () -> Unit, + setPackagesSuspend: (List, Boolean) -> Unit, + setPackagesHidden: (List, Boolean) -> Unit, ) { val packages by packageList.collectAsStateWithLifecycle() val context = LocalContext.current + val hf = LocalHapticFeedback.current val progress by refreshProgress.collectAsStateWithLifecycle() var system by rememberSaveable { mutableStateOf(false) } var query by rememberSaveable { mutableStateOf("") } @@ -85,6 +100,7 @@ fun AppChooserScreen( system == (it.flags and ApplicationInfo.FLAG_SYSTEM != 0) && (query.isEmpty() || (searchInString(query, it.label) || searchInString(query, it.name))) } + val selectedPackages = remember { mutableStateListOf() } val focusMgr = LocalFocusManager.current LaunchedEffect(Unit) { if(packages.size <= 1) onRefresh() @@ -101,18 +117,86 @@ fun AppChooserScreen( system = !system context.popToast(if(system) R.string.show_system_app else R.string.show_user_app) }) { - Icon(painter = painterResource(R.drawable.filter_alt_fill0), contentDescription = null) + Icon(painterResource(R.drawable.filter_alt_fill0), null) } - IconButton(onRefresh, enabled = progress == 1F) { - Icon(painter = painterResource(R.drawable.refresh_fill0), contentDescription = null) + if (selectedPackages.isEmpty()) { + IconButton(onRefresh, enabled = progress == 1F) { + Icon(Icons.Default.Refresh, null) + } + if (params.canSwitchView) IconButton(onSwitchView) { + Icon(Icons.AutoMirrored.Default.List, null) + } } - if (canSwitchView) IconButton(onSwitchView) { - Icon(Icons.AutoMirrored.Default.List, null) + } + if (selectedPackages.isNotEmpty()) { + if (params.canSwitchView) { + var dropdown by remember { mutableStateOf(false) } + Box { + IconButton({ + dropdown = !dropdown + }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(dropdown, { dropdown = false }) { + if (Build.VERSION.SDK_INT >= 24) { + DropdownMenuItem( + { Text(stringResource(R.string.suspend)) }, + { + setPackagesSuspend(selectedPackages.map { it.name }, true) + dropdown = false + selectedPackages.clear() + }, + leadingIcon = { + Icon(painterResource(R.drawable.block_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.unsuspend)) }, + { + setPackagesSuspend(selectedPackages.map { it.name }, false) + dropdown = false + selectedPackages.clear() + }, + leadingIcon = { + Icon(painterResource(R.drawable.enable_fill0), null) + } + ) + } + DropdownMenuItem( + { Text(stringResource(R.string.hide)) }, + { + setPackagesHidden(selectedPackages.map { it.name }, true) + dropdown = false + selectedPackages.clear() + }, + leadingIcon = { + Icon(painterResource(R.drawable.visibility_off_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.unhide)) }, + { + setPackagesHidden(selectedPackages.map { it.name }, false) + dropdown = false + selectedPackages.clear() + }, + leadingIcon = { + Icon(painterResource(R.drawable.visibility_fill0), null) + } + ) + } + } + } else { + FilledIconButton({ + onChoosePackage(selectedPackages.joinToString("\n") { it.name }) + }) { + Icon(Icons.Default.Check, null) + } } } }, title = { - if(searchMode) { + if (searchMode) { val fr = remember { FocusRequester() } LaunchedEffect(Unit) { fr.requestFocus() } OutlinedTextField( @@ -122,19 +206,20 @@ fun AppChooserScreen( keyboardActions = KeyboardActions { focusMgr.clearFocus() }, placeholder = { Text(stringResource(R.string.search)) }, trailingIcon = { - Icon( - painter = painterResource(R.drawable.close_fill0), - contentDescription = null, - modifier = Modifier.clickable { - focusMgr.clearFocus() - query = "" - searchMode = false - } - ) + IconButton({ + query = "" + searchMode = false + }) { + Icon(Icons.Outlined.Clear, null) + } }, textStyle = typography.bodyLarge, modifier = Modifier.fillMaxWidth().focusRequester(fr) ) + } else { + if (selectedPackages.isNotEmpty()) { + Text(selectedPackages.size.toString()) + } } }, navigationIcon = { @@ -156,10 +241,24 @@ fun AppChooserScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { - focusMgr.clearFocus() - onChoosePackage(it.name) - } + .combinedClickable(onLongClick = { + if (params.multiSelect && it !in selectedPackages) { + selectedPackages += it + hf.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, onClick = { + if (selectedPackages.isEmpty()) { + focusMgr.clearFocus() + onChoosePackage(it.name) + } else { + if (it in selectedPackages) selectedPackages -= it + else selectedPackages += it + } + }) + .background( + if (it in selectedPackages) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.background + ) .padding(horizontal = 8.dp, vertical = 10.dp) .animateItem() ) { diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index b542132..f5cd676 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -1,31 +1,17 @@ package com.bintianqi.owndroid -import android.app.NotificationManager -import android.app.PendingIntent 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.handlePrivilegeChange import com.bintianqi.owndroid.dpm.retrieveNetworkLogs import com.bintianqi.owndroid.dpm.retrieveSecurityLogs 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 receiver = ComponentName(context, this::class.java) - val packages = Privilege.DPM.getLockTaskPackages(receiver) - Privilege.DPM.setLockTaskPackages(receiver, arrayOf()) - Privilege.DPM.setLockTaskPackages(receiver, packages) - } - } - override fun onEnabled(context: Context, intent: Intent) { super.onEnabled(context, intent) Privilege.updateStatus() @@ -56,25 +42,6 @@ class Receiver : DeviceAdminReceiver() { } } - override fun onLockTaskModeEntering(context: Context, intent: Intent, pkg: String) { - super.onLockTaskModeEntering(context, intent, pkg) - 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()) - .build() - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(NotificationType.LockTaskMode.id, notification) - } - - override fun onLockTaskModeExiting(context: Context, intent: Intent) { - super.onLockTaskModeExiting(context, intent) - NotificationUtils.cancel(context, NotificationType.LockTaskMode) - } - override fun onPasswordChanged(context: Context, intent: Intent, userHandle: UserHandle) { super.onPasswordChanged(context, intent, userHandle) sendUserRelatedNotification(context, userHandle, NotificationType.PasswordChanged) diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/Settings.kt index a004147..a005535 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Settings.kt @@ -232,7 +232,7 @@ fun AppLockSettingsScreen( config: AppLockConfig, setConfig: (AppLockConfig) -> Unit, onNavigateUp: () -> Unit ) = MyScaffold(R.string.app_lock, onNavigateUp) { - var password by rememberSaveable { mutableStateOf(config.password ?: "") } + var password by rememberSaveable { mutableStateOf("") } var confirmPassword by rememberSaveable { mutableStateOf("") } var allowBiometrics by rememberSaveable { mutableStateOf(config.biometrics) } var lockWhenLeaving by rememberSaveable { mutableStateOf(config.whenLeaving) } diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index 700658d..c545970 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -1,9 +1,12 @@ package com.bintianqi.owndroid +import android.content.BroadcastReceiver import android.content.ClipData import android.content.ClipboardManager import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageInfo import android.net.Uri import android.os.Build @@ -159,4 +162,20 @@ fun Modifier.clickableTextField(onClick: () -> Unit) = fun adaptiveInsets(): WindowInsets { val navbar = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) return WindowInsets.ime.union(navbar).union(WindowInsets.displayCutout) -} \ No newline at end of file +} + +fun registerPackageRemovedReceiver( + ctx: Context, callback: (String) -> Unit +) { + val br = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + callback(intent.data!!.schemeSpecificPart) + } + } + val filter = IntentFilter() + filter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED) + filter.addDataScheme("package") + ctx.registerReceiver(br, filter) +} + +fun parsePackageNames(input: String) = input.split('\n').filter { it.isNotEmpty() } 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 c4ffd1b..1f501dd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -5,8 +5,11 @@ 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.Intent +import android.net.Uri import android.os.Build.VERSION import android.os.Looper +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -21,14 +24,17 @@ 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState 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.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List @@ -37,10 +43,14 @@ 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.Clear import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -53,7 +63,17 @@ import androidx.compose.material3.LinearProgressIndicator 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.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -65,21 +85,26 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf 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.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color 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.font.FontStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.AppInfo @@ -90,6 +115,7 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.adaptiveInsets +import com.bintianqi.owndroid.parsePackageNames import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem import com.bintianqi.owndroid.ui.FunctionItem @@ -103,7 +129,10 @@ import com.google.accompanist.drawablepainter.rememberDrawablePainter import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState val String.isValidPackageName get() = Regex("""^(?:[a-zA-Z]\w*\.)+[a-zA-Z]\w*$""").matches(this) @@ -111,13 +140,18 @@ val String.isValidPackageName @Composable fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) { Row( - Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp).animateItem(), + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp) + .animateItem(), Arrangement.SpaceBetween, 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) + modifier = Modifier + .padding(start = 12.dp, end = 18.dp) + .size(30.dp) ) Column { Text(info.label) @@ -137,13 +171,16 @@ fun PackageNameTextField( ) { val fm = LocalFocusManager.current OutlinedTextField( - value, onValueChange, Modifier.fillMaxWidth().then(modifier), + value, onValueChange, Modifier + .fillMaxWidth() + .then(modifier), label = { Text(stringResource(R.string.package_name)) }, trailingIcon = { IconButton(onChoosePackage) { Icon(Icons.AutoMirrored.Default.List, null) } }, + isError = value.isNotEmpty() && !value.isValidPackageName, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), keyboardActions = KeyboardActions { fm.clearFocus() } ) @@ -186,9 +223,7 @@ fun ApplicationsFeaturesScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Un if(VERSION.SDK_INT >= 30 && (privilege.device || (VERSION.SDK_INT >= 33 && privilege.profile))) { FunctionItem(R.string.disable_user_control, icon = R.drawable.do_not_touch_fill0) { onNavigate(DisableUserControl) } } - if(VERSION.SDK_INT >= 23) { - FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) { onNavigate(PermissionsManager()) } - } + FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) { onNavigate(PermissionsManager()) } if(VERSION.SDK_INT >= 28) { FunctionItem(R.string.disable_metered_data, icon = R.drawable.money_off_fill0) { onNavigate(DisableMeteredData) } } @@ -248,12 +283,20 @@ fun ApplicationDetailsScreen( 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) } + val appRestrictions by vm.appRestrictions.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + vm.getAppStatus(packageName) + vm.getAppRestrictions(packageName) + } MySmallTitleScaffold(R.string.place_holder, onNavigateUp, 0.dp) { - Column(Modifier.align(Alignment.CenterHorizontally).padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Column(Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { 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) + 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( @@ -285,13 +328,21 @@ fun ApplicationDetailsScreen( state = status.keepUninstalled, onCheckedChange = { vm.adSetPackageKu(packageName, it) } ) + if (appRestrictions.isNotEmpty()) { + FunctionItem(R.string.managed_configuration, icon = R.drawable.description_fill0) { + onNavigate(ManagedConfiguration(packageName)) + } + } 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(BottomPadding)) } if(dialog == 1 && VERSION.SDK_INT >= 28) ClearAppStorageDialog(packageName, vm::clearAppData) { dialog = 0 } - if(dialog == 2) UninstallAppDialog(packageName, vm::uninstallPackage) { dialog = 0 } + if(dialog == 2) UninstallAppDialog(packageName, vm::uninstallPackage) { + dialog = 0 + if (it) onNavigateUp() + } } @Serializable object Suspend @@ -304,7 +355,6 @@ fun ApplicationDetailsScreen( @Serializable data class PermissionsManager(val packageName: String? = null) -@RequiresApi(23) @Composable fun PermissionsManagerScreen( packagePermissions: MutableStateFlow>, getPackagePermissions: (String) -> Unit, @@ -335,7 +385,7 @@ fun PermissionsManagerScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable(packageName.isValidPackageName) { + .clickable { selectedPermission = index } .padding(8.dp) @@ -370,7 +420,7 @@ fun PermissionsManagerScreen( Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(if(selected) colorScheme.primaryContainer else Color.Transparent) + .background(if (selected) colorScheme.primaryContainer else Color.Transparent) .clickable { changeState(status) } .padding(vertical = 16.dp, horizontal = 12.dp), Arrangement.SpaceBetween, Alignment.CenterVertically, @@ -418,8 +468,7 @@ fun ClearAppStorageScreen( Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { dialog = true }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName + Modifier.fillMaxWidth() ) { Text(stringResource(R.string.clear)) } @@ -437,7 +486,8 @@ private fun ClearAppStorageDialog( AlertDialog( title = { Text(stringResource(R.string.clear_app_storage)) }, text = { - if(clearing) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (clearing) LinearProgressIndicator(Modifier.fillMaxWidth()) + else Text(stringResource(R.string.clear_app_storage_confirmation)) }, confirmButton = { TextButton( @@ -482,8 +532,7 @@ fun UninstallAppScreen( Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { dialog = true }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName + Modifier.fillMaxWidth() ) { Text(stringResource(R.string.uninstall)) } @@ -496,7 +545,8 @@ fun UninstallAppScreen( @Composable private fun UninstallAppDialog( - packageName: String, onUninstall: (String, (String?) -> Unit) -> Unit, onClose: () -> Unit + packageName: String, onUninstall: (String, (String?) -> Unit) -> Unit, + onClose: (Boolean) -> Unit ) { var uninstalling by rememberSaveable { mutableStateOf(false) } var errorMessage by rememberSaveable { mutableStateOf(null) } @@ -509,22 +559,27 @@ private fun UninstallAppDialog( confirmButton = { TextButton( { - uninstalling = true - onUninstall(packageName) { - uninstalling = false - if(it == null) onClose() else errorMessage = it + if (errorMessage == null) { + uninstalling = true + onUninstall(packageName) { + uninstalling = false + if (it == null) onClose(true) else errorMessage = it + } + } else { + onClose(false) } }, - enabled = !uninstalling, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) + enabled = !uninstalling ) { Text(stringResource(R.string.confirm)) } }, dismissButton = { - TextButton(onClose, enabled = !uninstalling) { Text(stringResource(R.string.cancel)) } + if (errorMessage == null) TextButton({ + onClose(false) + }, enabled = !uninstalling) { Text(stringResource(R.string.cancel)) } }, - onDismissRequest = onClose, + onDismissRequest = { onClose(false) }, properties = DialogProperties(false, false) ) } @@ -551,8 +606,7 @@ fun InstallExistingAppScreen( { context.showOperationResultToast(onInstall(packageName)) }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName + Modifier.fillMaxWidth() ) { Text(stringResource(R.string.install)) } @@ -571,14 +625,16 @@ fun InstallExistingAppScreen( fun CredentialManagerPolicyScreen( chosenPackage: Channel, onChoosePackage: () -> Unit, cmPackages: MutableStateFlow>, getCmPolicy: () -> Int, - setCmPackage: (String, Boolean) -> Unit, setCmPolicy: (Int) -> Unit, onNavigateUp: () -> Unit + setCmPackage: (List, Boolean) -> Unit, setCmPolicy: (Int) -> Unit, + onNavigateUp: () -> Unit ) { val context = LocalContext.current var policy by rememberSaveable { mutableIntStateOf(getCmPolicy()) } val packages by cmPackages.collectAsStateWithLifecycle() - var packageName by rememberSaveable { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) LaunchedEffect(Unit) { - packageName = chosenPackage.receive() + input = chosenPackage.receive() } MyLazyScaffold(R.string.credential_manager_policy, onNavigateUp) { item { @@ -593,20 +649,19 @@ fun CredentialManagerPolicyScreen( Spacer(Modifier.padding(vertical = 4.dp)) } if (policy != -1) items(packages, { it.name }) { - ApplicationItem(it) { setCmPackage(it.name, false) } + ApplicationItem(it) { setCmPackage(listOf(it.name), false) } } item { Column(Modifier.padding(horizontal = HorizontalPadding)) { if (policy != -1) { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(input, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { input = it } Button( { - setCmPackage(packageName, true) - packageName = "" + setCmPackage(inputPackages, true) + input = "" }, - Modifier.fillMaxWidth(), - enabled = packageName.isValidPackageName + Modifier.fillMaxWidth() ) { Text(stringResource(R.string.add)) } @@ -634,33 +689,36 @@ fun CredentialManagerPolicyScreen( fun PermittedAsAndImPackages( title: Int, note: Int, chosenPackage: Channel, onChoosePackage: () -> Unit, packagesState: MutableStateFlow>, getPackages: () -> Boolean, - setPackage: (String, Boolean) -> Unit, setPolicy: (Boolean) -> Boolean, onNavigateUp: () -> Unit + setPackage: (List, Boolean) -> Unit, setPolicy: (Boolean) -> Boolean, + onNavigateUp: () -> Unit ) { val context = LocalContext.current val packages by packagesState.collectAsStateWithLifecycle() - var packageName by rememberSaveable { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) var allowAll by rememberSaveable { mutableStateOf(getPackages()) } LaunchedEffect(Unit) { - packageName = chosenPackage.receive() + input = chosenPackage.receive() } MyLazyScaffold(title, onNavigateUp) { item { SwitchItem(R.string.allow_all, state = allowAll, onCheckedChange = { allowAll = it }) } if (!allowAll) items(packages, { it.name }) { - ApplicationItem(it) { setPackage(it.name, false) } + ApplicationItem(it) { setPackage(listOf(it.name), false) } } item { if (!allowAll) { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + PackageNameTextField(input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { input = it } Button( { - setPackage(packageName, true) - packageName = "" + setPackage(inputPackages, true) + input = "" }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.add)) } @@ -669,7 +727,10 @@ fun PermittedAsAndImPackages( { context.showOperationResultToast(setPolicy(allowAll)) }, - Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = HorizontalPadding) + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.apply)) } @@ -739,35 +800,26 @@ fun SetDefaultDialerScreen( } } -@Composable -fun PackageFunctionScreenWithoutResult( - title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, - onSet: (String, Boolean) -> Unit, onNavigateUp: () -> Unit, - chosenPackage: Channel, onChoosePackage: () -> Unit, - navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null -) { - PackageFunctionScreen( - title, packagesState, onGet, { name, status -> onSet(name, status); null }, - onNavigateUp, chosenPackage, onChoosePackage, navigateToGroups, appGroups, notes - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun PackageFunctionScreen( title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, - onSet: (String, Boolean) -> Boolean?, onNavigateUp: () -> Unit, + onSet: (List, Boolean) -> Unit, onNavigateUp: () -> Unit, chosenPackage: Channel, onChoosePackage: () -> Unit, navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null ) { + val context = LocalContext.current val groups by appGroups.collectAsStateWithLifecycle() val packages by packagesState.collectAsStateWithLifecycle() - var packageName by rememberSaveable { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) var dialog by remember { mutableStateOf(false) } var selectedGroup by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + val coroutine = rememberCoroutineScope() LaunchedEffect(Unit) { onGet() - packageName = chosenPackage.receive() + input = chosenPackage.receive() } Scaffold( topBar = { @@ -805,26 +857,40 @@ fun PackageFunctionScreen( } } ) + }, + snackbarHost = { + SnackbarHost(snackbar) } ) { paddingValues -> LazyColumn(Modifier.padding(paddingValues)) { items(packages, { it.name }) { ApplicationItem(it) { - onSet(it.name, false) + onSet(listOf(it.name), false) + coroutine.launch { + val result = snackbar.showSnackbar( + context.getString(R.string.package_removed, it.name), + context.getString(R.string.undo), + true, SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + onSet(listOf(it.name), true) + } + } } } item { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + PackageNameTextField(input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { input = it } Button( { - if (onSet(packageName, true) != false) { - packageName = "" - } + onSet(inputPackages, true) + input = "" }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), - packageName.isValidPackageName && - packages.find { it.name == packageName } == null + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .padding(bottom = 10.dp), + packages.none { it.name in inputPackages } ) { Text(stringResource(R.string.add)) } @@ -836,18 +902,16 @@ fun PackageFunctionScreen( if (dialog) AlertDialog( text = { Column { + Text(selectedGroup!!.name, style = typography.titleLarge) + Spacer(Modifier.height(6.dp)) Button({ - selectedGroup!!.apps.forEach { - onSet(it, true) - } + onSet(selectedGroup!!.apps, true) dialog = false }) { Text(stringResource(R.string.add_to_list)) } Button({ - selectedGroup!!.apps.forEach { - onSet(it, false) - } + onSet(selectedGroup!!.apps, false) dialog = false }) { Text(stringResource(R.string.remove_from_list)) @@ -863,22 +927,68 @@ fun PackageFunctionScreen( ) } -class AppGroup(val id: Int, val name: String, val apps: List) +@Serializable +open class BasicAppGroup(open val name: String, open val apps: List) + +class AppGroup( + val id: Int, override val name: String, override val apps: List +) : BasicAppGroup(name, apps) @Serializable object ManageAppGroups @OptIn(ExperimentalMaterial3Api::class) @Composable fun ManageAppGroupsScreen( - appGroups: StateFlow>, + appGroups: StateFlow>, exportData: (Uri) -> Unit, importData: (Uri) -> Unit, navigateToEditScreen: (Int?, String, List) -> Unit, navigateUp: () -> Unit ) { val groups by appGroups.collectAsStateWithLifecycle() + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) exportData(it) + } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { + if (it != null) importData(it) + } Scaffold( topBar = { TopAppBar( { Text(stringResource(R.string.app_group)) }, - navigationIcon = { NavIcon(navigateUp) } + navigationIcon = { NavIcon(navigateUp) }, + actions = { + var dropdown by remember { mutableStateOf(false) } + Box { + IconButton({ + dropdown = true + }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(dropdown, { dropdown = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.export)) }, + { + exportLauncher.launch("owndroid_app_groups") + dropdown = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_export_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.import_str)) }, + { + importLauncher.launch(arrayOf("application/json")) + dropdown = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_open_fill0), null) + } + ) + } + } + + } ) }, floatingActionButton = { @@ -892,9 +1002,12 @@ fun ManageAppGroupsScreen( LazyColumn(Modifier.padding(paddingValues)) { items(groups, { it.id }) { Column( - Modifier.fillMaxWidth().clickable { - navigateToEditScreen(it.id, it.name, it.apps) - }.padding(HorizontalPadding, 8.dp) + Modifier + .fillMaxWidth() + .clickable { + navigateToEditScreen(it.id, it.name, it.apps) + } + .padding(HorizontalPadding, 8.dp) ) { Text(it.name) Text( @@ -919,9 +1032,10 @@ fun EditAppGroupScreen( 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("") } + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) LaunchedEffect(Unit) { - packageName = chosenPackage.receive() + input = chosenPackage.receive() } Scaffold( topBar = { @@ -954,7 +1068,9 @@ fun EditAppGroupScreen( LazyColumn(Modifier.padding(paddingValues)) { item { OutlinedTextField( - name, { name = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + name, { name = it }, Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp), label = { Text(stringResource(R.string.name)) } ) } @@ -964,15 +1080,18 @@ fun EditAppGroupScreen( } } item { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + PackageNameTextField(input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { input = it } Button( { - list += packageName - packageName = "" + list += inputPackages + input = "" }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), - packageName.isValidPackageName && packageName !in list + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .padding(bottom = 10.dp), + inputPackages.all { it !in list } ) { Text(stringResource(R.string.add)) } @@ -981,3 +1100,401 @@ fun EditAppGroupScreen( } } } + +@Serializable class ManagedConfiguration(val packageName: String) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManagedConfigurationScreen( + params: ManagedConfiguration, appRestrictions: StateFlow>, + setRestriction: (String, AppRestriction) -> Unit, clearRestriction: (String) -> Unit, + navigateUp: () -> Unit +) { + val restrictions by appRestrictions.collectAsStateWithLifecycle() + var searchMode by remember { mutableStateOf(false) } + var searchKeyword by remember { mutableStateOf("") } + val displayRestrictions = if (searchKeyword.isEmpty()) { + restrictions + } else { + restrictions.filter { + it.key.contains(searchKeyword, true) || + it.title?.contains(searchKeyword, true) ?: true + } + } + var dialog by remember { mutableStateOf(null) } + var clearRestrictionDialog by remember { mutableStateOf(false) } + Scaffold( + topBar = { + TopAppBar( + { + if (searchMode) { + val fr = remember { FocusRequester() } + LaunchedEffect(Unit) { + fr.requestFocus() + } + OutlinedTextField( + searchKeyword, { searchKeyword = it }, + Modifier + .fillMaxWidth() + .focusRequester(fr), + textStyle = typography.bodyLarge, + placeholder = { Text(stringResource(R.string.search)) }, + trailingIcon = { + IconButton({ + searchKeyword = "" + searchMode = false + }) { + Icon(Icons.Outlined.Clear, null) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } else { + Text(stringResource(R.string.managed_configuration)) + } + }, + navigationIcon = { NavIcon(navigateUp) }, + actions = { + if (!searchMode) { + IconButton({ + searchMode = true + }) { + Icon(Icons.Outlined.Search, null) + } + IconButton({ + clearRestrictionDialog = true + }) { + Icon(Icons.Outlined.Delete, null) + } + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(displayRestrictions, { it.key }) { entry -> + Row( + Modifier + .fillMaxWidth() + .clickable { + dialog = entry + } + .padding(HorizontalPadding, 8.dp) + .animateItem(), + verticalAlignment = Alignment.CenterVertically + ) { + val iconId = when (entry) { + is AppRestriction.IntItem -> R.drawable.number_123_fill0 + is AppRestriction.StringItem -> R.drawable.abc_fill0 + is AppRestriction.BooleanItem -> R.drawable.toggle_off_fill0 + is AppRestriction.ChoiceItem -> R.drawable.radio_button_checked_fill0 + is AppRestriction.MultiSelectItem -> R.drawable.check_box_fill0 + } + Icon(painterResource(iconId), null, Modifier.padding(end = 12.dp)) + Column { + if (entry.title != null) { + Text(entry.title!!, style = typography.labelLarge) + Text(entry.key, style = typography.bodyMedium) + } else { + Text(entry.key, style = typography.labelLarge) + } + val text = when (entry) { + is AppRestriction.IntItem -> entry.value?.toString() + is AppRestriction.StringItem -> entry.value?.take(30) + is AppRestriction.BooleanItem -> entry.value?.toString() + is AppRestriction.ChoiceItem -> entry.value + is AppRestriction.MultiSelectItem -> entry.value?.joinToString(limit = 30) + } + Text( + text ?: "null", Modifier.alpha(0.7F), + fontStyle = if(text == null) FontStyle.Italic else null, + style = typography.bodyMedium + ) + } + } + } + item { + Spacer(Modifier.height(BottomPadding)) + } + } + } + if (dialog != null) Dialog({ + dialog = null + }) { + Surface( + color = AlertDialogDefaults.containerColor, + shape = AlertDialogDefaults.shape, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + ManagedConfigurationDialog(dialog!!) { + if (it != null) { + setRestriction(params.packageName, it) + } + dialog = null + } + } + } + if (clearRestrictionDialog) AlertDialog( + text = { + Text(stringResource(R.string.clear_configurations)) + }, + confirmButton = { + TextButton({ + clearRestriction(params.packageName) + clearRestrictionDialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ + clearRestrictionDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { + clearRestrictionDialog = false + } + ) +} + +@Composable +fun ManagedConfigurationDialog( + restriction: AppRestriction, setRestriction: (AppRestriction?) -> Unit +) { + var specifyValue by remember { mutableStateOf(false) } + var input by remember { mutableStateOf("") } + var inputState by remember { mutableStateOf(false) } + val multiSelectList = remember { + mutableStateListOf( + *(if (restriction is AppRestriction.MultiSelectItem) { + restriction.entryValues.mapIndexed { index, value -> + MultiSelectEntry( + value, restriction.entries.getOrNull(index), + restriction.value?.contains(value) ?: false + ) + }.sortedBy { entry -> + val index = restriction.value?.indexOf(entry.value) + if (index == null || index == -1) Int.MAX_VALUE else index + } + } else emptyList()).toTypedArray() + ) + } + LaunchedEffect(Unit) { + when (restriction) { + is AppRestriction.IntItem -> restriction.value?.let { + input = it.toString() + specifyValue = true + } + is AppRestriction.StringItem -> restriction.value?.let { + input = it + specifyValue = true + } + is AppRestriction.BooleanItem -> restriction.value?.let { + inputState = it + specifyValue = true + } + is AppRestriction.ChoiceItem -> restriction.value?.let { + input = it + specifyValue = true + } + is AppRestriction.MultiSelectItem -> restriction.value?.let { + specifyValue = true + } + } + } + val listState = rememberLazyListState() + val reorderableListState = rememberReorderableLazyListState(listState) { from, to -> + // `-1` because there's an `item` before items + multiSelectList.add(from.index - 1, multiSelectList.removeAt(to.index - 1)) + } + LazyColumn(Modifier.padding(12.dp), listState) { + item { + SelectionContainer { + Column { + restriction.title?.let { + Text(it, style = typography.titleLarge) + } + Text(restriction.key, Modifier.padding(vertical = 4.dp), style = typography.labelLarge) + Spacer(Modifier.height(4.dp)) + restriction.description?.let { + Text(it, Modifier.alpha(0.8F), style = typography.bodyMedium) + } + Spacer(Modifier.height(8.dp)) + } + } + Row( + Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.specify_value)) + Switch(specifyValue, { specifyValue = it }) + } + } + if (specifyValue) when (restriction) { + is AppRestriction.IntItem -> item { + OutlinedTextField( + input, { input = it }, Modifier.fillMaxWidth(), + isError = input.toIntOrNull() == null + ) + } + is AppRestriction.StringItem -> item { + OutlinedTextField( + input, { input = it }, Modifier.fillMaxWidth() + ) + } + is AppRestriction.BooleanItem -> item { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton( + inputState, { inputState = true }, + SegmentedButtonDefaults.itemShape(0, 2) + ) { + Text("true") + } + SegmentedButton( + !inputState, { inputState = false }, + SegmentedButtonDefaults.itemShape(1, 2) + ) { + Text("false") + } + } + } + is AppRestriction.ChoiceItem -> itemsIndexed(restriction.entryValues) { index, value -> + val label = restriction.entries.getOrNull(index) + Row( + Modifier + .fillMaxWidth() + .clickable { + input = value + } + .padding(8.dp, 4.dp) + ) { + RadioButton(input == value, { input = value }) + Spacer(Modifier.width(8.dp)) + if (label == null) { + Text(value) + } else { + Column { + Text(label) + Text(value, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + } + } + is AppRestriction.MultiSelectItem -> itemsIndexed( + multiSelectList, { _, v -> v.value } + ) { index, entry -> + ReorderableItem(reorderableListState, entry.value) { + Row( + Modifier + .fillMaxWidth() + .clickable { + val old = multiSelectList[index] + multiSelectList[index] = old.copy(selected = !old.selected) + } + .padding(8.dp, 4.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Checkbox(entry.selected, null) + Spacer(Modifier.width(8.dp)) + if (entry.title == null) { + Text(entry.value) + } else { + Column { + Text(entry.title) + Text(entry.value, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + } + Icon( + painterResource(R.drawable.drag_indicator_fill0), null, + Modifier.draggableHandle() + ) + } + } + } + } + item { + Row(Modifier + .fillMaxWidth() + .padding(top = 4.dp), Arrangement.End) { + TextButton({ + setRestriction(null) + }, Modifier.padding(end = 4.dp)) { + Text(stringResource(R.string.cancel)) + } + TextButton({ + val newRestriction = when (restriction) { + is AppRestriction.IntItem -> restriction.copy( + value = if (specifyValue) input.toIntOrNull() else null + ) + is AppRestriction.StringItem -> restriction.copy( + value = if (specifyValue) input else null + ) + is AppRestriction.BooleanItem -> restriction.copy( + value = if (specifyValue) inputState else null + ) + is AppRestriction.ChoiceItem -> restriction.copy( + value = if (specifyValue) input else null + ) + is AppRestriction.MultiSelectItem -> restriction.copy( + value = if (specifyValue) + multiSelectList.filter { it.selected } + .map { it.value }.toTypedArray() + else null + ) + } + setRestriction(newRestriction) + }) { + Text(stringResource(R.string.confirm)) + } + } + } + } +} + +sealed class AppRestriction( + open val key: String, open val title: String?, open val description: String? +) { + data class IntItem( + override val key: String, + override val title: String?, + override val description: String?, + var value: Int?, + ) : AppRestriction(key, title, description) + data class StringItem( + override val key: String, + override val title: String?, + override val description: String?, + var value: String? + ) : AppRestriction(key, title, description) + data class BooleanItem( + override val key: String, + override val title: String?, + override val description: String?, + var value: Boolean? + ) : AppRestriction(key, title, description) + data class ChoiceItem( + override val key: String, + override val title: String?, + override val description: String?, + val entries: Array, + val entryValues: Array, + var value: String? + ) : AppRestriction(key, title, description) + data class MultiSelectItem( + override val key: String, + override val title: String?, + override val description: String?, + val entries: Array, + val entryValues: Array, + var value: Array? + ) : AppRestriction(key, title, description) +} + +data class MultiSelectEntry(val value: String, val title: String?, val selected: Boolean) 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 5e1abd2..8723f40 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -152,7 +152,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) + if (!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) } @@ -186,9 +186,9 @@ fun NetworkOptionsScreen( ) { val privilege by Privilege.status.collectAsStateWithLifecycle() 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)) { + var lanEnabled by rememberSaveable { mutableStateOf(getLanEnabled()) } SwitchItem(R.string.lockdown_admin_configured_network, icon = R.drawable.wifi_password_fill0, state = lanEnabled, onCheckedChange = { @@ -937,7 +937,6 @@ enum class NetworkStatsState(val id: Int, val text: Int) { Default(NetworkStats.Bucket.STATE_DEFAULT, R.string.default_str), Foreground(NetworkStats.Bucket.STATE_FOREGROUND, R.string.foreground) } -@RequiresApi(23) 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), @@ -952,7 +951,6 @@ data class QueryNetworkStatsParams( @Serializable object QueryNetworkStats @OptIn(ExperimentalMaterial3Api::class) -@RequiresApi(23) @Composable fun NetworkStatsScreen( chosenPackage: Channel, onChoosePackage: () -> Unit, getUid: (String) -> Int, @@ -1270,7 +1268,6 @@ data class NetworkStatsData( @Serializable object NetworkStatsViewer -@RequiresApi(23) @Composable fun NetworkStatsViewerScreen( data: List, clearData: () -> Unit, onNavigateUp: () -> Unit 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 fea9381..db843e3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -97,7 +97,9 @@ fun PasswordScreen(vm: MyViewModel,onNavigateUp: () -> Unit, onNavigate: (Any) - if(privilege.device) { FunctionItem(R.string.max_time_to_lock, icon = R.drawable.schedule_fill0) { dialog = 1 } FunctionItem(R.string.pwd_expiration_timeout, icon = R.drawable.lock_clock_fill0) { dialog = 3 } - FunctionItem(R.string.max_pwd_fail, icon = R.drawable.no_encryption_fill0) { dialog = 4 } + if (SP.displayDangerousFeatures) { + FunctionItem(R.string.max_pwd_fail, icon = R.drawable.no_encryption_fill0) { dialog = 4 } + } } if(VERSION.SDK_INT >= 26) { FunctionItem(R.string.required_strong_auth_timeout, icon = R.drawable.fingerprint_off_fill0) { dialog = 2 } @@ -333,12 +335,10 @@ fun ResetPasswordScreen(resetPassword: (String, String, Int) -> Boolean, onNavig visualTransformation = PasswordVisualTransformation() ) Spacer(Modifier.padding(vertical = 5.dp)) - if(VERSION.SDK_INT >= 23) { - CheckBoxItem( - R.string.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.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, flags and RESET_PASSWORD_REQUIRE_ENTRY != 0 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 777e865..8f0fd09 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -72,7 +72,6 @@ import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker @@ -109,9 +108,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.adaptiveInsets 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 @@ -172,16 +171,14 @@ fun SystemManagerScreen( FunctionItem(R.string.key_pairs, icon = R.drawable.key_vertical_fill0) { navCtrl.navigate("KeyPairs") }*/ if(VERSION.SDK_INT >= 35 && (privilege.device || (privilege.profile && privilege.affiliated))) FunctionItem(R.string.content_protection_policy, icon = R.drawable.search_fill0) { onNavigate(ContentProtectionPolicy) } - if(VERSION.SDK_INT >= 23) { - FunctionItem(R.string.permission_policy, icon = R.drawable.key_fill0) { onNavigate(PermissionPolicy) } - } + FunctionItem(R.string.permission_policy, icon = R.drawable.key_fill0) { onNavigate(PermissionPolicy) } if(VERSION.SDK_INT >= 34 && privilege.device) { FunctionItem(R.string.mte_policy, icon = R.drawable.memory_fill0) { onNavigate(MtePolicy) } } 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 && !privilege.dhizuku) { + if (VERSION.SDK_INT >= 28 && privilege.device) { 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) } @@ -205,7 +202,7 @@ fun SystemManagerScreen( FunctionItem(R.string.support_messages, icon = R.drawable.chat_fill0) { onNavigate(SupportMessage) } } FunctionItem(R.string.disable_account_management, icon = R.drawable.account_circle_fill0) { onNavigate(DisableAccountManagement) } - if(VERSION.SDK_INT >= 23 && (privilege.device || privilege.org)) { + if (privilege.device || privilege.org) { FunctionItem(R.string.system_update_policy, icon = R.drawable.system_update_fill0) { onNavigate(SetSystemUpdatePolicy) } } if(VERSION.SDK_INT >= 29 && (privilege.device || privilege.org)) { @@ -369,7 +366,7 @@ 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) { + if (VERSION.SDK_INT < 34) { Row( Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), verticalAlignment = Alignment.CenterVertically @@ -414,8 +411,8 @@ fun KeyguardScreen( 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 (privilege.device || + (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated)) { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() @@ -436,7 +433,7 @@ fun KeyguardScreen( Notes(R.string.info_disable_keyguard) Spacer(Modifier.padding(vertical = 12.dp)) } - if(VERSION.SDK_INT >= 23) Text(text = stringResource(R.string.lock_now), style = typography.headlineLarge) + Text(text = stringResource(R.string.lock_now), style = typography.headlineLarge) Spacer(Modifier.padding(vertical = 2.dp)) var evictKey by rememberSaveable { mutableStateOf(false) } Button( @@ -1008,7 +1005,6 @@ fun ContentProtectionPolicyScreen( @Serializable object PermissionPolicy -@RequiresApi(23) @Composable fun PermissionPolicyScreen( getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit @@ -1148,9 +1144,10 @@ fun NearbyStreamingPolicyScreen( @RequiresApi(28) @Composable fun LockTaskModeScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, + chosenPackage: Channel, chooseSinglePackage: () -> Unit, choosePackage: () -> Unit, lockTaskPackages: StateFlow>, getLockTaskPackages: () -> Unit, - setLockTaskPackage: (String, Boolean) -> Unit, startLockTaskMode: (String, String) -> Boolean, + setLockTaskPackage: (String, Boolean) -> Unit, + startLockTaskMode: (String, String, Boolean, Boolean) -> Boolean, getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String?, onNavigateUp: () -> Unit ) { val coroutine = rememberCoroutineScope() @@ -1175,7 +1172,7 @@ fun LockTaskModeScreen( .fillMaxSize() .padding(paddingValues) ) { - TabRow(tabIndex) { + PrimaryTabRow(tabIndex) { Tab( tabIndex == 0, onClick = { coroutine.launch { pagerState.animateScrollToPage(0) } }, text = { Text(stringResource(R.string.start)) } @@ -1191,9 +1188,9 @@ fun LockTaskModeScreen( } HorizontalPager(pagerState, verticalAlignment = Alignment.Top) { page -> if(page == 0) { - StartLockTaskMode(startLockTaskMode, chosenPackage, onChoosePackage) + StartLockTaskMode(startLockTaskMode, chosenPackage, chooseSinglePackage) } else if (page == 1) { - LockTaskPackages(chosenPackage, onChoosePackage, lockTaskPackages, setLockTaskPackage) + LockTaskPackages(chosenPackage, choosePackage, lockTaskPackages, setLockTaskPackage) } else { LockTaskFeatures(getLockTaskFeatures, setLockTaskFeature) } @@ -1205,29 +1202,39 @@ fun LockTaskModeScreen( @RequiresApi(28) @Composable private fun StartLockTaskMode( - startLockTaskMode: (String, String) -> Boolean, + startLockTaskMode: (String, String, Boolean, Boolean) -> Boolean, chosenPackage: Channel, onChoosePackage: () -> Unit ) { val context = LocalContext.current val focusMgr = LocalFocusManager.current + val privilege by Privilege.status.collectAsStateWithLifecycle() var packageName by rememberSaveable { mutableStateOf("") } var activity by rememberSaveable { mutableStateOf("") } var specifyActivity by rememberSaveable { mutableStateOf(false) } + var clearTask by rememberSaveable { mutableStateOf(true) } + var showNotification by rememberSaveable { mutableStateOf(true) } LaunchedEffect(Unit) { packageName = chosenPackage.receive() } Column( Modifier .fillMaxWidth() - .padding(horizontal = HorizontalPadding) .verticalScroll(rememberScrollState()) ) { - Spacer(Modifier.height(5.dp)) - PackageNameTextField(packageName, onChoosePackage) { packageName = it } + PackageNameTextField( + packageName, onChoosePackage, Modifier.padding(HorizontalPadding, 8.dp) + ) { packageName = it } + FullWidthCheckBoxItem( + R.string.lock_task_mode_start_clear_task, clearTask + ) { clearTask = it } + FullWidthCheckBoxItem( + R.string.lock_task_mode_show_notification, showNotification + ) { showNotification = it } Row( Modifier .fillMaxWidth() - .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically + .padding(start = 4.dp, top = 4.dp, end = HorizontalPadding, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { Checkbox(specifyActivity, { specifyActivity = it @@ -1246,16 +1253,17 @@ private fun StartLockTaskMode( Button( modifier = Modifier .fillMaxWidth() - .padding(bottom = 5.dp), + .padding(horizontal = HorizontalPadding), onClick = { - val result = startLockTaskMode(packageName, activity) + val result = startLockTaskMode(packageName, activity, clearTask, showNotification) if (!result) context.showOperationResultToast(false) }, enabled = packageName.isNotBlank() && (!specifyActivity || activity.isNotBlank()) ) { Text(stringResource(R.string.start)) } - Notes(R.string.info_start_lock_task_mode) + Spacer(Modifier.height(5.dp)) + if (!privilege.dhizuku) Notes(R.string.info_start_lock_task_mode) } } @@ -1774,7 +1782,7 @@ fun WipeDataScreen( FullWidthCheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { flag = flag xor WIPE_EXTERNAL_STORAGE } - if(VERSION.SDK_INT >= 22 && privilege.device) FullWidthCheckBoxItem( + if (privilege.device) FullWidthCheckBoxItem( R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0) { flag = flag xor WIPE_RESET_PROTECTION_DATA } @@ -1828,7 +1836,7 @@ fun WipeDataScreen( text = { Text( text = stringResource( - if(VERSION.SDK_INT >= 23 && userManager.isSystemUser) R.string.wipe_data_warning + if (userManager.isSystemUser) R.string.wipe_data_warning else R.string.info_wipe_data_in_managed_user ), color = colorScheme.error @@ -1869,7 +1877,6 @@ data class PendingSystemUpdateInfo(val exists: Boolean, val time: Long, val secu @Serializable object SetSystemUpdatePolicy -@RequiresApi(23) @Composable fun SystemUpdatePolicyScreen( getPolicy: () -> SystemUpdatePolicyInfo, setPolicy: (SystemUpdatePolicyInfo) -> Unit, 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 2177563..18776a3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt @@ -30,12 +30,12 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType 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 @@ -105,25 +105,23 @@ fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> FunctionItem(R.string.create_user, icon = R.drawable.person_add_fill0) { onNavigate(CreateUser) } } FunctionItem(R.string.change_username, icon = R.drawable.edit_fill0) { onNavigate(ChangeUsername) } - if(VERSION.SDK_INT >= 23) { - var changeUserIconDialog by remember { mutableStateOf(false) } - var bitmap: Bitmap? by remember { mutableStateOf(null) } - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { - if(it != null) uriToStream(context, it) { stream -> - bitmap = BitmapFactory.decodeStream(stream) - if(bitmap != null) changeUserIconDialog = true - } + var changeUserIconDialog by remember { mutableStateOf(false) } + var bitmap: Bitmap? by remember { mutableStateOf(null) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + if(it != null) uriToStream(context, it) { stream -> + bitmap = BitmapFactory.decodeStream(stream) + if(bitmap != null) changeUserIconDialog = true } - FunctionItem(R.string.change_user_icon, icon = R.drawable.account_circle_fill0) { - context.popToast(R.string.select_an_image) - launcher.launch("image/*") - } - if (changeUserIconDialog) ChangeUserIconDialog( - bitmap!!, { - vm.setUserIcon(bitmap!!) - changeUserIconDialog = false - }) { changeUserIconDialog = false } } + FunctionItem(R.string.change_user_icon, icon = R.drawable.account_circle_fill0) { + context.popToast(R.string.select_an_image) + launcher.launch("image/*") + } + 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) } } @@ -191,7 +189,7 @@ fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { 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) + 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, formatDate(info.time)) @@ -264,7 +262,7 @@ fun UserOperationScreen( input, { input = it }, Modifier .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryEditable) + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable) .padding(top = 4.dp, bottom = 8.dp), label = { Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number)) @@ -586,7 +584,6 @@ fun UserSessionMessageScreen( } } -@RequiresApi(23) @Composable private fun ChangeUserIconDialog(bitmap: Bitmap, onSet: () -> Unit, onClose: () -> Unit) { AlertDialog( 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 180deed..beb3cb3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt @@ -101,31 +101,29 @@ fun CreateWorkProfileScreen( var migrateAccountName by remember { mutableStateOf("") } var migrateAccountType by remember { mutableStateOf("") } var keepAccount by remember { mutableStateOf(true) } - 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)) { - OutlinedTextField( - value = migrateAccountName, onValueChange = { migrateAccountName = it }, - label = { Text(stringResource(R.string.account_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() }, - 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() - .padding(horizontal = HorizontalPadding) - .focusRequester(fr) - ) - if(VERSION.SDK_INT >= 26) { - FullWidthCheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = it } - } + FullWidthCheckBoxItem(R.string.migrate_account, migrateAccount) { migrateAccount = it } + AnimatedVisibility(migrateAccount) { + val fr = FocusRequester() + Column(modifier = Modifier.padding(start = 10.dp)) { + OutlinedTextField( + value = migrateAccountName, onValueChange = { migrateAccountName = it }, + label = { Text(stringResource(R.string.account_name)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions { fr.requestFocus() }, + 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() + .padding(horizontal = HorizontalPadding) + .focusRequester(fr) + ) + if(VERSION.SDK_INT >= 26) { + FullWidthCheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = it } } } } diff --git a/app/src/main/res/drawable/abc_fill0.xml b/app/src/main/res/drawable/abc_fill0.xml new file mode 100644 index 0000000..c87da90 --- /dev/null +++ b/app/src/main/res/drawable/abc_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/check_box_fill0.xml b/app/src/main/res/drawable/check_box_fill0.xml new file mode 100644 index 0000000..0a6bfb1 --- /dev/null +++ b/app/src/main/res/drawable/check_box_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/drag_indicator_fill0.xml b/app/src/main/res/drawable/drag_indicator_fill0.xml new file mode 100644 index 0000000..3719a6b --- /dev/null +++ b/app/src/main/res/drawable/drag_indicator_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/file_export_fill0.xml b/app/src/main/res/drawable/file_export_fill0.xml new file mode 100644 index 0000000..a2e4e8b --- /dev/null +++ b/app/src/main/res/drawable/file_export_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/file_open_fill0.xml b/app/src/main/res/drawable/file_open_fill0.xml new file mode 100644 index 0000000..64664a0 --- /dev/null +++ b/app/src/main/res/drawable/file_open_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/number_123_fill0.xml b/app/src/main/res/drawable/number_123_fill0.xml new file mode 100644 index 0000000..29ff3ae --- /dev/null +++ b/app/src/main/res/drawable/number_123_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio_button_checked_fill0.xml b/app/src/main/res/drawable/radio_button_checked_fill0.xml new file mode 100644 index 0000000..3f7a1bc --- /dev/null +++ b/app/src/main/res/drawable/radio_button_checked_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/toggle_off_fill0.xml b/app/src/main/res/drawable/toggle_off_fill0.xml new file mode 100644 index 0000000..9179260 --- /dev/null +++ b/app/src/main/res/drawable/toggle_off_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/visibility_fill0.xml b/app/src/main/res/drawable/visibility_fill0.xml new file mode 100644 index 0000000..62b8445 --- /dev/null +++ b/app/src/main/res/drawable/visibility_fill0.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 258abcd..ea24a39 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -278,7 +278,6 @@ Сетевой журнал Удалить журналы Экспортировать журналы - Пара ключей Wi-Fi Предпочтительная сетевая служба Add config Идентификатор сети diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 33d5f12..9195b73 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -307,7 +307,6 @@ Ağ Kayıtları Kayıtları Sil Kayıtları Dışa Aktar - Wi-Fi Kimlik Doğrulama Anahtar Çifti Tercihli Ağ Servisi Yapılandırma Ekle Ağ Kimliği diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 18c10cc..08879fb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -68,6 +68,7 @@ 超时 继续 退出 + 撤销 Profile owner @@ -173,6 +174,8 @@ 附近通知传输 在足够安全时启用 锁定任务模式 + 清除任务(新实例) + 显示通知以退出 应用未被允许 禁用全部 允许状态栏信息 @@ -293,7 +296,6 @@ 网络日志已收集 删除日志 导出日志 - Wi-Fi密钥对 首选网络服务 添加配置 网络ID @@ -343,7 +345,9 @@ 显示用户应用 显示系统应用 挂起 + 取消挂起 隐藏 + 取消隐藏 VPN保持打开 启用锁定 清除当前配置 @@ -362,6 +366,7 @@ 许可的输入法 卸载后保留的应用 清除应用存储 + 你确定要删除应用数据吗? 设置默认拨号器 卸载应用 安装应用 @@ -371,9 +376,14 @@ 搜索 应用组 管理组 + 导入 编辑组 添加到列表 从列表中移除 + 托管配置 + 清除配置 + 指定值 + 移除了 %1$s 用户限制 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3baaaf..e8ff66c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,7 @@ Timeout Continue Exit + Undo Profile owner @@ -102,7 +103,7 @@ Organization name Disable account management Account type - Transfer Ownership + Transfer ownership Lock screen info Support Messages Short message @@ -201,6 +202,8 @@ Nearby notification streaming policy Same managed account only Lock task mode + Clear task (start fresh) + Show a notification to exit App is not allowed Disable all @@ -327,7 +330,6 @@ Network logs collected Delete logs Export logs - Wi-Fi keypair Preferential network service Add config Network ID @@ -377,7 +379,9 @@ Show user apps Show system apps Suspend + Unsuspend Hide + Unhide Always-on VPN Enable lockdown Clear current config @@ -397,6 +401,7 @@ Permitted IME Keep uninstalled packages Clear app storage + Are you sure to delete app data? Set default dialer Uninstall app Install app @@ -405,9 +410,14 @@ Search App group Manage groups + Import Edit group Add to list Remove from list + Managed configuration + Clear configurations + Specify value + Removed %1$s User restriction diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a68b36..14feb9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] -agp = "8.13.1" -kotlin = "2.2.21" +agp = "8.13.2" +kotlin = "2.3.0" navigation-compose = "2.9.6" -composeBom = "2025.11.00" +composeBom = "2025.12.01" accompanist-drawablepainter = "0.37.3" accompanist-permissions = "0.37.3" shizuku = "13.1.5" @@ -12,6 +12,7 @@ dhizuku = "2.5.4" dhizuku-server = "0.0.10" hiddenApiBypass = "6.1" libsu = "6.0.0" +reoderable = "3.0.0" serialization = "1.9.0" [libraries] @@ -33,6 +34,7 @@ dhizuku-api = { module = "io.github.iamr0s:Dhizuku-API", version.ref = "dhizuku" dhizuku-server-api = { group = "io.github.iamr0s", name = "Dhizuku-SERVER_API", version.ref = "dhizuku-server" } hiddenApiBypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenApiBypass" } libsu = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" } +reoderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reoderable" } serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } @@ -40,4 +42,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.21" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.3.0" }