diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 00c075e..7c1761d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -89,6 +89,7 @@ gradle.taskGraph.whenReady {
dependencies {
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.nav3.runtime)
implementation(libs.androidx.nav3.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 48b55e8..b19edd8 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -24,5 +24,3 @@
-dontwarn android.app.ActivityThread
-dontwarn android.app.ContextImpl
-dontwarn android.app.LoadedApk
-
--keep class com.bintianqi.owndroid.MyViewModel { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e328d50..e0e9e7d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,72 +1,79 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:windowSoftInputMode="adjustResize|stateHidden">
-
-
-
-
+
+
+
+
+
-
+ android:name=".activity.ManageSpaceActivity"
+ android:theme="@style/Theme.Transparent">
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:exported="false"
+ android:permission="android.permission.BIND_DEVICE_ADMIN">
+ android:resource="@xml/device_admin" />
-
-
-
-
-
+
+
+
+
+
-
+ android:exported="true">
-
+ android:exported="true">
diff --git a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt
index eca67d7..a224a5b 100644
--- a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt
@@ -5,70 +5,95 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
+import com.bintianqi.owndroid.utils.hash
-class ApiReceiver: BroadcastReceiver() {
+class ApiReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val requestKey = intent.getStringExtra("key")
var log = "OwnDroid API request received. action: ${intent.action}"
- val key = SP.apiKeyHash
- if(!key.isNullOrEmpty() && key == requestKey?.hash()) {
+ val myApp = context.applicationContext as MyApplication
+ val key = myApp.container.settingsRepo.data.apiKeyHash
+ if (key.isNotEmpty() && key == requestKey?.hash()) {
val app = intent.getStringExtra("package")
val permission = intent.getStringExtra("permission")
val restriction = intent.getStringExtra("restriction")
if (!app.isNullOrEmpty()) log += "\npackage: $app"
if (!permission.isNullOrEmpty()) log += "\npermission: $permission"
try {
- @SuppressWarnings("NewApi")
- when(intent.action?.removePrefix("com.bintianqi.owndroid.action.")) {
- "HIDE" -> Privilege.DPM.setApplicationHidden(Privilege.DAR, app, true)
- "UNHIDE" -> Privilege.DPM.setApplicationHidden(Privilege.DAR, app, false)
- "SUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), true)
- "UNSUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), false)
- "ADD_USER_RESTRICTION" -> { Privilege.DPM.addUserRestriction(Privilege.DAR, restriction) }
- "CLEAR_USER_RESTRICTION" -> { Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction) }
- "SET_PERMISSION_DEFAULT" -> {
- Privilege.DPM.setPermissionGrantState(
- Privilege.DAR, app!!, permission!!,
- DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT
- )
- }
- "SET_PERMISSION_GRANTED" -> {
- Privilege.DPM.setPermissionGrantState(
- Privilege.DAR, app!!, permission!!,
- DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED
- )
- }
- "SET_PERMISSION_DENIED" -> {
- Privilege.DPM.setPermissionGrantState(
- Privilege.DAR, app!!, permission!!,
- DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED
- )
- }
- "LOCK" -> { Privilege.DPM.lockNow() }
- "REBOOT" -> { Privilege.DPM.reboot(Privilege.DAR) }
- "SET_CAMERA_DISABLED" -> {
- Privilege.DPM.setCameraDisabled(Privilege.DAR, true)
- }
- "SET_CAMERA_ENABLED" -> {
- Privilege.DPM.setCameraDisabled(Privilege.DAR, false)
- }
- "SET_USB_DISABLED" -> {
- Privilege.DPM.isUsbDataSignalingEnabled = false
- }
- "SET_USB_ENABLED" -> {
- Privilege.DPM.isUsbDataSignalingEnabled = true
- }
- "SET_SCREEN_CAPTURE_DISABLED" -> {
- Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, true)
- }
- "SET_SCREEN_CAPTURE_ENABLED" -> {
- Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, false)
- }
- else -> {
- log += "\nInvalid action"
+ myApp.container.privilegeHelper.safeDpmCall {
+ @SuppressWarnings("NewApi")
+ when (intent.action?.removePrefix("com.bintianqi.owndroid.action.")) {
+ "HIDE" -> dpm.setApplicationHidden(dar, app, true)
+ "UNHIDE" -> dpm.setApplicationHidden(dar, app, false)
+ "SUSPEND" -> dpm.setPackagesSuspended(dar, arrayOf(app), true)
+ "UNSUSPEND" -> dpm.setPackagesSuspended(dar, arrayOf(app), false)
+ "ADD_USER_RESTRICTION" -> {
+ dpm.addUserRestriction(dar, restriction)
+ }
+
+ "CLEAR_USER_RESTRICTION" -> {
+ dpm.clearUserRestriction(dar, restriction)
+ }
+
+ "SET_PERMISSION_DEFAULT" -> {
+ dpm.setPermissionGrantState(
+ dar, app!!, permission!!,
+ DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT
+ )
+ }
+
+ "SET_PERMISSION_GRANTED" -> {
+ dpm.setPermissionGrantState(
+ dar, app!!, permission!!,
+ DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED
+ )
+ }
+
+ "SET_PERMISSION_DENIED" -> {
+ dpm.setPermissionGrantState(
+ dar, app!!, permission!!,
+ DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED
+ )
+ }
+
+ "LOCK" -> {
+ dpm.lockNow()
+ }
+
+ "REBOOT" -> {
+ dpm.reboot(dar)
+ }
+
+ "SET_CAMERA_DISABLED" -> {
+ dpm.setCameraDisabled(dar, true)
+ }
+
+ "SET_CAMERA_ENABLED" -> {
+ dpm.setCameraDisabled(dar, false)
+ }
+
+ "SET_USB_DISABLED" -> {
+ dpm.isUsbDataSignalingEnabled = false
+ }
+
+ "SET_USB_ENABLED" -> {
+ dpm.isUsbDataSignalingEnabled = true
+ }
+
+ "SET_SCREEN_CAPTURE_DISABLED" -> {
+ dpm.setScreenCaptureDisabled(dar, true)
+ }
+
+ "SET_SCREEN_CAPTURE_ENABLED" -> {
+ dpm.setScreenCaptureDisabled(dar, false)
+ }
+
+ else -> {
+ log += "\nInvalid action"
+ }
}
}
- } catch(e: Exception) {
+ } catch (e: Exception) {
e.printStackTrace()
val message = (e::class.qualifiedName ?: "Exception") + ": " + (e.message ?: "")
log += "\n$message"
@@ -78,6 +103,7 @@ class ApiReceiver: BroadcastReceiver() {
}
Log.d(TAG, log)
}
+
companion object {
private const val TAG = "API"
}
diff --git a/app/src/main/java/com/bintianqi/owndroid/AppContainer.kt b/app/src/main/java/com/bintianqi/owndroid/AppContainer.kt
new file mode 100644
index 0000000..23da9ce
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/AppContainer.kt
@@ -0,0 +1,38 @@
+package com.bintianqi.owndroid
+
+import com.bintianqi.owndroid.feature.applications.AppGroupRepository
+import com.bintianqi.owndroid.feature.network.NetworkLoggingRepository
+import com.bintianqi.owndroid.feature.privilege.DhizukuServerRepository
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
+import com.bintianqi.owndroid.feature.system.SecurityLoggingRepository
+import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterRepository
+import com.bintianqi.owndroid.utils.DhizukuError
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class AppContainer(val app: MyApplication) {
+ val dbHelper = MyDbHelper(app)
+ val networkLoggingRepo = NetworkLoggingRepository(dbHelper)
+ val securityLoggingRepo = SecurityLoggingRepository(dbHelper)
+ val appGroupRepo = AppGroupRepository(dbHelper)
+ val dhizukuServerRepo = DhizukuServerRepository(dbHelper)
+ val cpifRepo = CrossProfileIntentFilterRepository(dbHelper)
+ val settingsRepo = SettingsRepository(app.filesDir.resolve("settings.json"))
+ val dhizukuErrorState = MutableStateFlow(null)
+ val privilegeHelper = PrivilegeHelper(
+ app, settingsRepo.data.privilege.dhizuku, dhizukuErrorState
+ )
+ val privilegeState = MutableStateFlow(PrivilegeStatus())
+ val appGroupsState = MutableStateFlow(appGroupRepo.getAppGroups())
+ val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST)
+ val themeState = MutableStateFlow(settingsRepo.data.theme)
+ val toastChannel = ToastChannel(app)
+ val viewModelFactory = MyViewModelFactory(
+ app, privilegeHelper, settingsRepo, networkLoggingRepo, dhizukuServerRepo,
+ securityLoggingRepo, appGroupRepo, cpifRepo, appGroupsState, dhizukuErrorState,
+ privilegeState, themeState, toastChannel
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt
index 7fbb400..8234826 100644
--- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt
@@ -7,7 +7,8 @@ import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.fragment.app.FragmentActivity
-import com.bintianqi.owndroid.ui.AppInstaller
+import com.bintianqi.owndroid.feature.applications.AppInstaller
+import com.bintianqi.owndroid.feature.applications.AppInstallerViewModel
import com.bintianqi.owndroid.ui.theme.OwnDroidTheme
class AppInstallerActivity:FragmentActivity() {
@@ -16,14 +17,11 @@ class AppInstallerActivity:FragmentActivity() {
super.onCreate(savedInstanceState)
val vm by viewModels()
vm.initialize(intent)
- vm.registerInstallerReceiver(this)
- val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme)
+ val themeState = (application as MyApplication).container.themeState
setContent {
+ val theme by themeState.collectAsState()
OwnDroidTheme(theme) {
- val uiState by vm.uiState.collectAsState()
- AppInstaller(
- uiState, vm::onPackagesAdd, vm::onPackageRemove, vm::startInstall, vm::closeResultDialog
- )
+ AppInstaller(vm)
}
}
}
diff --git a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt
index 51bce41..0701c4c 100644
--- a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt
@@ -24,7 +24,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.feature.privilege.DhizukuClientInfo
+import com.bintianqi.owndroid.ui.screen.AppLockDialog
import com.bintianqi.owndroid.ui.theme.OwnDroidTheme
+import com.bintianqi.owndroid.utils.MyAdminComponent
+import com.bintianqi.owndroid.utils.getPackageSignature
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.rosan.dhizuku.aidl.IDhizukuClient
import com.rosan.dhizuku.aidl.IDhizukuRequestPermissionListener
@@ -32,21 +36,23 @@ import com.rosan.dhizuku.server_api.DhizukuProvider
import com.rosan.dhizuku.server_api.DhizukuService
import com.rosan.dhizuku.shared.DhizukuVariables
import kotlinx.coroutines.delay
-import kotlinx.serialization.Serializable
private const val TAG = "DhizukuServer"
-class MyDhizukuProvider(): DhizukuProvider() {
+class MyDhizukuProvider : DhizukuProvider() {
override fun onCreateService(client: IDhizukuClient): DhizukuService? {
Log.d(TAG, "Creating MyDhizukuService")
- return if (SP.dhizukuServer) MyDhizukuService(context!!, MyAdminComponent, client) else null
+ val settingsRepo = (context!!.applicationContext as MyApplication).container.settingsRepo
+ return if (settingsRepo.data.privilege.dhizukuServer)
+ MyDhizukuService(context!!, MyAdminComponent, client) else null
}
}
class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuClient) :
DhizukuService(context, admin, client) {
override fun checkCallingPermission(func: String?, callingUid: Int, callingPid: Int): Boolean {
- if (!SP.dhizukuServer) return false
+ val settingsRepo = (mContext.applicationContext as MyApplication).container.settingsRepo
+ if (!settingsRepo.data.privilege.dhizukuServer) return false
val pm = mContext.packageManager
val packageInfo = pm.getPackageInfo(
pm.getNameForUid(callingUid) ?: return false,
@@ -59,11 +65,14 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC
"get_delegated_scopes", "set_delegated_scopes" -> "delegated_scopes"
else -> "other"
}
- val hasPermission = (mContext.applicationContext as MyApplication).myRepo
- .checkDhizukuClientPermission(
- callingUid, signature, requiredPermission
+ val hasPermission = (mContext.applicationContext as MyApplication)
+ .container.dhizukuServerRepo.checkDhizukuClientPermission(
+ callingUid, signature, requiredPermission
+ )
+ Log.d(
+ TAG,
+ "UID $callingUid, PID $callingPid, required permission: $requiredPermission, has permission: $hasPermission"
)
- Log.d(TAG, "UID $callingUid, PID $callingPid, required permission: $requiredPermission, has permission: $hasPermission")
return hasPermission
}
@@ -71,17 +80,20 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC
}
class DhizukuActivity : ComponentActivity() {
+ val settingsRepo = (application as MyApplication).container.settingsRepo
+
@OptIn(ExperimentalStdlibApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- if (!SP.dhizukuServer) {
+ if (!settingsRepo.data.privilege.dhizukuServer) {
finish()
return
}
val bundle = intent.extras ?: return
val uid = bundle.getInt(DhizukuVariables.PARAM_CLIENT_UID, -1)
if (uid == -1) return
- val binder = bundle.getBinder(DhizukuVariables.PARAM_CLIENT_REQUEST_PERMISSION_BINDER) ?: return
+ val binder =
+ bundle.getBinder(DhizukuVariables.PARAM_CLIENT_REQUEST_PERMISSION_BINDER) ?: return
val listener = IDhizukuRequestPermissionListener.Stub.asInterface(binder)
val packageName = packageManager.getPackagesForUid(uid)?.first() ?: return
val packageInfo = packageManager.getPackageInfo(
@@ -93,19 +105,19 @@ class DhizukuActivity : ComponentActivity() {
val label = appInfo.loadLabel(packageManager).toString()
fun close(grantPermission: Boolean) {
val clientInfo = DhizukuClientInfo(
- uid, getPackageSignature(packageInfo), if (grantPermission) DhizukuPermissions else emptyList()
+ uid, getPackageSignature(packageInfo),
+ if (grantPermission) DhizukuPermissions else emptyList()
)
- (application as MyApplication).myRepo.setDhizukuClient(clientInfo)
+ (application as MyApplication).container.dhizukuServerRepo.setDhizukuClient(clientInfo)
finish()
listener.onRequestPermission(
if (grantPermission) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED
)
}
enableEdgeToEdge()
- val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme)
setContent {
var appLockDialog by rememberSaveable { mutableStateOf(false) }
- OwnDroidTheme(theme) {
+ OwnDroidTheme(settingsRepo.data.theme) {
if (!appLockDialog) AlertDialog(
icon = {
Image(rememberDrawablePainter(icon), null, Modifier.size(35.dp))
@@ -125,7 +137,7 @@ class DhizukuActivity : ComponentActivity() {
}
}
TextButton({
- if (SP.lockPasswordHash.isNullOrEmpty()) {
+ if (settingsRepo.data.appLock.passwordHash.isEmpty()) {
close(true)
} else {
appLockDialog = true
@@ -144,17 +156,11 @@ class DhizukuActivity : ComponentActivity() {
},
onDismissRequest = { close(false) }
)
- else AppLockDialog({ close(true) }) { close(false) }
+ else AppLockDialog(settingsRepo.data.appLock, { close(true) }) { close(false) }
}
}
}
}
-val DhizukuPermissions = listOf("remote_transact", "remote_process", "user_service", "delegated_scopes", "other")
-
-@Serializable
-data class DhizukuClientInfo(
- val uid: Int,
- val signature: String?,
- val permissions: List = emptyList()
-)
\ No newline at end of file
+val DhizukuPermissions =
+ listOf("remote_transact", "remote_process", "user_service", "delegated_scopes", "other")
diff --git a/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt b/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt
index c9cec0c..7eddb49 100644
--- a/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt
@@ -11,6 +11,8 @@ import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
+import com.bintianqi.owndroid.utils.MyNotificationChannel
+import com.bintianqi.owndroid.utils.NotificationType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@@ -65,14 +67,17 @@ class LockTaskService: Service() {
}
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)
+ val ph = (application as MyApplication).container.privilegeHelper
+ ph.safeDpmCall {
+ val features = dpm.getLockTaskFeatures(dar)
+ val packages = dpm.getLockTaskPackages(dar)
+ dpm.setLockTaskPackages(dar, arrayOf())
+ dpm.setLockTaskPackages(dar, packages)
+ dpm.setLockTaskFeatures(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 f0a6064..c890352 100644
--- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt
@@ -31,46 +31,92 @@ import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
-import com.bintianqi.owndroid.dpm.dhizukuErrorStatus
+import com.bintianqi.owndroid.feature.applications.AppChooserViewModel
import com.bintianqi.owndroid.ui.NavTransition
import com.bintianqi.owndroid.ui.navigation.Destination
import com.bintianqi.owndroid.ui.navigation.myEntryProvider
+import com.bintianqi.owndroid.ui.navigation.rememberSharedViewModelStoreNavEntryDecorator
+import com.bintianqi.owndroid.ui.screen.AppLockDialog
import com.bintianqi.owndroid.ui.theme.OwnDroidTheme
-import java.util.Locale
+import com.bintianqi.owndroid.utils.DhizukuError
+import com.bintianqi.owndroid.utils.popToast
+import com.bintianqi.owndroid.utils.registerPackageRemovedReceiver
+import com.bintianqi.owndroid.utils.viewModelFactory
+import kotlinx.coroutines.launch
@ExperimentalMaterial3Api
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
- val context = applicationContext
- val locale = context.resources?.configuration?.locale
- zhCN = locale == Locale.SIMPLIFIED_CHINESE || locale == Locale.CHINESE || locale == Locale.CHINA
- val vm by viewModels()
+ val context = this
+ val myApp = (application as MyApplication)
+ val settingsRepo = myApp.container.settingsRepo
if (
VERSION.SDK_INT >= 33 &&
- checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ checkSelfPermission(
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
) {
val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
+ val appChooserVm: AppChooserViewModel by viewModels(
+ factoryProducer = {
+ viewModelFactory { AppChooserViewModel(myApp) }
+ }
+ )
registerPackageRemovedReceiver(this) {
- vm.onPackageRemoved(it)
+ appChooserVm.onPackageRemoved(it)
+ }
+ if (
+ myApp.container.privilegeState.value.work &&
+ !settingsRepo.data.privilege.managedProfileActivated
+ ) {
+ myApp.container.privilegeHelper.dpm.setProfileEnabled(
+ myApp.container.privilegeHelper.dar
+ )
+ settingsRepo.update {
+ it.privilege.managedProfileActivated = true
+ }
+ context.popToast(R.string.work_profile_activated)
+ }
+ lifecycleScope.launch {
+ while (true) {
+ val text = myApp.container.toastChannel.channel.receive()
+ context.popToast(text)
+ }
}
setContent {
+ val dhizukuError by myApp.container.dhizukuErrorState.collectAsState()
var appLockDialog by rememberSaveable { mutableStateOf(false) }
- val theme by vm.theme.collectAsStateWithLifecycle()
+ val theme by myApp.container.themeState.collectAsState()
OwnDroidTheme(theme) {
- Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ )
val backstack = rememberNavBackStack(Destination.Home)
+ LaunchedEffect(Unit) {
+ if (!myApp.container.privilegeState.value.activated) {
+ backstack.add(Destination.WorkingModes(false))
+ backstack.removeFirstOrNull()
+ }
+ }
NavDisplay(
backstack,
onBack = {
backstack.removeLastOrNull()
},
+ entryDecorators = listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberSharedViewModelStoreNavEntryDecorator()
+ ),
transitionSpec = {
NavTransition.transition
},
@@ -81,17 +127,21 @@ class MainActivity : FragmentActivity() {
NavTransition.popTransition
}
) {
- myEntryProvider(it as Destination, backstack, vm)
+ myEntryProvider(it as Destination, backstack, appChooserVm, myApp.container)
}
val lifecycleOwner = LocalLifecycleOwner.current
if (appLockDialog) {
- AppLockDialog({ appLockDialog = false }) { moveTaskToBack(true) }
+ AppLockDialog(
+ myApp.container.settingsRepo.data.appLock, { appLockDialog = false }
+ ) { moveTaskToBack(true) }
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (
- (event == Lifecycle.Event.ON_CREATE && !SP.lockPasswordHash.isNullOrEmpty()) ||
- (event == Lifecycle.Event.ON_RESUME && SP.lockWhenLeaving)
+ settingsRepo.data.appLock.passwordHash.isNotEmpty() &&
+ (event == Lifecycle.Event.ON_CREATE ||
+ (event == Lifecycle.Event.ON_RESUME &&
+ settingsRepo.data.appLock.lockWhenLeaving))
) {
appLockDialog = true
}
@@ -101,21 +151,12 @@ class MainActivity : FragmentActivity() {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
- LaunchedEffect(Unit) {
- val profileNotActivated = !SP.managedProfileActivated && Privilege.status.value.work
- if(profileNotActivated) {
- Privilege.DPM.setProfileEnabled(Privilege.DAR)
- SP.managedProfileActivated = true
- context.popToast(R.string.work_profile_activated)
- }
- }
- DhizukuErrorDialog {
- dhizukuErrorStatus.value = 0
- Privilege.updateStatus()
- backstack += Destination.WorkingModes(false)
+ if (dhizukuError != null) DhizukuErrorDialog(dhizukuError!!) {
+ myApp.container.dhizukuErrorState.value = null
+ /*backstack += Destination.WorkingModes(false)
repeat(backstack.size - 1) {
backstack.removeFirstOrNull()
- }
+ }*/
}
}
}
@@ -123,31 +164,25 @@ class MainActivity : FragmentActivity() {
}
@Composable
-private fun DhizukuErrorDialog(onClose: () -> Unit) {
- val status by dhizukuErrorStatus.collectAsState()
- if (status != 0) {
- LaunchedEffect(Unit) {
- SP.dhizuku = false
- }
- AlertDialog(
- onDismissRequest = {},
- confirmButton = {
- TextButton(onClose) {
- Text(stringResource(R.string.confirm))
+private fun DhizukuErrorDialog(error: DhizukuError, onClose: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ TextButton(onClose) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ title = { Text(stringResource(R.string.dhizuku)) },
+ text = {
+ val text = stringResource(
+ when (error) {
+ DhizukuError.Init -> R.string.failed_to_init_dhizuku
+ DhizukuError.Permission -> R.string.dhizuku_permission_not_granted
+ else -> R.string.failed_to_init_dhizuku
}
- },
- title = { Text(stringResource(R.string.dhizuku)) },
- text = {
- val text = stringResource(
- when(status){
- 1 -> R.string.failed_to_init_dhizuku
- 2 -> R.string.dhizuku_permission_not_granted
- else -> R.string.failed_to_init_dhizuku
- }
- )
- Text(text)
- },
- properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
- )
- }
+ )
+ Text(text)
+ },
+ properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
+ )
}
diff --git a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt
index d6105c3..dea9196 100644
--- a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt
@@ -2,20 +2,18 @@ package com.bintianqi.owndroid
import android.app.Application
import android.os.Build.VERSION
+import com.bintianqi.owndroid.utils.NotificationUtils
+import com.bintianqi.owndroid.utils.getPrivilegeStatus
import org.lsposed.hiddenapibypass.HiddenApiBypass
class MyApplication : Application() {
- lateinit var myRepo: MyRepository
+ lateinit var container: AppContainer
+
override fun onCreate() {
super.onCreate()
if (VERSION.SDK_INT >= 28) HiddenApiBypass.setHiddenApiExemptions("")
- SP = SharedPrefs(applicationContext)
- val dbHelper = MyDbHelper(this)
- myRepo = MyRepository(dbHelper)
- Privilege.initialize(applicationContext)
+ container = AppContainer(this)
+ container.privilegeState.value = getPrivilegeStatus(container.privilegeHelper)
NotificationUtils.createChannels(this)
}
}
-
-lateinit var SP: SharedPrefs
- private set
diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt
deleted file mode 100644
index 3d89493..0000000
--- a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt
+++ /dev/null
@@ -1,277 +0,0 @@
-package com.bintianqi.owndroid
-
-import android.app.admin.SecurityLog
-import android.content.ContentValues
-import android.database.DatabaseUtils
-import android.database.sqlite.SQLiteDatabase
-import android.os.Build.VERSION
-import androidx.annotation.RequiresApi
-import androidx.core.database.getIntOrNull
-import androidx.core.database.getLongOrNull
-import androidx.core.database.getStringOrNull
-import com.bintianqi.owndroid.ui.screen.AppGroup
-import com.bintianqi.owndroid.ui.screen.IntentFilterOptions
-import com.bintianqi.owndroid.dpm.NetworkLog
-import com.bintianqi.owndroid.dpm.SecurityEvent
-import com.bintianqi.owndroid.dpm.SecurityEventWithData
-import com.bintianqi.owndroid.dpm.transformSecurityEventData
-import kotlinx.serialization.json.ClassDiscriminatorMode
-import kotlinx.serialization.json.Json
-import java.io.OutputStream
-
-class MyRepository(val dbHelper: MyDbHelper) {
- fun getDhizukuClients(): List {
- val list = mutableListOf()
- dbHelper.readableDatabase.rawQuery("SELECT * FROM dhizuku_clients", null).use { cursor ->
- while (cursor.moveToNext()) {
- list += DhizukuClientInfo(
- cursor.getInt(0), cursor.getString(1),
- cursor.getString(2).split(",").filter { it.isNotEmpty() }
- )
- }
- }
- return list
- }
- fun checkDhizukuClientPermission(uid: Int, signature: String?, permission: String): Boolean {
- val cursor = if (signature == null) {
- dbHelper.readableDatabase.rawQuery(
- "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature IS NULL",
- null
- )
- } else {
- dbHelper.readableDatabase.rawQuery(
- "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature = ?",
- arrayOf(signature)
- )
- }
- return cursor.use {
- it.moveToNext() && permission in it.getString(0).split(",")
- }
- }
- fun setDhizukuClient(info: DhizukuClientInfo) {
- val cv = ContentValues()
- cv.put("uid", info.uid)
- cv.put("signature", info.signature)
- cv.put("permissions", info.permissions.joinToString(","))
- dbHelper.writableDatabase.insertWithOnConflict("dhizuku_clients", null, cv,
- SQLiteDatabase.CONFLICT_REPLACE)
- }
- fun deleteDhizukuClient(info: DhizukuClientInfo) {
- dbHelper.writableDatabase.delete("dhizuku_clients", "uid = ${info.uid}", null)
- }
-
- fun getSecurityLogsCount(): Long {
- return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "security_logs")
- }
- @RequiresApi(24)
- fun writeSecurityLogs(events: List) {
- val db = dbHelper.writableDatabase
- val json = Json {
- classDiscriminatorMode = ClassDiscriminatorMode.NONE
- }
- val statement = db.compileStatement("INSERT INTO security_logs VALUES (?, ?, ?, ?, ?)")
- db.beginTransaction()
- events.forEach { event ->
- try {
- if (VERSION.SDK_INT >= 28) {
- statement.bindLong(1, event.id)
- statement.bindLong(3, event.logLevel.toLong())
- } else {
- statement.bindNull(1)
- statement.bindNull(3)
- }
- statement.bindLong(2, event.tag.toLong())
- statement.bindLong(4, event.timeNanos / 1000000)
- val dataObject = transformSecurityEventData(event.tag, event.data)
- if (dataObject == null) {
- statement.bindNull(5)
- } else {
- statement.bindString(5, json.encodeToString(dataObject))
- }
- statement.executeInsert()
- } catch (e: Exception) {
- e.printStackTrace()
- } finally {
- statement.clearBindings()
- }
- }
- db.setTransactionSuccessful()
- db.endTransaction()
- statement.close()
- }
- fun exportSecurityLogs(stream: OutputStream) {
- var offset = 0
- val json = Json {
- explicitNulls = false
- }
- var addComma = false
- val bw = stream.bufferedWriter()
- bw.write("[")
- while (true) {
- dbHelper.readableDatabase.rawQuery(
- "SELECT * FROM security_logs LIMIT ? OFFSET ?",
- arrayOf(100.toString(), offset.toString())
- ).use { cursor ->
- if (cursor.count == 0) {
- break
- }
- while (cursor.moveToNext()) {
- if (addComma) bw.write(",")
- addComma = true
- val event = SecurityEvent(
- cursor.getLong(0), cursor.getInt(1), cursor.getInt(2), cursor.getLong(3),
- cursor.getStringOrNull(4)?.let { json.decodeFromString(it) }
- )
- bw.write(json.encodeToString(event))
- }
- offset += 100
- }
- }
- bw.write("]")
- bw.close()
- }
- @RequiresApi(24)
- fun exportPRSecurityLogs(logs: List, stream: OutputStream) {
- val bw = stream.bufferedWriter()
- bw.write("[")
- val json = Json {
- explicitNulls = false
- classDiscriminatorMode = ClassDiscriminatorMode.NONE
- }
- var addComma = false
- logs.forEach { log ->
- try {
- if (addComma) bw.write(",")
- addComma = true
- val event = SecurityEventWithData(
- if (VERSION.SDK_INT >= 28) log.id else null, log.tag,
- if (VERSION.SDK_INT >= 28) log.logLevel else null, log.timeNanos / 1000000,
- transformSecurityEventData(log.tag, log.data)
- )
- bw.write(json.encodeToString(event))
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- bw.write("]")
- bw.close()
- }
- fun deleteSecurityLogs() {
- dbHelper.writableDatabase.execSQL("DELETE FROM security_logs")
- }
-
- fun getNetworkLogsCount(): Long {
- return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "network_logs")
- }
- fun writeNetworkLogs(logs: List) {
- val db = dbHelper.writableDatabase
- val statement = db.compileStatement(
- "INSERT INTO network_logs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
- )
- db.beginTransaction()
- logs.forEach { event ->
- if (event.id == null) statement.bindNull(1)
- else statement.bindLong(1, event.id)
- statement.bindString(2, event.packageName)
- statement.bindLong(3, event.time)
- statement.bindString(4, event.type)
- if (event.host == null) statement.bindNull(5)
- else statement.bindString(5, event.host)
- if (event.count == null) statement.bindNull(6)
- else statement.bindLong(6, event.count.toLong())
- if (event.addresses == null) statement.bindNull(7)
- else statement.bindString(7, event.addresses.joinToString(","))
- if (event.address == null) statement.bindNull(8)
- else statement.bindString(8, event.address)
- if (event.port == null) statement.bindNull(9)
- else statement.bindLong(9, event.port.toLong())
- statement.executeInsert()
- statement.clearBindings()
- }
- db.setTransactionSuccessful()
- db.endTransaction()
- statement.close()
- }
- fun exportNetworkLogs(stream: OutputStream) {
- val bw = stream.bufferedWriter()
- val json = Json {
- explicitNulls = false
- }
- var offset = 0
- var addComma = false
- bw.write("[")
- while (true) {
- val cursor = dbHelper.readableDatabase.rawQuery(
- "SELECT * FROM network_logs LIMIT ? OFFSET ?",
- arrayOf(100.toString(), offset.toString())
- )
- if (cursor.count == 0) break
- while (cursor.moveToNext()) {
- if (addComma) bw.write(",")
- addComma = true
- val log = NetworkLog(
- cursor.getLongOrNull(0), cursor.getString(1), cursor.getLong(2),
- cursor.getString(3), cursor.getStringOrNull(4), cursor.getIntOrNull(5),
- cursor.getStringOrNull(6)?.split(',')?.filter { it.isNotEmpty() },
- cursor.getStringOrNull(7), cursor.getIntOrNull(8)
- )
- bw.write(json.encodeToString(log))
- offset += 100
- }
- cursor.close()
- }
- bw.write("]")
- bw.close()
- }
- fun deleteNetworkLogs() {
- dbHelper.writableDatabase.execSQL("DELETE FROM network_logs")
- }
-
- fun getAppGroups(): List {
- val list = mutableListOf()
- dbHelper.readableDatabase.rawQuery("SELECT * FROM app_groups", null).use {
- while (it.moveToNext()) {
- list += AppGroup(it.getInt(0), it.getString(1), it.getString(2).split(','))
- }
- }
- return list
- }
- fun setAppGroup(id: Int?, name: String, apps: List) {
- val cv = ContentValues()
- cv.put("name", name)
- cv.put("apps", apps.joinToString(","))
- if (id == null) {
- dbHelper.writableDatabase.insert("app_groups", null, cv)
- } else {
- dbHelper.writableDatabase.update("app_groups", cv, "id = ?", arrayOf(id.toString()))
- }
- }
- fun deleteAppGroup(id: Int) {
- dbHelper.writableDatabase.delete("app_groups", "id = ?", arrayOf(id.toString()))
- }
-
- fun setCrossProfileIntentFilter(data: IntentFilterOptions) {
- val cv = ContentValues()
- cv.put("action_str", data.action)
- cv.put("category", data.category)
- cv.put("mime_type", data.mimeType)
- cv.put("direction", data.direction)
- dbHelper.writableDatabase.insert("cross_profile_intent_filters", null, cv)
- }
- fun getAllCrossProfileIntentFilters(): List {
- val list = mutableListOf()
- dbHelper.readableDatabase.rawQuery(
- "SELECT * FROM cross_profile_intent_filters", null
- ).use {
- while (it.moveToNext()) {
- list += IntentFilterOptions(
- it.getString(0), it.getString(1), it.getString(2), it.getInt(3)
- )
- }
- }
- return list
- }
- fun deleteAllCrossProfileIntentFilters() {
- dbHelper.writableDatabase.delete("cross_profile_intent_filters", null, null)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt
deleted file mode 100644
index e582e52..0000000
--- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt
+++ /dev/null
@@ -1,2099 +0,0 @@
-package com.bintianqi.owndroid
-
-import android.accounts.Account
-import android.annotation.SuppressLint
-import android.app.ActivityOptions
-import android.app.Application
-import android.app.KeyguardManager
-import android.app.PendingIntent
-import android.app.admin.DeviceAdminInfo
-import android.app.admin.DeviceAdminReceiver
-import android.app.admin.DevicePolicyManager
-import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback
-import android.app.admin.FactoryResetProtectionPolicy
-import android.app.admin.IDevicePolicyManager
-import android.app.admin.PackagePolicy
-import android.app.admin.PreferentialNetworkServiceConfig
-import android.app.admin.SecurityLog
-import android.app.admin.SystemUpdateInfo
-import android.app.admin.SystemUpdatePolicy
-import android.app.admin.WifiSsidPolicy
-import android.app.usage.NetworkStats
-import android.app.usage.NetworkStatsManager
-import android.content.BroadcastReceiver
-import android.content.ComponentName
-import android.content.Context
-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
-import android.graphics.Bitmap
-import android.net.IpConfiguration
-import android.net.LinkAddress
-import android.net.ProxyInfo
-import android.net.StaticIpConfiguration
-import android.net.Uri
-import android.net.wifi.WifiConfiguration
-import android.net.wifi.WifiManager
-import android.net.wifi.WifiSsid
-import android.os.Binder
-import android.os.Build.VERSION
-import android.os.Bundle
-import android.os.HardwarePropertiesManager
-import android.os.UserHandle
-import android.os.UserManager
-import android.provider.Settings
-import android.telephony.data.ApnSetting
-import android.view.inputmethod.InputMethodManager
-import androidx.annotation.RequiresApi
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.drawable.toDrawable
-import androidx.core.net.toUri
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.application
-import androidx.lifecycle.viewModelScope
-import com.bintianqi.owndroid.Privilege.DAR
-import com.bintianqi.owndroid.Privilege.DPM
-import com.bintianqi.owndroid.dpm.ACTIVATE_DEVICE_OWNER_COMMAND
-import com.bintianqi.owndroid.dpm.DelegatedAdmin
-import com.bintianqi.owndroid.dpm.DeviceAdmin
-import com.bintianqi.owndroid.dpm.doUserOperationWithContext
-import com.bintianqi.owndroid.dpm.getPackageInstaller
-import com.bintianqi.owndroid.dpm.handlePrivilegeChange
-import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage
-import com.bintianqi.owndroid.dpm.runtimePermissions
-import com.bintianqi.owndroid.ui.screen.ApnAuthType
-import com.bintianqi.owndroid.ui.screen.ApnConfig
-import com.bintianqi.owndroid.ui.screen.ApnMvnoType
-import com.bintianqi.owndroid.ui.screen.ApnProtocol
-import com.bintianqi.owndroid.ui.screen.AppGroup
-import com.bintianqi.owndroid.ui.screen.AppLockConfig
-import com.bintianqi.owndroid.ui.screen.AppRestriction
-import com.bintianqi.owndroid.ui.screen.AppStatus
-import com.bintianqi.owndroid.ui.screen.BasicAppGroup
-import com.bintianqi.owndroid.ui.screen.CaCertInfo
-import com.bintianqi.owndroid.ui.screen.CreateUserResult
-import com.bintianqi.owndroid.ui.screen.CreateWorkProfileOptions
-import com.bintianqi.owndroid.ui.screen.FrpPolicyInfo
-import com.bintianqi.owndroid.ui.screen.HardwareProperties
-import com.bintianqi.owndroid.ui.screen.IntentFilterOptions
-import com.bintianqi.owndroid.ui.screen.IpMode
-import com.bintianqi.owndroid.ui.screen.KeyguardDisableConfig
-import com.bintianqi.owndroid.ui.screen.KeyguardDisableMode
-import com.bintianqi.owndroid.ui.screen.NetworkStatsData
-import com.bintianqi.owndroid.ui.screen.NetworkStatsTarget
-import com.bintianqi.owndroid.ui.screen.PasswordComplexity
-import com.bintianqi.owndroid.ui.screen.PendingSystemUpdateInfo
-import com.bintianqi.owndroid.ui.screen.PreferentialNetworkServiceInfo
-import com.bintianqi.owndroid.ui.screen.PrivateDnsConfiguration
-import com.bintianqi.owndroid.ui.screen.PrivateDnsMode
-import com.bintianqi.owndroid.ui.screen.ProxyMode
-import com.bintianqi.owndroid.ui.screen.ProxyType
-import com.bintianqi.owndroid.ui.screen.QueryNetworkStatsParams
-import com.bintianqi.owndroid.ui.screen.RecommendedProxyConf
-import com.bintianqi.owndroid.ui.screen.RpTokenState
-import com.bintianqi.owndroid.ui.screen.SsidPolicy
-import com.bintianqi.owndroid.ui.screen.SsidPolicyType
-import com.bintianqi.owndroid.ui.screen.SystemOptionsStatus
-import com.bintianqi.owndroid.ui.screen.SystemUpdatePolicyInfo
-import com.bintianqi.owndroid.ui.screen.UserIdentifier
-import com.bintianqi.owndroid.ui.screen.UserInformation
-import com.bintianqi.owndroid.ui.screen.UserOperationType
-import com.bintianqi.owndroid.ui.screen.WifiInfo
-import com.bintianqi.owndroid.ui.screen.WifiSecurity
-import com.bintianqi.owndroid.ui.screen.WifiStatus
-import com.bintianqi.owndroid.ui.screen.activateOrgProfileCommand
-import com.bintianqi.owndroid.ui.screen.delegatedScopesList
-import com.bintianqi.owndroid.ui.screen.globalSettings
-import com.bintianqi.owndroid.ui.screen.secureSettings
-import com.bintianqi.owndroid.ui.screen.temperatureTypes
-import com.rosan.dhizuku.api.Dhizuku
-import com.rosan.dhizuku.api.DhizukuRequestPermissionListener
-import com.topjohnwu.superuser.Shell
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.json.Json
-import java.net.InetAddress
-import java.security.MessageDigest
-import java.security.cert.CertificateException
-import java.security.cert.CertificateFactory
-import java.security.cert.X509Certificate
-import java.time.ZoneId
-import java.time.ZonedDateTime
-import java.util.concurrent.Executors
-import kotlin.reflect.jvm.jvmErasure
-
-class MyViewModel(application: Application): AndroidViewModel(application) {
- val myRepo = getApplication().myRepo
- val PM = application.packageManager
-
- val theme = MutableStateFlow(ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme))
- fun changeTheme(newTheme: ThemeSettings) {
- theme.value = newTheme
- SP.materialYou = newTheme.materialYou
- SP.darkTheme = newTheme.darkTheme
- SP.blackTheme = newTheme.blackTheme
- }
- fun getDisplayDangerousFeatures(): Boolean {
- return SP.displayDangerousFeatures
- }
- fun getShortcutsEnabled(): Boolean {
- return SP.shortcuts
- }
- fun setDisplayDangerousFeatures(state: Boolean) {
- SP.displayDangerousFeatures = state
- }
- fun setShortcutsEnabled(enabled: Boolean) {
- SP.shortcuts = enabled
- ShortcutUtils.setAllShortcuts(application, enabled)
- }
- fun getAppLockConfig(): AppLockConfig {
- val passwordHash = SP.lockPasswordHash
- return AppLockConfig(
- passwordHash?.ifEmpty { null }, SP.biometricsUnlock, SP.lockWhenLeaving
- )
- }
- fun setAppLockConfig(config: AppLockConfig) {
- if (config.password == null) {
- SP.lockPasswordHash = ""
- } else if (!config.password.isEmpty()) {
- SP.lockPasswordHash = config.password.hash()
- }
- SP.biometricsUnlock = config.biometrics
- SP.lockWhenLeaving = config.whenLeaving
- }
- fun getApiEnabled(): Boolean {
- return SP.apiKeyHash?.isNotEmpty() ?: false
- }
- fun setApiKey(key: String) {
- SP.apiKeyHash = if (key.isEmpty()) "" else key.hash()
- }
- val enabledNotifications = MutableStateFlow(emptyList())
- fun getEnabledNotifications() {
- val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() }
- enabledNotifications.value = list ?: NotificationType.entries.map { it.id }
- }
- fun setNotificationEnabled(type: NotificationType, enabled: Boolean) {
- enabledNotifications.update { list ->
- if (enabled) list.plus(type.id) else list.minus(type.id)
- }
- SP.notifications = enabledNotifications.value.joinToString(",") { it.toString() }
- }
-
- val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST)
-
- val installedPackages = MutableStateFlow(emptyList())
- val refreshPackagesProgress = MutableStateFlow(0F)
- fun refreshPackageList() {
- viewModelScope.launch(Dispatchers.IO) {
- installedPackages.value = emptyList()
- val apps = PM.getInstalledApplications(getInstalledAppsFlags)
- apps.forEachIndexed { index, info ->
- installedPackages.update {
- it + getAppInfo(info)
- }
- refreshPackagesProgress.value = (index + 1).toFloat() / apps.size
- }
- }
- }
- fun 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 {
- return try {
- getAppInfo(PM.getApplicationInfo(name, getInstalledAppsFlags))
- } catch (_: PackageManager.NameNotFoundException) {
- AppInfo(name, "???", Color.Transparent.toArgb().toDrawable(), 0)
- }
- }
-
- val suspendedPackages = MutableStateFlow(emptyList())
- @RequiresApi(24)
- fun getSuspendedPackaged() {
- val packages = PM.getInstalledApplications(getInstalledAppsFlags).filter {
- DPM.isPackageSuspended(DAR, it.packageName)
- }
- suspendedPackages.value = packages.map { getAppInfo(it) }
- }
- @RequiresApi(24)
- fun setPackageSuspended(packages: List, status: Boolean) {
- DPM.setPackagesSuspended(DAR, packages.toTypedArray(), status)
- getSuspendedPackaged()
- }
-
- val hiddenPackages = MutableStateFlow(emptyList())
- fun getHiddenPackages() {
- hiddenPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter {
- DPM.isApplicationHidden(DAR, it.packageName)
- }.map { getAppInfo(it) }
- }
- fun setPackageHidden(packages: List, status: Boolean) {
- for (name in packages) {
- DPM.setApplicationHidden(DAR, name, status)
- }
- getHiddenPackages()
- }
-
- // Uninstall blocked packages
- val ubPackages = MutableStateFlow(emptyList())
- fun getUbPackages() {
- ubPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter {
- DPM.isUninstallBlocked(DAR, it.packageName)
- }.map { getAppInfo(it) }
- }
- fun setPackageUb(packages: List, status: Boolean) {
- for (name in packages) {
- DPM.setUninstallBlocked(DAR, name, status)
- }
- getUbPackages()
- }
-
- // User control disabled packages
- val ucdPackages = MutableStateFlow(emptyList())
- @RequiresApi(30)
- fun getUcdPackages() {
- ucdPackages.value = DPM.getUserControlDisabledPackages(DAR).distinct().map {
- getAppInfo(it)
- }
- }
- @RequiresApi(30)
- fun setPackageUcd(packages: List, status: Boolean) {
- DPM.setUserControlDisabledPackages(
- DAR,
- ucdPackages.value.map { it.name }.run {
- if (status) plus(packages) else minus(packages)
- }
- )
- getUcdPackages()
- }
-
- fun getPackagePermissions(name: String): Map {
- return runtimePermissions.associate {
- it.id to DPM.getPermissionGrantState(DAR, name, it.id)
- }
- }
- fun setPackagePermission(name: String, permission: String, status: Int): Boolean {
- return DPM.setPermissionGrantState(DAR, name, permission, status)
- }
- fun getPermissionPackages(permission: String): List> {
- return PM.getInstalledPackages(
- getInstalledAppsFlags or PackageManager.GET_PERMISSIONS
- ).filter {
- it.requestedPermissions?.contains(permission) ?: false
- }.map {
- getAppInfo(it.packageName) to
- DPM.getPermissionGrantState(DAR, it.packageName, permission)
- }
- }
-
- // Metered data disabled packages
- val mddPackages = MutableStateFlow(emptyList())
- @RequiresApi(28)
- fun getMddPackages() {
- mddPackages.value = DPM.getMeteredDataDisabledPackages(DAR).distinct().map { getAppInfo(it) }
- }
- @RequiresApi(28)
- fun setPackageMdd(packages: List, status: Boolean) {
- DPM.setMeteredDataDisabledPackages(
- DAR, mddPackages.value.map { it.name }.run {
- if (status) plus(packages) else minus(packages)
- }
- )
- getMddPackages()
- }
-
- // Keep uninstalled packages
- val kuPackages = MutableStateFlow(emptyList())
- @RequiresApi(28)
- fun getKuPackages() {
- kuPackages.value = DPM.getKeepUninstalledPackages(DAR)?.distinct()?.map { getAppInfo(it) } ?: emptyList()
- }
- @RequiresApi(28)
- fun setPackageKu(packages: List, status: Boolean) {
- DPM.setKeepUninstalledPackages(
- DAR, kuPackages.value.map { it.name }.run {
- if (status) plus(packages) else minus(packages)
- }
- )
- getKuPackages()
- }
-
- // Cross profile packages
- val cpPackages = MutableStateFlow(emptyList())
- @RequiresApi(30)
- fun getCpPackages() {
- cpPackages.value = DPM.getCrossProfilePackages(DAR).map { getAppInfo(it) }
- }
- @RequiresApi(30)
- fun setPackageCp(packages: List, status: Boolean) {
- DPM.setCrossProfilePackages(
- DAR,
- cpPackages.value.map { it.name }.toSet().run {
- if (status) plus(packages) else minus(packages)
- }
- )
- getCpPackages()
- }
-
- // Cross-profile widget providers
- val cpwProviders = MutableStateFlow(emptyList())
- fun getCpwProviders() {
- cpwProviders.value = DPM.getCrossProfileWidgetProviders(DAR).distinct().map { getAppInfo(it) }
- }
- fun setCpwProvider(packages: List, status: Boolean) {
- for (name in packages) {
- if (status) {
- DPM.addCrossProfileWidgetProvider(DAR, name)
- } else {
- DPM.removeCrossProfileWidgetProvider(DAR, name)
- }
- }
- getCpwProviders()
- }
-
- @RequiresApi(28)
- fun clearAppData(name: String, callback: (Boolean) -> Unit) {
- DPM.clearApplicationUserData(DAR, name, Executors.newSingleThreadExecutor()) { _, result ->
- callback(result)
- }
- }
-
- fun uninstallPackage(packageName: String, onComplete: (String?) -> Unit) {
- val 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)
- if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) {
- @SuppressWarnings("UnsafeIntentLaunch")
- context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?)
- } else {
- context.unregisterReceiver(this)
- if (statusExtra == PackageInstaller.STATUS_SUCCESS) {
- onComplete(null)
- } else {
- onComplete(parsePackageInstallerMessage(context, intent))
- }
- }
- }
- }
- ContextCompat.registerReceiver(
- application, receiver, IntentFilter(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,
- PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE
- ).intentSender
- } else {
- PendingIntent.getBroadcast(application, 0, intent, PendingIntent.FLAG_MUTABLE).intentSender
- }
- application.getPackageInstaller().uninstall(packageName, pi)
- }
-
- @RequiresApi(28)
- fun installExistingApp(name: String): Boolean {
- return DPM.installExistingPackage(DAR, name)
- }
-
- // Credential manager policy
- val cmPackages = MutableStateFlow(emptyList())
- @RequiresApi(34)
- fun getCmPolicy(): Int {
- return DPM.credentialManagerPolicy?.let { policy ->
- cmPackages.value = policy.packageNames.distinct().map { getAppInfo(it) }
- policy.policyType
- } ?: -1
- }
- fun setCmPackage(packages: List, status: Boolean) {
- cmPackages.update {
- updateAppInfoList(it, packages, status)
- }
- }
- @RequiresApi(34)
- fun setCmPolicy(type: Int) {
- DPM.credentialManagerPolicy = if (type != -1 && cmPackages.value.isNotEmpty()) {
- PackagePolicy(type, cmPackages.value.map { it.name }.toSet())
- } else null
- getCmPolicy()
- }
-
- 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 {
- return DPM.getPermittedInputMethods(DAR).let { packages ->
- pimPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList()
- packages == null
- }
- }
- fun setPimPackage(packages: List, status: Boolean) {
- pimPackages.update {
- updateAppInfoList(it, packages, status)
- }
- }
- fun setPimPolicy(allowAll: Boolean): Boolean {
- val result = DPM.setPermittedInputMethods(
- DAR, if (allowAll) null else pimPackages.value.map { it.name })
- getPimPackages()
- return result
- }
-
- // Permitted accessibility services
- val pasPackages = MutableStateFlow(emptyList())
- fun getPasPackages(): Boolean {
- return DPM.getPermittedAccessibilityServices(DAR).let { packages ->
- pasPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList()
- packages == null
- }
- }
- fun setPasPackage(packages: List, status: Boolean) {
- pasPackages.update {
- updateAppInfoList(it, packages, status)
- }
- }
- fun setPasPolicy(allowAll: Boolean): Boolean {
- val result = DPM.setPermittedAccessibilityServices(
- DAR, if (allowAll) null else pasPackages.value.map { it.name })
- getPasPackages()
- return result
- }
-
- fun enableSystemApp(name: String) {
- DPM.enableSystemApp(DAR, name)
- }
-
- val appStatus = MutableStateFlow(AppStatus(false, false, false, false, false, false))
- fun getAppStatus(name: String) {
- appStatus.value = AppStatus(
- if (VERSION.SDK_INT >= 24) DPM.isPackageSuspended(DAR, name) else false,
- DPM.isApplicationHidden(DAR, name),
- DPM.isUninstallBlocked(DAR, name),
- if (VERSION.SDK_INT >= 30) name in DPM.getUserControlDisabledPackages(DAR) else false,
- if (VERSION.SDK_INT >= 28) name in DPM.getMeteredDataDisabledPackages(DAR) else false,
- if (VERSION.SDK_INT >= 28 && Privilege.status.value.device)
- DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true
- else false
- )
- }
- // Application details
- @RequiresApi(24)
- fun adSetPackageSuspended(name: String, status: Boolean) {
- 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)
- appStatus.update { it.copy(hide = DPM.isApplicationHidden(DAR, name)) }
- }
- fun adSetPackageUb(name: String, status: Boolean) {
- DPM.setUninstallBlocked(DAR, name, status)
- appStatus.update { it.copy(uninstallBlocked = DPM.isUninstallBlocked(DAR, name)) }
- }
- @RequiresApi(30)
- fun adSetPackageUcd(name: String, status: Boolean) {
- DPM.setUserControlDisabledPackages(DAR,
- DPM.getUserControlDisabledPackages(DAR).run { if (status) plus(name) else minus(name) })
- appStatus.update {
- it.copy(userControlDisabled = name in DPM.getUserControlDisabledPackages(DAR))
- }
- }
- @RequiresApi(28)
- fun adSetPackageMdd(name: String, status: Boolean) {
- DPM.setMeteredDataDisabledPackages(DAR,
- DPM.getMeteredDataDisabledPackages(DAR).run { if (status) plus(name) else minus(name) })
- appStatus.update {
- it.copy(meteredDataDisabled = name in DPM.getMeteredDataDisabledPackages(DAR))
- }
- }
- @RequiresApi(28)
- fun adSetPackageKu(name: String, status: Boolean) {
- DPM.setKeepUninstalledPackages(DAR,
- DPM.getKeepUninstalledPackages(DAR)?.run { if (status) plus(name) else minus(name) } ?: emptyList())
- appStatus.update {
- it.copy(keepUninstalled = DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true )
- }
- }
-
- @RequiresApi(34)
- fun setDefaultDialer(name: String): Boolean {
- return try {
- DPM.setDefaultDialerApplication(name)
- true
- } catch (e: IllegalArgumentException) {
- e.printStackTrace()
- false
- }
- }
-
- 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()
- }
- fun getAppGroups() {
- appGroups.value = myRepo.getAppGroups()
- }
- fun setAppGroup(id: Int?, name: String, apps: List) {
- myRepo.setAppGroup(id, name, apps)
- getAppGroups()
- }
- fun deleteAppGroup(id: Int) {
- myRepo.deleteAppGroup(id)
- appGroups.update { group ->
- group.filter { it.id != id }
- }
- }
- 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() {
- DPM.reboot(DAR)
- }
- @RequiresApi(24)
- fun requestBugReport(): Boolean {
- return try {
- DPM.requestBugreport(DAR)
- } catch (e: Exception) {
- e.printStackTrace()
- false
- }
- }
- @SuppressLint("PrivateApi")
- @RequiresApi(24)
- fun getOrgName(): String {
- return try {
- DPM.getOrganizationName(DAR)?.toString() ?: ""
- } catch (_: Exception) {
- try {
- val method = DevicePolicyManager::class.java.getDeclaredMethod(
- "getDeviceOwnerOrganizationName"
- )
- method.isAccessible = true
- (method.invoke(DPM) as CharSequence).toString()
- } catch (_: Exception) {
- ""
- }
- }
- }
- @RequiresApi(24)
- fun setOrgName(name: String) {
- DPM.setOrganizationName(DAR, name)
- }
- @RequiresApi(31)
- fun setOrgId(id: String): Boolean {
- return try {
- DPM.setOrganizationId(id)
- true
- } catch (_: IllegalStateException) {
- false
- }
- }
- @RequiresApi(31)
- fun getEnrollmentSpecificId(): String {
- return DPM.enrollmentSpecificId
- }
- val systemOptionsStatus = MutableStateFlow(SystemOptionsStatus())
- fun getSystemOptionsStatus() {
- val privilege = Privilege.status.value
- systemOptionsStatus.value = SystemOptionsStatus(
- cameraDisabled = DPM.getCameraDisabled(null),
- screenCaptureDisabled = DPM.getScreenCaptureDisabled(null),
- statusBarDisabled = if (VERSION.SDK_INT >= 34 &&
- privilege.run { device || (profile && affiliated) })
- DPM.isStatusBarDisabled else false,
- autoTimeEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org))
- DPM.getAutoTimeEnabled(DAR) else false,
- 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 (privilege.work)
- DPM.getBluetoothContactSharingDisabled(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,
- stayOnWhilePluggedIn = Settings.Global.getInt(
- application.contentResolver, Settings.Global.STAY_ON_WHILE_PLUGGED_IN) != 0
- )
- }
- fun setCameraDisabled(disabled: Boolean) {
- DPM.setCameraDisabled(DAR, disabled)
- ShortcutUtils.setShortcut(application, MyShortcut.DisableCamera, !disabled)
- systemOptionsStatus.update { it.copy(cameraDisabled = DPM.getCameraDisabled(null)) }
- }
- fun setScreenCaptureDisabled(disabled: Boolean) {
- DPM.setScreenCaptureDisabled(DAR, disabled)
- systemOptionsStatus.update {
- it.copy(screenCaptureDisabled = DPM.getScreenCaptureDisabled(null))
- }
- }
- fun setStatusBarDisabled(disabled: Boolean) {
- val result = DPM.setStatusBarDisabled(DAR, disabled)
- if (result) systemOptionsStatus.update { it.copy(statusBarDisabled = disabled) }
- }
- @RequiresApi(30)
- fun setAutoTimeEnabled(enabled: Boolean) {
- DPM.setAutoTimeEnabled(DAR, enabled)
- systemOptionsStatus.update { it.copy(autoTimeEnabled = DPM.getAutoTimeEnabled(DAR)) }
- }
- @RequiresApi(30)
- fun setAutoTimeZoneEnabled(enabled: Boolean) {
- DPM.setAutoTimeZoneEnabled(DAR, enabled)
- systemOptionsStatus.update {
- it.copy(autoTimeZoneEnabled = DPM.getAutoTimeZoneEnabled(DAR))
- }
- }
- @Suppress("DEPRECATION")
- fun setAutoTimeRequired(required: Boolean) {
- DPM.setAutoTimeRequired(DAR, required)
- systemOptionsStatus.update { it.copy(autoTimeRequired = DPM.autoTimeRequired) }
- }
- fun setMasterVolumeMuted(muted: Boolean) {
- DPM.setMasterVolumeMuted(DAR, muted)
- ShortcutUtils.setShortcut(application, MyShortcut.Mute, !muted)
- systemOptionsStatus.update { it.copy(masterVolumeMuted = DPM.isMasterVolumeMuted(DAR)) }
- }
- @RequiresApi(26)
- fun setBackupServiceEnabled(enabled: Boolean) {
- DPM.setBackupServiceEnabled(DAR, enabled)
- systemOptionsStatus.update {
- it.copy(backupServiceEnabled = DPM.isBackupServiceEnabled(DAR))
- }
- }
- fun setBtContactSharingDisabled(disabled: Boolean) {
- DPM.setBluetoothContactSharingDisabled(DAR, disabled)
- systemOptionsStatus.update {
- it.copy(btContactSharingDisabled = DPM.getBluetoothContactSharingDisabled(DAR))
- }
- }
- @RequiresApi(30)
- fun setCommonCriteriaModeEnabled(enabled: Boolean) {
- DPM.setCommonCriteriaModeEnabled(DAR, enabled)
- systemOptionsStatus.update {
- it.copy(commonCriteriaMode = DPM.isCommonCriteriaModeEnabled(DAR))
- }
- }
- @RequiresApi(31)
- fun setUsbSignalEnabled(enabled: Boolean) {
- DPM.isUsbDataSignalingEnabled = enabled
- systemOptionsStatus.update { it.copy(usbSignalEnabled = DPM.isUsbDataSignalingEnabled) }
- }
- fun setStayOnWhilePluggedIn(status: Boolean) {
- DPM.setGlobalSetting(
- DAR, Settings.Global.STAY_ON_WHILE_PLUGGED_IN, if (status) "15" else "0"
- )
- systemOptionsStatus.update { it.copy(stayOnWhilePluggedIn = status) }
- }
- fun getGlobalSettings(): Map {
- return globalSettings.associate {
- it.setting to (Settings.Global.getInt(application.contentResolver, it.setting, 0) == 1)
- }
- }
- fun setGlobalSetting(name: String, status: Boolean) {
- DPM.setGlobalSetting(DAR, name, if (status) "1" else "0")
- }
- fun getSecureSettings(): Map {
- return secureSettings.associate {
- it.setting to (Settings.Secure.getInt(application.contentResolver, it.setting, 0) == 1)
- }
- }
- fun setSecureSetting(name: String, status: Boolean) {
- DPM.setSecureSetting(DAR, name, if (status) "1" else "0")
- }
- fun setKeyguardDisabled(disabled: Boolean): Boolean {
- return DPM.setKeyguardDisabled(DAR, disabled)
- }
- fun lockScreen(evictKey: Boolean) {
- if (VERSION.SDK_INT >= 26 && Privilege.status.value.work) {
- DPM.lockNow(if (evictKey) DevicePolicyManager.FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY else 0)
- } else {
- DPM.lockNow()
- }
- }
- val hardwareProperties = MutableStateFlow(HardwareProperties())
- var hpRefreshInterval = 1000L
- fun setHpRefreshInterval(interval: Float) {
- hpRefreshInterval = (interval * 1000).toLong()
- }
- @RequiresApi(24)
- suspend fun getHardwareProperties() {
- val hpm = application.getSystemService(HardwarePropertiesManager::class.java)
- while (true) {
- val properties = HardwareProperties(
- temperatureTypes.map { (type, _) ->
- type to hpm.getDeviceTemperatures(type, HardwarePropertiesManager.TEMPERATURE_CURRENT).toList()
- }.toMap(),
- hpm.cpuUsages.map { it.active to it.total },
- hpm.fanSpeeds.toList()
- )
- if (properties.cpuUsages.isEmpty() && properties.fanSpeeds.isEmpty() &&
- properties.temperatures.isEmpty()) {
- break
- }
- hardwareProperties.value = properties
- delay(hpRefreshInterval)
- }
- }
- fun getCurrentInputMethod(): String {
- return Settings.Secure.getString(
- application.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD)
- }
- val inputMethodList = MutableStateFlow(listOf>())
- fun getInputMethods() {
- val imm = application.getSystemService(InputMethodManager::class.java)
- inputMethodList.value = imm.inputMethodList.map {
- it.id to getAppInfo(it.packageName)
- }
- }
- fun setDefaultInputMethod(id: String) {
- DPM.setSecureSetting(DAR, Settings.Secure.DEFAULT_INPUT_METHOD, id)
- getCurrentInputMethod()
- }
- @RequiresApi(28)
- fun setTime(time: Long, useCurrentTz: Boolean): Boolean {
- val offset = if (useCurrentTz) {
- ZonedDateTime.now(ZoneId.systemDefault()).offset.totalSeconds * 1000L
- } else 0L
- return DPM.setTime(DAR, time - offset)
- }
- @RequiresApi(28)
- fun setTimeZone(tz: String): Boolean {
- return DPM.setTimeZone(DAR, tz)
- }
- @RequiresApi(36)
- fun getAutoTimePolicy(): Int {
- return DPM.autoTimePolicy
- }
- @RequiresApi(36)
- fun setAutoTimePolicy(policy: Int) {
- DPM.autoTimePolicy = policy
- }
- @RequiresApi(36)
- fun getAutoTimeZonePolicy(): Int {
- return DPM.autoTimeZonePolicy
- }
- @RequiresApi(36)
- fun setAutoTimeZonePolicy(policy: Int) {
- DPM.autoTimeZonePolicy = policy
- }
- @RequiresApi(35)
- fun getContentProtectionPolicy(): Int {
- return DPM.getContentProtectionPolicy(DAR)
- }
- @RequiresApi(35)
- fun setContentProtectionPolicy(policy: Int) {
- DPM.setContentProtectionPolicy(DAR, policy)
- }
- fun getPermissionPolicy(): Int {
- return DPM.getPermissionPolicy(DAR)
- }
- fun setPermissionPolicy(policy: Int) {
- DPM.setPermissionPolicy(DAR, policy)
- }
- @RequiresApi(34)
- fun getMtePolicy(): Int {
- return DPM.mtePolicy
- }
- @RequiresApi(34)
- fun setMtePolicy(policy: Int): Boolean {
- return try {
- DPM.mtePolicy = policy
- true
- } catch (_: UnsupportedOperationException) {
- false
- }
- }
- @RequiresApi(31)
- fun getNsAppPolicy(): Int {
- return DPM.nearbyAppStreamingPolicy
- }
- @RequiresApi(31)
- fun setNsAppPolicy(policy: Int) {
- DPM.nearbyAppStreamingPolicy = policy
- }
- @RequiresApi(31)
- fun getNsNotificationPolicy(): Int {
- return DPM.nearbyNotificationStreamingPolicy
- }
- @RequiresApi(31)
- fun setNsNotificationPolicy(policy: Int) {
- DPM.nearbyNotificationStreamingPolicy = policy
- }
- val lockTaskPackages = MutableStateFlow(emptyList())
- @RequiresApi(26)
- fun getLockTaskPackages() {
- lockTaskPackages.value = DPM.getLockTaskPackages(DAR).map { getAppInfo(it) }
- }
- @RequiresApi(26)
- fun setLockTaskPackage(name: String, status: Boolean) {
- DPM.setLockTaskPackages(DAR,
- lockTaskPackages.value.map { it.name }
- .run { if (status) plus(name) else minus(name) }
- .toTypedArray()
- )
- getLockTaskPackages()
- }
- @RequiresApi(28)
- fun startLockTaskMode(
- packageName: String, activity: String, 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
- 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
- }
- }
- @RequiresApi(28)
- fun getLockTaskFeatures(): Int {
- return DPM.getLockTaskFeatures(DAR)
- }
- @RequiresApi(28)
- fun setLockTaskFeatures(flags: Int): String? {
- try {
- DPM.setLockTaskFeatures(DAR, flags)
- return null
- } catch (e: IllegalArgumentException) {
- return e.message
- }
- }
- val installedCaCerts = MutableStateFlow(emptyList())
- val selectedCaCert = MutableStateFlow(null)
- fun getCaCerts() {
- installedCaCerts.value = DPM.getInstalledCaCerts(DAR).mapNotNull { parseCaCert(it) }
- }
- fun selectCaCert(cert: CaCertInfo) {
- selectedCaCert.value = cert
- }
- fun parseCaCert(uri: Uri) {
- try {
- application.contentResolver.openInputStream(uri)?.use {
- selectedCaCert.value = parseCaCert(it.readBytes())
- }
- } catch(e: Exception) {
- e.printStackTrace()
- }
- }
- fun parseCaCert(bytes: ByteArray): CaCertInfo? {
- val hash = MessageDigest.getInstance("SHA-256").digest(bytes).toHexString()
- return try {
- val factory = CertificateFactory.getInstance("X.509")
- val cert = factory.generateCertificate(bytes.inputStream()) as X509Certificate
- CaCertInfo(
- hash, cert.serialNumber.toString(16),
- cert.issuerX500Principal.name, cert.subjectX500Principal.name,
- cert.notBefore.time, cert.notAfter.time, bytes
- )
- } catch (e: CertificateException) {
- e.printStackTrace()
- null
- }
- }
- fun installCaCert(): Boolean {
- val result = DPM.installCaCert(DAR, selectedCaCert.value!!.bytes)
- if (result) getCaCerts()
- return result
- }
- fun uninstallCaCert() {
- DPM.uninstallCaCert(DAR, selectedCaCert.value!!.bytes)
- getCaCerts()
- }
- fun uninstallAllCaCerts() {
- DPM.uninstallAllUserCaCerts(DAR)
- getCaCerts()
- }
- fun exportCaCert(uri: Uri) {
- application.contentResolver.openOutputStream(uri)?.use {
- it.write(selectedCaCert.value!!.bytes)
- }
- }
- val mdAccountTypes = MutableStateFlow(emptyList())
- fun getMdAccountTypes() {
- mdAccountTypes.value = DPM.accountTypesWithManagementDisabled?.toList() ?: emptyList()
- }
- fun setMdAccountType(type: String, disabled: Boolean) {
- DPM.setAccountManagementDisabled(DAR, type, disabled)
- getMdAccountTypes()
- }
- @RequiresApi(30)
- fun getFrpPolicy(): FrpPolicyInfo {
- return try {
- val policy = DPM.getFactoryResetProtectionPolicy(DAR)
- FrpPolicyInfo(
- true, policy != null, policy?.isFactoryResetProtectionEnabled ?: false,
- policy?.factoryResetProtectionAccounts ?: emptyList()
- )
- } catch (_: UnsupportedOperationException) {
- FrpPolicyInfo(false, false, false, emptyList())
- }
- }
- @RequiresApi(30)
- fun setFrpPolicy(info: FrpPolicyInfo) {
- val policy = if (info.usePolicy) {
- FactoryResetProtectionPolicy.Builder()
- .setFactoryResetProtectionEnabled(info.enabled)
- .setFactoryResetProtectionAccounts(info.accounts)
- .build()
- } else null
- DPM.setFactoryResetProtectionPolicy(DAR, policy)
- }
- fun wipeData(wipeDevice: Boolean, flags: Int, reason: String) {
- if (wipeDevice && VERSION.SDK_INT >= 34) {
- DPM.wipeDevice(flags)
- } else {
- if(VERSION.SDK_INT >= 28 && reason.isNotEmpty()) {
- DPM.wipeData(flags, reason)
- } else {
- DPM.wipeData(flags)
- }
- }
- }
- fun getSystemUpdatePolicy(): SystemUpdatePolicyInfo {
- val policy = DPM.systemUpdatePolicy
- return SystemUpdatePolicyInfo(
- policy?.policyType ?: -1, policy?.installWindowStart ?: 0, policy?.installWindowEnd ?: 0
- )
- }
- fun setSystemUpdatePolicy(info: SystemUpdatePolicyInfo) {
- val policy = when (info.type) {
- SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC -> SystemUpdatePolicy.createAutomaticInstallPolicy()
- SystemUpdatePolicy.TYPE_INSTALL_WINDOWED ->
- SystemUpdatePolicy.createWindowedInstallPolicy(info.start, info.end)
- SystemUpdatePolicy.TYPE_POSTPONE -> SystemUpdatePolicy.createPostponeInstallPolicy()
- else -> null
- }
- DPM.setSystemUpdatePolicy(DAR, policy)
- }
- @RequiresApi(26)
- fun getPendingSystemUpdate(): PendingSystemUpdateInfo {
- val update = DPM.getPendingSystemUpdate(DAR)
- return PendingSystemUpdateInfo(update != null, update?.receivedTime ?: 0,
- update?.securityPatchState == SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE)
- }
- @RequiresApi(29)
- fun installSystemUpdate(uri: Uri, callback: (String) -> Unit) {
- val callback = object: InstallSystemUpdateCallback() {
- override fun onInstallUpdateError(errorCode: Int, errorMessage: String) {
- super.onInstallUpdateError(errorCode, errorMessage)
- val errDetail = when(errorCode) {
- UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low
- UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid
- UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver
- UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist
- else -> R.string.unknown_error
- }
- callback(application.getString(errDetail) + "\n$errorMessage")
- }
- }
- DPM.installSystemUpdate(DAR, uri, application.mainExecutor, callback)
- }
- @RequiresApi(24)
- fun getSecurityLoggingEnabled(): Boolean {
- return DPM.isSecurityLoggingEnabled(DAR)
- }
- @RequiresApi(24)
- fun setSecurityLoggingEnabled(enabled: Boolean) {
- DPM.setSecurityLoggingEnabled(DAR, enabled)
- }
- fun exportSecurityLogs(uri: Uri, callback: () -> Unit) {
- viewModelScope.launch(Dispatchers.IO) {
- application.contentResolver.openOutputStream(uri)?.use {
- myRepo.exportSecurityLogs(it)
- }
- withContext(Dispatchers.Main) {
- callback()
- }
- }
- }
- fun getSecurityLogsCount(): Int {
- return myRepo.getSecurityLogsCount().toInt()
- }
- fun deleteSecurityLogs() {
- myRepo.deleteSecurityLogs()
- }
- var preRebootSecurityLogs = emptyList()
- @RequiresApi(24)
- fun getPreRebootSecurityLogs(): Boolean {
- if (preRebootSecurityLogs.isNotEmpty()) return true
- return try {
- val logs = DPM.retrievePreRebootSecurityLogs(DAR)
- if (logs != null && logs.isNotEmpty()) {
- preRebootSecurityLogs = logs
- true
- } else false
- } catch (_: SecurityException) {
- false
- }
- }
- @RequiresApi(24)
- fun exportPreRebootSecurityLogs(uri: Uri, callback: () -> Unit) {
- viewModelScope.launch(Dispatchers.IO) {
- val stream = application.contentResolver.openOutputStream(uri) ?: return@launch
- myRepo.exportPRSecurityLogs(preRebootSecurityLogs, stream)
- stream.close()
- withContext(Dispatchers.Main) { callback() }
- }
- }
-
- @RequiresApi(24)
- fun isCreatingWorkProfileAllowed(): Boolean {
- return DPM.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE)
- }
- fun activateDoByShizuku(callback: (Boolean, String?) -> Unit) {
- viewModelScope.launch(Dispatchers.IO) {
- useShizuku(application) { service ->
- try {
- val result = IUserService.Stub.asInterface(service)
- .execute(ACTIVATE_DEVICE_OWNER_COMMAND)
- if (result == null || result.getInt("code", -1) != 0) {
- callback(false, null)
- } else {
- Privilege.updateStatus()
- handlePrivilegeChange(application)
- callback(
- true, result.getString("output") + "\n" + result.getString("error")
- )
- }
- } catch (e: Exception) {
- e.printStackTrace()
- callback(false, null)
- }
- }
- }
- }
- fun activateDoByRoot(callback: (Boolean, String?) -> Unit) {
- Shell.getShell { shell ->
- if(shell.isRoot) {
- val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec()
- val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n")
- if (result.isSuccess) {
- Privilege.updateStatus()
- handlePrivilegeChange(application)
- }
- callback(result.isSuccess, output)
- } else {
- callback(false, application.getString(R.string.permission_denied))
- }
- }
- }
- @RequiresApi(28)
- fun activateDoByDhizuku(callback: (Boolean, String?) -> Unit) {
- DPM.transferOwnership(DAR, MyAdminComponent, null)
- SP.dhizuku = false
- Privilege.initialize(application)
- handlePrivilegeChange(application)
- callback(true, null)
- }
- fun activateDhizukuMode(callback: (Boolean, String?) -> Unit) {
- fun onSucceed() {
- SP.dhizuku = true
- Privilege.initialize(application)
- handlePrivilegeChange(application)
- callback(true, null)
- }
- if (Dhizuku.init(application)) {
- if (Dhizuku.isPermissionGranted()) {
- onSucceed()
- } else {
- Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() {
- override fun onRequestPermission(grantResult: Int) {
- if (grantResult == PackageManager.PERMISSION_GRANTED) onSucceed()
- else callback(false, application.getString(R.string.dhizuku_permission_not_granted))
- }
- })
- }
- } else {
- callback(false, application.getString(R.string.failed_to_init_dhizuku))
- }
- }
- fun clearDeviceOwner() {
- DPM.clearDeviceOwnerApp(application.packageName)
- }
- @RequiresApi(24)
- fun clearProfileOwner() {
- DPM.clearProfileOwner(MyAdminComponent)
- }
- fun deactivateDhizukuMode() {
- SP.dhizuku = false
- Privilege.initialize(application)
- }
- val dhizukuClients = MutableStateFlow(emptyList>())
- fun getDhizukuClients() {
- viewModelScope.launch(Dispatchers.IO) {
- dhizukuClients.value = myRepo.getDhizukuClients().mapNotNull {
- val packageName = PM.getNameForUid(it.uid)
- if (packageName == null) {
- myRepo.deleteDhizukuClient(it)
- null
- } else {
- it to getAppInfo(packageName)
- }
- }
- }
- }
- fun getDhizukuServerEnabled(): Boolean {
- return SP.dhizukuServer
- }
- fun setDhizukuServerEnabled(status: Boolean) {
- SP.dhizukuServer = status
- }
- fun updateDhizukuClient(info: DhizukuClientInfo) {
- myRepo.setDhizukuClient(info)
- dhizukuClients.update { list ->
- val ml = list.toMutableList()
- val index = ml.indexOfFirst { it.first.uid == info.uid }
- ml[index] = info to ml[index].second
- ml
- }
- }
- @RequiresApi(24)
- fun getLockScreenInfo(): String {
- return DPM.deviceOwnerLockScreenInfo?.toString() ?: ""
- }
- @RequiresApi(24)
- fun setLockScreenInfo(text: String) {
- DPM.setDeviceOwnerLockScreenInfo(DAR, text)
- }
- val delegatedAdmins = MutableStateFlow(emptyList())
- @RequiresApi(26)
- fun getDelegatedAdmins() {
- val list = mutableListOf()
- delegatedScopesList.forEach { scope ->
- DPM.getDelegatePackages(DAR, scope.id)?.forEach { pkg ->
- val index = list.indexOfFirst { it.app.name == pkg }
- if (index == -1) {
- list += DelegatedAdmin(getAppInfo(pkg), listOf(scope.id))
- } else {
- list[index] = DelegatedAdmin(list[index].app, list[index].scopes + scope.id)
- }
- }
- }
- delegatedAdmins.value = list
- }
- @RequiresApi(26)
- fun setDelegatedAdmin(name: String, scopes: List) {
- DPM.setDelegatedScopes(DAR, name, scopes)
- getDelegatedAdmins()
- }
- @RequiresApi(34)
- fun getDeviceFinanced(): Boolean {
- return DPM.isDeviceFinanced
- }
- @RequiresApi(33)
- fun getDpmRh(): String? {
- return DPM.devicePolicyManagementRoleHolderPackage
- }
- fun getStorageEncryptionStatus(): Int {
- return DPM.storageEncryptionStatus
- }
- @RequiresApi(28)
- fun getDeviceIdAttestationSupported(): Boolean {
- return DPM.isDeviceIdAttestationSupported
- }
- @RequiresApi(30)
- fun getUniqueDeviceAttestationSupported(): Boolean {
- return DPM.isUniqueDeviceAttestationSupported
- }
- fun getActiveAdmins(): String {
- return DPM.activeAdmins?.joinToString("\n") {
- it.flattenToShortString()
- } ?: application.getString(R.string.none)
- }
- @RequiresApi(24)
- fun getShortSupportMessage(): String {
- return DPM.getShortSupportMessage(DAR)?.toString() ?: ""
- }
- @RequiresApi(24)
- fun getLongSupportMessage(): String {
- return DPM.getLongSupportMessage(DAR)?.toString() ?: ""
- }
- @RequiresApi(24)
- fun setShortSupportMessage(text: String?) {
- DPM.setShortSupportMessage(DAR, text)
- }
- @RequiresApi(24)
- fun setLongSupportMessage(text: String?) {
- DPM.setLongSupportMessage(DAR, text)
- }
- val deviceAdminReceivers = MutableStateFlow(emptyList())
- fun getDeviceAdminReceivers() {
- viewModelScope.launch(Dispatchers.IO) {
- deviceAdminReceivers.value = PM.queryBroadcastReceivers(
- Intent(DeviceAdminReceiver.ACTION_DEVICE_ADMIN_ENABLED),
- PackageManager.GET_META_DATA
- ).mapNotNull {
- try {
- DeviceAdminInfo(application, it)
- } catch(_: Exception) {
- null
- }
- }.filter {
- it.isVisible && it.packageName != "com.bintianqi.owndroid" &&
- it.activityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 0
- }.map {
- DeviceAdmin(getAppInfo(it.packageName), it.component)
- }
- }
- }
- @RequiresApi(28)
- fun transferOwnership(component: ComponentName) {
- DPM.transferOwnership(DAR, component, null)
- Privilege.updateStatus()
- }
- val userRestrictions = MutableStateFlow(emptyMap())
- fun getUserRestrictions() {
- val bundle = UM.userRestrictions
- userRestrictions.value = bundle.keySet().associateWith { bundle.getBoolean(it) }
- }
- fun setUserRestriction(name: String, state: Boolean): Boolean {
- return try {
- if (state) {
- DPM.addUserRestriction(DAR, name)
- } else {
- DPM.clearUserRestriction(DAR, name)
- }
- userRestrictions.update { it.plus(name to state) }
- getUserRestrictions()
- ShortcutUtils.updateUserRestrictionShortcut(application, name, !state, true)
- true
- } catch (_: SecurityException) {
- false
- }
- }
- fun createUserRestrictionShortcut(id: String): Boolean {
- return ShortcutUtils.setUserRestrictionShortcut(
- application, id, userRestrictions.value[id] ?: true
- )
- }
- fun createWorkProfile(options: CreateWorkProfileOptions): Intent {
- val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE)
- 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)
- )
- if (VERSION.SDK_INT >= 26) {
- intent.putExtra(
- DevicePolicyManager.EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION,
- options.keepAccount
- )
- }
- }
- if (VERSION.SDK_INT >= 24) {
- intent.putExtra(
- DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION,
- options.skipEncrypt
- )
- }
- if (VERSION.SDK_INT >= 33) {
- intent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_ALLOW_OFFLINE, options.offline)
- }
- return intent
- }
- fun activateOrgProfileByShizuku(callback: (Boolean) -> Unit) {
- viewModelScope.launch(Dispatchers.IO) {
- var succeed = false
- useShizuku(application) { service ->
- val result = IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand)
- succeed = result?.getInt("code", -1) == 0
- callback(succeed)
- }
- if (succeed) Privilege.updateStatus()
- }
- }
- @RequiresApi(30)
- fun getPersonalAppsSuspendedReason(): Int {
- return DPM.getPersonalAppsSuspendedReasons(DAR)
- }
- @RequiresApi(30)
- fun setPersonalAppsSuspended(suspended: Boolean) {
- DPM.setPersonalAppsSuspended(DAR, suspended)
- }
- @RequiresApi(30)
- fun getProfileMaxTimeOff(): Long {
- return DPM.getManagedProfileMaximumTimeOff(DAR)
- }
- @RequiresApi(30)
- fun setProfileMaxTimeOff(time: Long) {
- DPM.setManagedProfileMaximumTimeOff(DAR, time)
- }
- fun addCrossProfileIntentFilter(options: IntentFilterOptions) {
- val filter = IntentFilter(options.action)
- if (options.category.isNotEmpty()) filter.addCategory(options.category)
- if (options.mimeType.isNotEmpty()) filter.addDataType(options.mimeType)
- DPM.addCrossProfileIntentFilter(DAR, filter, options.direction)
- myRepo.setCrossProfileIntentFilter(options)
- }
- fun clearCrossProfileIntentFilters() {
- DPM.clearCrossProfileIntentFilters(DAR)
- myRepo.deleteAllCrossProfileIntentFilters()
- }
- fun importCrossProfileIntentFilters(uri: Uri) {
- val bytes = application.contentResolver.openInputStream(uri)!!.use {
- it.readBytes().decodeToString()
- }
- val data = Json.decodeFromString>(bytes)
- data.forEach {
- addCrossProfileIntentFilter(it)
- }
- }
- fun exportCrossProfileIntentFilters(uri: Uri) {
- val data = myRepo.getAllCrossProfileIntentFilters()
- val bytes = Json.encodeToString(data).encodeToByteArray()
- application.contentResolver.openOutputStream(uri)!!.use {
- it.write(bytes)
- }
- }
-
- val UM = application.getSystemService(Context.USER_SERVICE) as UserManager
- @RequiresApi(28)
- fun getLogoutEnabled(): Boolean {
- return DPM.isLogoutEnabled
- }
- @RequiresApi(28)
- fun setLogoutEnabled(enabled: Boolean) {
- DPM.setLogoutEnabled(DAR, enabled)
- }
- fun getUserInformation(): UserInformation {
- val uh = Binder.getCallingUserHandle()
- return UserInformation(
- if (VERSION.SDK_INT >= 24) UserManager.supportsMultipleUsers() else false,
- if (VERSION.SDK_INT >= 31) UserManager.isHeadlessSystemUserMode() else false,
- UM.isSystemUser,
- if (VERSION.SDK_INT >= 34) UM.isAdminUser else false,
- if (VERSION.SDK_INT >= 25) UM.isDemoUser else false,
- 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,
- UM.getSerialNumberForUser(uh)
- )
- }
- @Suppress("PrivateApi")
- @RequiresApi(28)
- fun getUserIdentifiers(): List {
- return DPM.getSecondaryUsers(DAR)?.mapNotNull {
- try {
- val field = UserHandle::class.java.getDeclaredField("mHandle")
- field.isAccessible = true
- UserIdentifier(field.get(it) as Int, UM.getSerialNumberForUser(it))
- } catch (e: Exception) {
- e.printStackTrace()
- null
- }
- } ?: emptyList()
- }
- fun doUserOperation(type: UserOperationType, id: Int, isUserId: Boolean): Boolean {
- return doUserOperationWithContext(application, type, id, isUserId)
- }
- fun createUserOperationShortcut(type: UserOperationType, id: Int, isUserId: Boolean): Boolean {
- val serial = if (isUserId && VERSION.SDK_INT >= 24) {
- UM.getSerialNumberForUser(UserHandle.getUserHandleForUid(id * 100000))
- } else id
- return ShortcutUtils.setUserOperationShortcut(application, type, serial.toInt())
- }
- fun getUserOperationResultText(code: Int): Int {
- return when (code) {
- UserManager.USER_OPERATION_SUCCESS -> R.string.success
- UserManager.USER_OPERATION_ERROR_UNKNOWN -> R.string.unknown_error
- UserManager.USER_OPERATION_ERROR_MANAGED_PROFILE-> R.string.fail_managed_profile
- UserManager.USER_OPERATION_ERROR_MAX_RUNNING_USERS -> R.string.limit_reached
- UserManager.USER_OPERATION_ERROR_MAX_USERS -> R.string.limit_reached
- UserManager.USER_OPERATION_ERROR_CURRENT_USER -> R.string.fail_current_user
- else -> R.string.unknown
- }
- }
- @RequiresApi(24)
- fun createUser(name: String, flags: Int, callback: (CreateUserResult) -> Unit) {
- viewModelScope.launch(Dispatchers.IO) {
- try {
- val uh = DPM.createAndManageUser(DAR, name, DAR, null, flags)
- if (uh == null) {
- callback(CreateUserResult(R.string.failed))
- } else {
- callback(CreateUserResult(R.string.succeeded, UM.getSerialNumberForUser(uh)))
- }
- } catch (e: Exception) {
- e.printStackTrace()
- if (VERSION.SDK_INT >= 28 && e is UserManager.UserOperationException) {
- callback(CreateUserResult(getUserOperationResultText(e.userOperationResult)))
- } else {
- callback(CreateUserResult(R.string.error))
- }
- }
- }
- }
- val affiliationIds = MutableStateFlow(emptyList())
- @RequiresApi(26)
- fun getAffiliationIds() {
- affiliationIds.value = DPM.getAffiliationIds(DAR).toList()
- }
- @RequiresApi(26)
- fun setAffiliationId(id: String, state: Boolean) {
- val newList = affiliationIds.value.run { if (state) plus(id) else minus(id) }
- DPM.setAffiliationIds(DAR, newList.toSet())
- affiliationIds.value = newList
- }
- fun setProfileName(name: String) {
- DPM.setProfileName(DAR, name)
- }
- fun setUserIcon(bitmap: Bitmap) {
- DPM.setUserIcon(DAR, bitmap)
- }
- @RequiresApi(28)
- fun getUserSessionMessages(): Pair {
- return (DPM.getStartUserSessionMessage(DAR)?.toString() ?: "") to
- (DPM.getEndUserSessionMessage(DAR)?.toString() ?: "")
- }
- @RequiresApi(28)
- fun setStartUserSessionMessage(message: String?) {
- DPM.setStartUserSessionMessage(DAR, message)
- }
- @RequiresApi(28)
- fun setEndUserSessionMessage(message: String?) {
- DPM.setEndUserSessionMessage(DAR, message)
- }
- @RequiresApi(28)
- fun logoutUser(): Int {
- return getUserOperationResultText(DPM.logoutUser(DAR))
- }
-
- val WM = application.getSystemService(Context.WIFI_SERVICE) as WifiManager
- // Lockdown admin configured networks
- @RequiresApi(30)
- fun getLanEnabled(): Boolean {
- return DPM.hasLockdownAdminConfiguredNetworks(DAR)
- }
- @RequiresApi(30)
- fun setLanEnabled(state: Boolean) {
- DPM.setConfiguredNetworksLockdownState(DAR, state)
- }
- fun setWifiEnabled(enabled: Boolean): Boolean {
- return WM.setWifiEnabled(enabled)
- }
- fun disconnectWifi(): Boolean {
- return WM.disconnect()
- }
- fun reconnectWifi(): Boolean {
- return WM.reconnect()
- }
- @RequiresApi(24)
- fun getWifiMac(): String? {
- return DPM.getWifiMacAddress(DAR)
- }
- val configuredNetworks = MutableStateFlow(emptyList())
- fun getConfiguredNetworks() {
- configuredNetworks.value = WM.configuredNetworks.distinctBy { it.networkId }.map { conf ->
- WifiInfo(
- conf.networkId, conf.SSID.removeSurrounding("\""), null, conf.BSSID ?: "", null,
- WifiStatus.entries.find { it.id == conf.status }!!, null, "", null, null, null, null
- )
- }
- }
- fun enableNetwork(id: Int): Boolean {
- return WM.enableNetwork(id, false)
- }
- fun disableNetwork(id: Int): Boolean {
- return WM.disableNetwork(id)
- }
- fun removeNetwork(id: Int): Boolean{
- return WM.removeNetwork(id)
- }
- fun setWifi(info: WifiInfo): Boolean {
- val conf = WifiConfiguration()
- conf.SSID = "\"" + info.ssid + "\""
- info.hiddenSsid?.let { conf.hiddenSSID = it }
- if (VERSION.SDK_INT >= 30) info.security?.let { conf.setSecurityParams(it.id) }
- if (info.security == WifiSecurity.Psk) conf.preSharedKey = info.password
- if (VERSION.SDK_INT >= 33) info.macRandomization?.let { conf.macRandomizationSetting = it.id }
- if (VERSION.SDK_INT >= 33 && info.ipMode != null) {
- val ipConf = if (info.ipMode == IpMode.Static && info.ipConf != null) {
- val constructor = LinkAddress::class.constructors.find {
- it.parameters.size == 1 && it.parameters[0].type.jvmErasure == String::class
- }
- val address = constructor!!.call(info.ipConf.address)
- val staticIpConf = StaticIpConfiguration.Builder()
- .setIpAddress(address)
- .setGateway(InetAddress.getByName(info.ipConf.gateway))
- .setDnsServers(info.ipConf.dns.map { InetAddress.getByName(it) })
- .build()
- IpConfiguration.Builder().setStaticIpConfiguration(staticIpConf).build()
- } else null
- conf.setIpConfiguration(ipConf)
- }
- if (VERSION.SDK_INT >= 26 && info.proxyMode != null) {
- val proxy = if (info.proxyMode == ProxyMode.Http) {
- info.proxyConf?.let {
- ProxyInfo.buildDirectProxy(it.host, it.port, it.exclude)
- }
- } else null
- conf.httpProxy = proxy
- }
- val result = if (info.id != -1) {
- conf.networkId = info.id
- WM.updateNetwork(conf)
- } else {
- WM.addNetwork(conf)
- }
- if (result != -1) {
- when (info.status) {
- WifiStatus.Current -> WM.enableNetwork(result, true)
- WifiStatus.Enabled -> WM.enableNetwork(result, false)
- WifiStatus.Disabled -> WM.disableNetwork(result)
- }
- }
- return result != -1
- }
- @RequiresApi(33)
- fun getMinimumWifiSecurityLevel(): Int {
- return DPM.minimumRequiredWifiSecurityLevel
- }
- @RequiresApi(33)
- fun setMinimumWifiSecurityLevel(level: Int) {
- DPM.minimumRequiredWifiSecurityLevel = level
- }
- @RequiresApi(33)
- fun getSsidPolicy(): SsidPolicy {
- val policy = DPM.wifiSsidPolicy
- return SsidPolicy(
- SsidPolicyType.entries.find { it.id == policy?.policyType } ?: SsidPolicyType.None,
- policy?.ssids?.map { it.bytes.decodeToString() } ?: emptyList()
- )
- }
- @RequiresApi(33)
- fun setSsidPolicy(policy: SsidPolicy) {
- val newPolicy = if (policy.type != SsidPolicyType.None) {
- WifiSsidPolicy(
- policy.type.id, policy.list.map { WifiSsid.fromBytes(it.encodeToByteArray()) }.toSet()
- )
- } else null
- DPM.wifiSsidPolicy = newPolicy
- }
- @RequiresApi(24)
- fun getPackageUid(name: String): Int {
- return PM.getPackageUid(name, 0)
- }
- var networkStatsData = emptyList()
- fun readNetworkStats(stats: NetworkStats): List {
- val list = mutableListOf()
- while (stats.hasNextBucket()) {
- val bucket = NetworkStats.Bucket()
- stats.getNextBucket(bucket)
- list += readNetworkStatsBucket(bucket)
- }
- stats.close()
- return list
- }
- fun readNetworkStatsBucket(bucket: NetworkStats.Bucket): NetworkStatsData {
- return NetworkStatsData(
- bucket.rxBytes, bucket.rxPackets, bucket.txBytes, bucket.txPackets,
- bucket.uid, bucket.state, bucket.startTimeStamp, bucket.endTimeStamp,
- if (VERSION.SDK_INT >= 24) bucket.tag else null,
- if (VERSION.SDK_INT >= 24) bucket.roaming else null,
- if (VERSION.SDK_INT >= 26) bucket.metered else null
- )
- }
- @Suppress("NewApi")
- fun queryNetworkStats(params: QueryNetworkStatsParams, callback: (String?) -> Unit) {
- viewModelScope.launch(Dispatchers.IO) {
- val nsm = application.getSystemService(NetworkStatsManager::class.java)
- try {
- val data = when (params.target) {
- NetworkStatsTarget.Device -> listOf(readNetworkStatsBucket(
- nsm.querySummaryForDevice(
- params.networkType.type, null, params.startTime, params.endTime
- )
- ))
- NetworkStatsTarget.User -> listOf(readNetworkStatsBucket(
- nsm.querySummaryForUser(
- params.networkType.type, null, params.startTime, params.endTime
- )
- ))
- NetworkStatsTarget.Uid -> readNetworkStats(nsm.queryDetailsForUid(
- params.networkType.type, null, params.startTime, params.endTime, params.uid
- ))
- NetworkStatsTarget.UidTag -> readNetworkStats(nsm.queryDetailsForUidTag(
- params.networkType.type, null, params.startTime, params.endTime,
- params.uid, params.tag
- ))
- NetworkStatsTarget.UidTagState -> readNetworkStats(
- nsm.queryDetailsForUidTagState(
- params.networkType.type, null, params.startTime, params.endTime,
- params.uid, params.tag, params.state.id
- )
- )
- }
- networkStatsData = data
- withContext(Dispatchers.Main) {
- if (data.isEmpty()) {
- callback(application.getString(R.string.no_data))
- } else {
- callback(null)
- }
- }
- } catch(e: Exception) {
- e.printStackTrace()
- withContext(Dispatchers.Main) {
- callback(e.message ?: "")
- }
- }
- }
- }
- fun clearNetworkStats() {
- networkStatsData = emptyList()
- }
- @RequiresApi(29)
- fun getPrivateDns(): PrivateDnsConfiguration {
- val mode = DPM.getGlobalPrivateDnsMode(DAR)
- return PrivateDnsConfiguration(
- PrivateDnsMode.entries.find { it.id == mode }, DPM.getGlobalPrivateDnsHost(DAR) ?: ""
- )
- }
- @Suppress("PrivateApi")
- @RequiresApi(29)
- fun setPrivateDns(conf: PrivateDnsConfiguration): Boolean {
- return try {
- val field = DevicePolicyManager::class.java.getDeclaredField("mService")
- field.isAccessible = true
- val dpm = field.get(DPM) as IDevicePolicyManager
- val host = if (conf.mode == PrivateDnsMode.Host) conf.host else null
- val result = dpm.setGlobalPrivateDns(DAR, conf.mode!!.id, host)
- result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR
- } catch (e: Exception) {
- e.printStackTrace()
- false
- }
- }
- @RequiresApi(24)
- fun getAlwaysOnVpnPackage(): String {
- return DPM.getAlwaysOnVpnPackage(DAR) ?: ""
- }
- @RequiresApi(29)
- fun getAlwaysOnVpnLockdown(): Boolean {
- return DPM.isAlwaysOnVpnLockdownEnabled(DAR)
- }
- @RequiresApi(24)
- fun setAlwaysOnVpn(name: String?, lockdown: Boolean): Int {
- return try {
- DPM.setAlwaysOnVpnPackage(DAR, name, lockdown)
- R.string.succeeded
- } catch (_: UnsupportedOperationException) {
- R.string.unsupported
- } catch (_: PackageManager.NameNotFoundException) {
- R.string.not_installed
- }
- }
- fun setRecommendedGlobalProxy(conf: RecommendedProxyConf) {
- val info = when (conf.type) {
- ProxyType.Off -> null
- ProxyType.Pac -> {
- if (VERSION.SDK_INT >= 30 && conf.specifyPort) {
- ProxyInfo.buildPacProxy(conf.url.toUri(), conf.port)
- } else {
- ProxyInfo.buildPacProxy(conf.url.toUri())
- }
- }
- ProxyType.Direct -> {
- ProxyInfo.buildDirectProxy(conf.host, conf.port, conf.exclude)
- }
- }
- DPM.setRecommendedGlobalProxy(DAR, info)
- }
- // PNS: preferential network service
- @RequiresApi(31)
- fun getPnsEnabled(): Boolean {
- return DPM.isPreferentialNetworkServiceEnabled
- }
- @RequiresApi(31)
- fun setPnsEnabled(enabled: Boolean) {
- DPM.isPreferentialNetworkServiceEnabled = enabled
- }
- val pnsConfigs = MutableStateFlow(emptyList())
- @RequiresApi(33)
- fun getPnsConfigs() {
- pnsConfigs.value = DPM.preferentialNetworkServiceConfigs.map {
- PreferentialNetworkServiceInfo(
- it.isEnabled, it.networkId, it.isFallbackToDefaultConnectionAllowed,
- if (VERSION.SDK_INT >= 34) it.shouldBlockNonMatchingNetworks() else false,
- it.excludedUids.toList(), it.includedUids.toList()
- )
- }
- }
- @RequiresApi(33)
- fun buildPnsConfig(
- info: PreferentialNetworkServiceInfo
- ): PreferentialNetworkServiceConfig {
- return PreferentialNetworkServiceConfig.Builder().apply {
- setEnabled(info.enabled)
- @Suppress("WrongConstant")
- setNetworkId(info.id)
- setFallbackToDefaultConnectionAllowed(info.allowFallback)
- if (VERSION.SDK_INT >= 34) setShouldBlockNonMatchingNetworks(info.blockNonMatching)
- setIncludedUids(info.includedUids.toIntArray())
- setExcludedUids(info.excludedUids.toIntArray())
- }.build()
- }
- @RequiresApi(33)
- fun setPnsConfig(info: PreferentialNetworkServiceInfo, state: Boolean) {
- val configs = pnsConfigs.value.run {
- if (state) plus(info) else minus(info)
- }.map { buildPnsConfig(it) }
- DPM.preferentialNetworkServiceConfigs = configs
- }
- val apnConfigs = MutableStateFlow(listOf())
- @RequiresApi(28)
- fun getApnEnabled(): Boolean {
- return DPM.isOverrideApnEnabled(DAR)
- }
- @RequiresApi(28)
- fun setApnEnabled(enabled: Boolean) {
- DPM.setOverrideApnsEnabled(DAR, enabled)
- }
- @RequiresApi(28)
- fun getApnConfigs() {
- apnConfigs.value = DPM.getOverrideApns(DAR).map {
- val proxy = if (VERSION.SDK_INT >= 29) it.proxyAddressAsString else it.proxyAddress.hostName
- val mmsProxy = if (VERSION.SDK_INT >= 29) it.mmsProxyAddressAsString else it.mmsProxyAddress.hostName
- ApnConfig(
- it.isEnabled, it.entryName, it.apnName, proxy, it.proxyPort,
- it.user, it.password, it.apnTypeBitmask, it.mmsc.toString(),
- mmsProxy, it.mmsProxyPort,
- ApnAuthType.entries.find { type -> type.id == it.authType }!!,
- ApnProtocol.entries.find { protocol -> protocol.id == it.protocol }!!,
- ApnProtocol.entries.find { protocol -> protocol.id == it.roamingProtocol }!!,
- it.networkTypeBitmask,
- if (VERSION.SDK_INT >= 33) it.profileId else 0,
- if (VERSION.SDK_INT >= 29) it.carrierId else 0,
- if (VERSION.SDK_INT >= 33) it.mtuV4 else 0,
- if (VERSION.SDK_INT >= 33) it.mtuV6 else 0,
- ApnMvnoType.entries.find { type -> type.id == it.mvnoType }!!,
- it.operatorNumeric,
- if (VERSION.SDK_INT >= 33) it.isPersistent else true,
- if (VERSION.SDK_INT >= 35) it.isAlwaysOn else true,
- it.id
- )
- }
- }
- @RequiresApi(28)
- fun buildApnSetting(config: ApnConfig): ApnSetting? {
- val builder = ApnSetting.Builder()
- builder.setCarrierEnabled(config.enabled)
- builder.setEntryName(config.name)
- builder.setApnName(config.apn)
- if (VERSION.SDK_INT >= 29) builder.setProxyAddress(config.proxy)
- else builder.setProxyAddress(InetAddress.getByName(config.proxy))
- config.port?.let { builder.setProxyPort(it) }
- builder.setUser(config.username)
- builder.setPassword(config.password)
- builder.setApnTypeBitmask(config.apnType)
- builder.setMmsc(config.mmsc.toUri())
- if (VERSION.SDK_INT >= 29) builder.setMmsProxyAddress(config.mmsProxy)
- else builder.setMmsProxyAddress(InetAddress.getByName(config.mmsProxy))
- builder.setAuthType(config.authType.id)
- builder.setProtocol(config.protocol.id)
- builder.setRoamingProtocol(config.roamingProtocol.id)
- builder.setNetworkTypeBitmask(config.networkType)
- if (VERSION.SDK_INT >= 33) config.profileId?.let { builder.setProfileId(it) }
- if (VERSION.SDK_INT >= 29) config.carrierId?.let { builder.setCarrierId(it) }
- if (VERSION.SDK_INT >= 33) {
- config.mtuV4?.let { builder.setMtuV4(it) }
- config.mtuV6?.let { builder.setMtuV6(it) }
- }
- builder.setMvnoType(config.mvno.id)
- builder.setOperatorNumeric(config.operatorNumeric)
- if (VERSION.SDK_INT >= 33) builder.setPersistent(config.persistent)
- if (VERSION.SDK_INT >= 35) builder.setAlwaysOn(config.alwaysOn)
- return builder.build()
- }
- @RequiresApi(28)
- fun setApnConfig(config: ApnConfig): Boolean {
- val settings = buildApnSetting(config)
- if (settings == null) return false
- return if (config.id == -1) {
- DPM.addOverrideApn(DAR, settings) != -1
- } else {
- DPM.updateOverrideApn(DAR, config.id, settings)
- }
- }
- @RequiresApi(28)
- fun removeApnConfig(id: Int): Boolean {
- return DPM.removeOverrideApn(DAR, id)
- }
- @RequiresApi(26)
- fun getNetworkLoggingEnabled(): Boolean {
- return DPM.isNetworkLoggingEnabled(DAR)
- }
- @RequiresApi(26)
- fun setNetworkLoggingEnabled(enabled: Boolean) {
- DPM.setNetworkLoggingEnabled(DAR, enabled)
- }
- fun getNetworkLogsCount(): Int {
- return myRepo.getNetworkLogsCount().toInt()
- }
- fun exportNetworkLogs(uri: Uri, callback: () -> Unit) {
- viewModelScope.launch(Dispatchers.IO) {
- application.contentResolver.openOutputStream(uri)?.use {
- myRepo.exportNetworkLogs(it)
- }
- withContext(Dispatchers.Main) { callback() }
- }
- }
- fun deleteNetworkLogs() {
- myRepo.deleteNetworkLogs()
- }
-
- @RequiresApi(29)
- fun getPasswordComplexity(): PasswordComplexity {
- val complexity = DPM.passwordComplexity
- return PasswordComplexity.entries.find { it.id == complexity }!!
- }
- fun isPasswordComplexitySufficient(): Boolean {
- return DPM.isActivePasswordSufficient
- }
- @RequiresApi(28)
- fun isUsingUnifiedPassword(): Boolean {
- return DPM.isUsingUnifiedPassword(DAR)
- }
- // Reset password token
- @RequiresApi(26)
- fun getRpTokenState(): RpTokenState {
- return try {
- RpTokenState(true, DPM.isResetPasswordTokenActive(DAR))
- } catch (_: IllegalStateException) {
- RpTokenState(false, false)
- }
- }
- @RequiresApi(26)
- fun setRpToken(token: String): Boolean {
- return try {
- DPM.setResetPasswordToken(DAR, token.encodeToByteArray())
- } catch (e: Exception) {
- e.printStackTrace()
- false
- }
- }
- @RequiresApi(26)
- fun clearRpToken(): Boolean {
- return DPM.clearResetPasswordToken(DAR)
- }
- @RequiresApi(26)
- fun createActivateRpTokenIntent(): Intent? {
- val km = application.getSystemService(KeyguardManager::class.java)
- val title = application.getString(R.string.activate_reset_password_token)
- return km.createConfirmDeviceCredentialIntent(title, "")
- }
- fun resetPassword(password: String, token: String, flags: Int): Boolean {
- return if (VERSION.SDK_INT >= 26) {
- DPM.resetPasswordWithToken(DAR, password, token.encodeToByteArray(), flags)
- } else {
- DPM.resetPassword(password, flags)
- }
- }
- @RequiresApi(31)
- fun getRequiredPasswordComplexity(): PasswordComplexity {
- val complexity = DPM.requiredPasswordComplexity
- return PasswordComplexity.entries.find { it.id == complexity }!!
- }
- @RequiresApi(31)
- fun setRequiredPasswordComplexity(complexity: PasswordComplexity) {
- DPM.requiredPasswordComplexity = complexity.id
- }
- fun getKeyguardDisableConfig(): KeyguardDisableConfig {
- val flags = DPM.getKeyguardDisabledFeatures(DAR)
- val mode = when (flags) {
- DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -> KeyguardDisableMode.None
- DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -> KeyguardDisableMode.All
- else -> KeyguardDisableMode.Custom
- }
- return KeyguardDisableConfig(mode, flags)
- }
- fun setKeyguardDisableConfig(config: KeyguardDisableConfig) {
- val flags = when (config.mode) {
- KeyguardDisableMode.None -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE
- KeyguardDisableMode.All -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL
- else -> config.flags
- }
- DPM.setKeyguardDisabledFeatures(DAR, flags)
- }
- fun getMaxTimeToLock(): Long {
- return DPM.getMaximumTimeToLock(DAR)
- }
- @RequiresApi(26)
- fun getRequiredStrongAuthTimeout(): Long {
- return DPM.getRequiredStrongAuthTimeout(DAR)
- }
- fun getPasswordExpirationTimeout(): Long {
- return DPM.getPasswordExpirationTimeout(DAR)
- }
- fun getMaxFailedPasswordsForWipe(): Int {
- return DPM.getMaximumFailedPasswordsForWipe(DAR)
- }
- fun getPasswordHistoryLength(): Int {
- return DPM.getPasswordHistoryLength(DAR)
- }
- fun setMaxTimeToLock(time: Long) {
- DPM.setMaximumTimeToLock(DAR, time)
- }
- @RequiresApi(26)
- fun setRequiredStrongAuthTimeout(time: Long) {
- DPM.setRequiredStrongAuthTimeout(DAR, time)
- }
- fun setPasswordExpirationTimeout(time: Long) {
- DPM.setPasswordExpirationTimeout(DAR, time)
- }
- fun setMaxFailedPasswordsForWipe(times: Int) {
- DPM.setMaximumFailedPasswordsForWipe(DAR, times)
- }
- fun setPasswordHistoryLength(length: Int) {
- DPM.setPasswordHistoryLength(DAR, length)
- }
-}
-
-data class ThemeSettings(
- val materialYou: Boolean = false,
- val darkTheme: Int = -1,
- val blackTheme: Boolean = false
-)
diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModelFactory.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModelFactory.kt
new file mode 100644
index 0000000..7c67269
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModelFactory.kt
@@ -0,0 +1,150 @@
+package com.bintianqi.owndroid
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import com.bintianqi.owndroid.feature.applications.AppChooserViewModel
+import com.bintianqi.owndroid.feature.applications.AppFeaturesViewModel
+import com.bintianqi.owndroid.feature.applications.AppGroup
+import com.bintianqi.owndroid.feature.applications.AppGroupRepository
+import com.bintianqi.owndroid.feature.applications.AppGroupViewModel
+import com.bintianqi.owndroid.feature.network.NetworkLoggingRepository
+import com.bintianqi.owndroid.feature.network.NetworkLoggingViewModel
+import com.bintianqi.owndroid.feature.network.NetworkStatsViewModel
+import com.bintianqi.owndroid.feature.network.NetworkViewModel
+import com.bintianqi.owndroid.feature.network.OverrideApnViewModel
+import com.bintianqi.owndroid.feature.network.PreferentialNetworkViewModel
+import com.bintianqi.owndroid.feature.network.WifiViewModel
+import com.bintianqi.owndroid.feature.password.PasswordViewModel
+import com.bintianqi.owndroid.feature.privilege.DelegatedAdminsViewModel
+import com.bintianqi.owndroid.feature.privilege.DhizukuServerRepository
+import com.bintianqi.owndroid.feature.privilege.DhizukuServerViewModel
+import com.bintianqi.owndroid.feature.privilege.TransferOwnershipViewModel
+import com.bintianqi.owndroid.feature.privilege.WorkingModesViewModel
+import com.bintianqi.owndroid.feature.settings.MySettings
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
+import com.bintianqi.owndroid.feature.settings.SettingsViewModel
+import com.bintianqi.owndroid.feature.system.CaCertViewModel
+import com.bintianqi.owndroid.feature.system.HardwareMonitorViewModel
+import com.bintianqi.owndroid.feature.system.LockTaskModeViewModel
+import com.bintianqi.owndroid.feature.system.SecurityLoggingRepository
+import com.bintianqi.owndroid.feature.system.SecurityLoggingViewModel
+import com.bintianqi.owndroid.feature.system.SystemOptionsViewModel
+import com.bintianqi.owndroid.feature.system.SystemUpdateViewModel
+import com.bintianqi.owndroid.feature.system.SystemViewModel
+import com.bintianqi.owndroid.feature.system.TimeViewModel
+import com.bintianqi.owndroid.feature.user_restriction.UserRestrictionViewModel
+import com.bintianqi.owndroid.feature.users.UsersViewModel
+import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterRepository
+import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterViewModel
+import com.bintianqi.owndroid.feature.work_profile.WorkProfileViewModel
+import com.bintianqi.owndroid.utils.DhizukuError
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlin.reflect.KClass
+
+class MyViewModelFactory(
+ val app: MyApplication, val ph: PrivilegeHelper,
+ val sr: SettingsRepository, val nlRepo: NetworkLoggingRepository,
+ val dsRepo: DhizukuServerRepository, val slRepo: SecurityLoggingRepository,
+ val agRepo: AppGroupRepository, val cpifRepo: CrossProfileIntentFilterRepository,
+ val agState: MutableStateFlow>,
+ val de: MutableStateFlow, val ps: MutableStateFlow,
+ val ts: MutableStateFlow, val tc: ToastChannel
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ fun checkType(cls: KClass<*>): Boolean {
+ return cls.java.isAssignableFrom(modelClass)
+ }
+ if (checkType(NetworkStatsViewModel::class)) {
+ return NetworkStatsViewModel(app, ps) as T
+ }
+ if (checkType(WifiViewModel::class)) {
+ return WifiViewModel(app, ph, tc, ps) as T
+ }
+ if (checkType(OverrideApnViewModel::class)) {
+ return OverrideApnViewModel(ph, tc) as T
+ }
+ if (checkType(PreferentialNetworkViewModel::class)) {
+ return PreferentialNetworkViewModel(ph) as T
+ }
+ if (checkType(NetworkLoggingViewModel::class)) {
+ return NetworkLoggingViewModel(app, ph, nlRepo) as T
+ }
+ if (checkType(NetworkViewModel::class)) {
+ return NetworkViewModel(ph, tc, ps) as T
+ }
+
+ if (checkType(DelegatedAdminsViewModel::class)) {
+ return DelegatedAdminsViewModel(app, ph) as T
+ }
+ if (checkType(TransferOwnershipViewModel::class)) {
+ return TransferOwnershipViewModel(app, ph, ps) as T
+ }
+ if (checkType(WorkingModesViewModel::class)) {
+ return WorkingModesViewModel(app, ph, sr, ps, tc) as T
+ }
+ if (checkType(DhizukuServerViewModel::class)) {
+ return DhizukuServerViewModel(app, dsRepo, sr) as T
+ }
+
+ if (checkType(SecurityLoggingViewModel::class)) {
+ return SecurityLoggingViewModel(app, ph, slRepo, tc) as T
+ }
+ if (checkType(CaCertViewModel::class)) {
+ return CaCertViewModel(app, ph, tc) as T
+ }
+ if (checkType(LockTaskModeViewModel::class)) {
+ return LockTaskModeViewModel(app, ph, tc) as T
+ }
+ if (checkType(SystemOptionsViewModel::class)) {
+ return SystemOptionsViewModel(app, ph, sr, ps) as T
+ }
+ if (checkType(SystemUpdateViewModel::class)) {
+ return SystemUpdateViewModel(app, ph, tc) as T
+ }
+ if (checkType(HardwareMonitorViewModel::class)) {
+ return HardwareMonitorViewModel(app) as T
+ }
+ if (checkType(TimeViewModel::class)) {
+ return TimeViewModel(ph, tc) as T
+ }
+ if (checkType(SystemViewModel::class)) {
+ return SystemViewModel(app, ph, sr, ps, tc) as T
+ }
+
+ if (checkType(AppGroupViewModel::class)) {
+ return AppGroupViewModel(app, agRepo, agState) as T
+ }
+ if (checkType(AppFeaturesViewModel::class)) {
+ return AppFeaturesViewModel(app, ph, ps, tc) as T
+ }
+ if (checkType(AppChooserViewModel::class)) {
+ return AppChooserViewModel(app) as T
+ }
+
+ if (checkType(WorkProfileViewModel::class)) {
+ return WorkProfileViewModel(ph, ps, tc) as T
+ }
+ if (checkType(CrossProfileIntentFilterViewModel::class)) {
+ return CrossProfileIntentFilterViewModel(app, ph, cpifRepo, tc) as T
+ }
+
+ if (checkType(UserRestrictionViewModel::class)) {
+ return UserRestrictionViewModel(app, ph, sr, ps, tc) as T
+ }
+
+ if (checkType(UsersViewModel::class)) {
+ return UsersViewModel(app, ph, tc, sr, ps) as T
+ }
+
+ if (checkType(PasswordViewModel::class)) {
+ return PasswordViewModel(app, ph, sr, ps, tc) as T
+ }
+
+ if (checkType(SettingsViewModel::class)) {
+ return SettingsViewModel(app, sr, ph, ps, tc, ts) as T
+ }
+ throw Exception("Unknown ViewModel")
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt
deleted file mode 100644
index 5b2f284..0000000
--- a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.bintianqi.owndroid
-
-import android.app.admin.DevicePolicyManager
-import android.content.ComponentName
-import android.content.Context
-import android.os.Binder
-import android.os.Build
-import com.bintianqi.owndroid.dpm.binderWrapperDevicePolicyManager
-import com.bintianqi.owndroid.dpm.dhizukuErrorStatus
-import com.rosan.dhizuku.api.Dhizuku
-import kotlinx.coroutines.flow.MutableStateFlow
-
-object Privilege {
- fun initialize(context: Context) {
- if (SP.dhizuku) {
- if (Dhizuku.init(context)) try {
- if (Dhizuku.isPermissionGranted()) {
- val dhizukuDpm = binderWrapperDevicePolicyManager(context)
- if (dhizukuDpm != null) {
- DPM = dhizukuDpm
- DAR = Dhizuku.getOwnerComponent()
- updateStatus()
- return
- }
- }
- } catch(e: Exception) {
- e.printStackTrace()
- }
- dhizukuErrorStatus.value = 2
- }
- DPM = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
- DAR = MyAdminComponent
- updateStatus()
- }
- lateinit var DPM: DevicePolicyManager
- private set
- lateinit var DAR: ComponentName
- private set
-
- data class Status(
- val device: Boolean = false,
- val profile: Boolean = false,
- val dhizuku: Boolean = false,
- val work: Boolean = false,
- val org: Boolean = false,
- val affiliated: Boolean = false
- ) {
- val activated = device || profile
- val primary = Binder.getCallingUid() / 100000 == 0 // Primary user
- }
- val status = MutableStateFlow(Status())
- fun updateStatus() {
- val profile = DPM.isProfileOwnerApp(DAR.packageName)
- val work = profile && Build.VERSION.SDK_INT >= 24 && DPM.isManagedProfile(DAR)
- status.value = Status(
- device = DPM.isDeviceOwnerApp(DAR.packageName),
- profile = profile,
- dhizuku = SP.dhizuku,
- work = work,
- org = work && Build.VERSION.SDK_INT >= 30 && DPM.isOrganizationOwnedDeviceWithManagedProfile,
- affiliated = Build.VERSION.SDK_INT >= 28 && DPM.isAffiliatedUser
- )
- }
-}
diff --git a/app/src/main/java/com/bintianqi/owndroid/PrivilegeHelper.kt b/app/src/main/java/com/bintianqi/owndroid/PrivilegeHelper.kt
new file mode 100644
index 0000000..31eb54e
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/PrivilegeHelper.kt
@@ -0,0 +1,50 @@
+package com.bintianqi.owndroid
+
+import android.app.admin.DevicePolicyManager
+import android.content.ComponentName
+import android.content.Context
+import com.bintianqi.owndroid.utils.DhizukuError
+import com.bintianqi.owndroid.utils.DhizukuException
+import com.bintianqi.owndroid.utils.binderWrapperDevicePolicyManager
+import com.rosan.dhizuku.api.Dhizuku
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class PrivilegeHelper(
+ val context: Context, var dhizuku: Boolean, val dhizukuError: MutableStateFlow
+) {
+ val myDpm = context.getSystemService(DevicePolicyManager::class.java)!!
+ val myDar = ComponentName(context, Receiver::class.java)
+
+ val dpm: DevicePolicyManager
+ get() {
+ return if (dhizuku) getDhizukuDpm() else myDpm
+ }
+
+ val dar: ComponentName
+ get() {
+ return if (dhizuku) Dhizuku.getOwnerComponent() else myDar
+ }
+
+ class SafeDpmCallScope(val dpm: DevicePolicyManager, val dar: ComponentName)
+
+ fun safeDpmCall(block: SafeDpmCallScope.() -> Unit) {
+ try {
+ SafeDpmCallScope(dpm, dar).block()
+ } catch (e: DhizukuException) {
+ dhizukuError.value = e.reason
+ }
+ }
+
+ private fun getDhizukuDpm(): DevicePolicyManager {
+ try {
+ if (!Dhizuku.init(context)) throw DhizukuException(DhizukuError.Init)
+ if (!Dhizuku.isPermissionGranted()) throw DhizukuException(DhizukuError.Permission)
+ return binderWrapperDevicePolicyManager(context)
+ } catch(e: Exception) {
+ if (e !is DhizukuException) {
+ throw DhizukuException(DhizukuError.Binder, e)
+ }
+ throw e
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt
index f5cd676..6707725 100644
--- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt
@@ -3,32 +3,26 @@ package com.bintianqi.owndroid
import android.app.admin.DeviceAdminReceiver
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 com.bintianqi.owndroid.dpm.handlePrivilegeChange
-import com.bintianqi.owndroid.dpm.retrieveNetworkLogs
-import com.bintianqi.owndroid.dpm.retrieveSecurityLogs
+import com.bintianqi.owndroid.utils.NotificationType
+import com.bintianqi.owndroid.utils.NotificationUtils
+import com.bintianqi.owndroid.utils.ShortcutUtils
+import com.bintianqi.owndroid.utils.formatDate
+import com.bintianqi.owndroid.utils.popToast
+import com.bintianqi.owndroid.utils.retrieveNetworkLogs
+import com.bintianqi.owndroid.utils.retrieveSecurityLogs
class Receiver : DeviceAdminReceiver() {
- override fun onEnabled(context: Context, intent: Intent) {
- super.onEnabled(context, intent)
- Privilege.updateStatus()
- if (Binder.getCallingUid() / 100000 != 0) handlePrivilegeChange(context)
- }
-
- override fun onDisabled(context: Context, intent: Intent) {
- super.onDisabled(context, intent)
- Privilege.updateStatus()
- }
-
override fun onProfileProvisioningComplete(context: Context, intent: Intent) {
super.onProfileProvisioningComplete(context, intent)
context.popToast(R.string.create_work_profile_success)
}
- override fun onNetworkLogsAvailable(context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int) {
+ override fun onNetworkLogsAvailable(
+ context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int
+ ) {
super.onNetworkLogsAvailable(context, intent, batchToken, networkLogsCount)
if (VERSION.SDK_INT >= 26) {
retrieveNetworkLogs(context.applicationContext as MyApplication, batchToken)
@@ -78,28 +72,34 @@ class Receiver : DeviceAdminReceiver() {
override fun onBugreportShared(context: Context, intent: Intent, hash: String) {
super.onBugreportShared(context, intent, hash)
- NotificationUtils.notifyEvent(context, NotificationType.BugReportShared, "SHA-256 hash: $hash")
+ NotificationUtils.notifyEvent(
+ context, getSr(context), NotificationType.BugReportShared, "SHA-256 hash: $hash"
+ )
}
override fun onBugreportSharingDeclined(context: Context, intent: Intent) {
super.onBugreportSharingDeclined(context, intent)
- NotificationUtils.notifyEvent(context, NotificationType.BugReportSharingDeclined, "")
+ NotificationUtils.notifyEvent(context, getSr(context), NotificationType.BugReportSharingDeclined, "")
}
override fun onBugreportFailed(context: Context, intent: Intent, failureCode: Int) {
super.onBugreportFailed(context, intent, failureCode)
- val message = when(failureCode) {
+ val message = when (failureCode) {
BUGREPORT_FAILURE_FAILED_COMPLETING -> R.string.bug_report_failure_failed_completing
BUGREPORT_FAILURE_FILE_NO_LONGER_AVAILABLE -> R.string.bug_report_failure_no_longer_available
else -> R.string.place_holder
}
- NotificationUtils.notifyEvent(context, NotificationType.BugReportFailed, context.getString(message))
+ NotificationUtils.notifyEvent(
+ context, getSr(context), NotificationType.BugReportFailed, context.getString(message)
+ )
}
override fun onSystemUpdatePending(context: Context, intent: Intent, receivedTime: Long) {
super.onSystemUpdatePending(context, intent, receivedTime)
val text = context.getString(R.string.received_time) + ": " + formatDate(receivedTime)
- NotificationUtils.notifyEvent(context, NotificationType.SystemUpdatePending, text)
+ NotificationUtils.notifyEvent(
+ context, getSr(context), NotificationType.SystemUpdatePending, text
+ )
}
private fun sendUserRelatedNotification(
@@ -108,6 +108,11 @@ class Receiver : DeviceAdminReceiver() {
val um = context.getSystemService(Context.USER_SERVICE) as UserManager
val serial = um.getSerialNumberForUser(userHandle)
val text = context.getString(R.string.serial_number) + ": $serial"
- NotificationUtils.notifyEvent(context, type, text)
+ NotificationUtils.notifyEvent(context, getSr(context), type, text)
+ }
+
+ companion object {
+ fun getSr(context: Context) =
+ (context.applicationContext as MyApplication).container.settingsRepo
}
}
diff --git a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt
deleted file mode 100644
index 27a880e..0000000
--- a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.bintianqi.owndroid
-
-import android.content.Context
-import android.content.SharedPreferences
-import android.os.Build
-import androidx.core.content.edit
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-class SharedPrefs(context: Context) {
- val sharedPrefs: SharedPreferences = context.getSharedPreferences("data", Context.MODE_PRIVATE)
- var managedProfileActivated by BooleanSharedPref("managed_profile_activated")
- var dhizuku by BooleanSharedPref("dhizuku_mode")
- var isDefaultAffiliationIdSet by BooleanSharedPref("default_affiliation_id_set")
- var displayDangerousFeatures by BooleanSharedPref("display_dangerous_features")
- var apiKeyHash by StringSharedPref("api_key_hash")
- var materialYou by BooleanSharedPref("theme.material_you", Build.VERSION.SDK_INT >= 31)
- /** -1: follow system, 0: off, 1: on */
- var darkTheme by IntSharedPref("theme.dark", -1)
- var blackTheme by BooleanSharedPref("theme.black")
- var lockPasswordHash by StringSharedPref("lock.password.sha256")
- var biometricsUnlock by BooleanSharedPref("lock.biometrics")
- var lockWhenLeaving by BooleanSharedPref("lock.onleave")
- var applicationsListView by BooleanSharedPref("applications.list_view", true)
- var shortcuts by BooleanSharedPref("shortcuts")
- var dhizukuServer by BooleanSharedPref("dhizuku_server")
- var notifications by StringSharedPref("notifications")
- var shortcutKey by StringSharedPref("shortcut_key")
-}
-
-private class BooleanSharedPref(val key: String, val defValue: Boolean = false): ReadWriteProperty {
- override fun getValue(thisRef: SharedPrefs, property: KProperty<*>): Boolean =
- thisRef.sharedPrefs.getBoolean(key, defValue)
- override fun setValue(thisRef: SharedPrefs, property: KProperty<*>, value: Boolean) =
- thisRef.sharedPrefs.edit(true) { putBoolean(key, value) }
-}
-
-private class StringSharedPref(val key: String): ReadWriteProperty {
- override fun getValue(thisRef: SharedPrefs, property: KProperty<*>): String? =
- thisRef.sharedPrefs.getString(key, null)
- override fun setValue(thisRef: SharedPrefs, property: KProperty<*>, value: String?) =
- thisRef.sharedPrefs.edit(true) { putString(key, value) }
-}
-
-private class IntSharedPref(val key: String, val defValue: Int = 0): ReadWriteProperty {
- override fun getValue(thisRef: SharedPrefs, property: KProperty<*>): Int =
- thisRef.sharedPrefs.getInt(key, defValue)
- override fun setValue(thisRef: SharedPrefs, property: KProperty<*>, value: Int) =
- thisRef.sharedPrefs.edit(true) { putInt(key, value) }
-}
diff --git a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt
index 99c5b0d..8d192c0 100644
--- a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt
@@ -7,8 +7,8 @@ import android.content.pm.PackageManager
import android.os.Bundle
import android.os.IBinder
import androidx.annotation.Keep
+import com.bintianqi.owndroid.utils.popToast
import rikka.shizuku.Shizuku
-import rikka.sui.Sui
import kotlin.system.exitProcess
@Keep
@@ -54,13 +54,14 @@ fun useShizuku(context: Context, action: (IBinder?) -> Unit) {
Shizuku.bindUserService(getShizukuArgs(context), connection)
} else if(Shizuku.shouldShowRequestPermissionRationale()) {
context.popToast(R.string.permission_denied)
+ action(null)
} else {
- Sui.init(context.packageName)
fun requestPermissionResultListener(requestCode: Int, grantResult: Int) {
if (grantResult == PackageManager.PERMISSION_GRANTED) {
Shizuku.bindUserService(getShizukuArgs(context), connection)
} else {
context.popToast(R.string.permission_denied)
+ action(null)
}
Shizuku.removeRequestPermissionResultListener(::requestPermissionResultListener)
}
@@ -69,5 +70,6 @@ fun useShizuku(context: Context, action: (IBinder?) -> Unit) {
}
} catch (e: Exception) {
e.printStackTrace()
+ action(null)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt
index 5586e20..7eac514 100644
--- a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt
@@ -3,46 +3,60 @@ package com.bintianqi.owndroid
import android.app.Activity
import android.os.Bundle
import android.util.Log
-import com.bintianqi.owndroid.ui.screen.UserOperationType
-import com.bintianqi.owndroid.dpm.doUserOperationWithContext
+import com.bintianqi.owndroid.feature.users.UserOperationType
+import com.bintianqi.owndroid.utils.doUserOperationWithContext
+import com.bintianqi.owndroid.utils.MyShortcut
+import com.bintianqi.owndroid.utils.ShortcutUtils
+import com.bintianqi.owndroid.utils.showOperationResultToast
class ShortcutsReceiverActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ val myApp = application as MyApplication
+ val context = this
+ val sr = myApp.container.settingsRepo
+ val ph = myApp.container.privilegeHelper
+ val settings = sr.data.shortcut
try {
val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.")
- val key = SP.shortcutKey
val requestKey = intent?.getStringExtra("key")
- if (action != null && key != null && requestKey == key) {
- when (action) {
- "LOCK" -> Privilege.DPM.lockNow()
- "DISABLE_CAMERA" -> {
- val state = Privilege.DPM.getCameraDisabled(Privilege.DAR)
- Privilege.DPM.setCameraDisabled(Privilege.DAR, !state)
- ShortcutUtils.setShortcut(this, MyShortcut.DisableCamera, state)
- }
- "MUTE" -> {
- val state = Privilege.DPM.isMasterVolumeMuted(Privilege.DAR)
- Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !state)
- ShortcutUtils.setShortcut(this, MyShortcut.Mute, state)
- }
- "USER_RESTRICTION" -> {
- val state = intent?.getBooleanExtra("state", false)
- val id = intent?.getStringExtra("restriction")
- if (state == null || id == null) return
- if (state) {
- Privilege.DPM.addUserRestriction(Privilege.DAR, id)
- } else {
- Privilege.DPM.clearUserRestriction(Privilege.DAR, id)
+ if (action != null && settings.enabled && requestKey == settings.key) {
+ ph.safeDpmCall {
+ when (action) {
+ "LOCK" -> dpm.lockNow()
+ "DISABLE_CAMERA" -> {
+ val state = dpm.getCameraDisabled(dar)
+ dpm.setCameraDisabled(dar, !state)
+ ShortcutUtils.setShortcut(context, sr, MyShortcut.DisableCamera, state)
+ }
+
+ "MUTE" -> {
+ val state = dpm.isMasterVolumeMuted(dar)
+ dpm.setMasterVolumeMuted(dar, !state)
+ ShortcutUtils.setShortcut(context, sr, MyShortcut.Mute, state)
+ }
+
+ "USER_RESTRICTION" -> {
+ val state = intent?.getBooleanExtra("state", false)
+ val id = intent?.getStringExtra("restriction")
+ if (state == null || id == null) return@safeDpmCall
+ if (state) {
+ dpm.addUserRestriction(dar, id)
+ } else {
+ dpm.clearUserRestriction(dar, id)
+ }
+ ShortcutUtils.updateUserRestrictionShortcut(
+ context, sr, id, !state, false
+ )
+ }
+
+ "USER_OPERATION" -> {
+ val typeName = intent.getStringExtra("operation") ?: return@safeDpmCall
+ val type = UserOperationType.valueOf(typeName)
+ val serial = intent.getIntExtra("serial", -1)
+ if (serial == -1) return@safeDpmCall
+ doUserOperationWithContext(context, ph.dpm, ph.dar, type, serial, false)
}
- ShortcutUtils.updateUserRestrictionShortcut(this, id, !state, false)
- }
- "USER_OPERATION" -> {
- val typeName = intent.getStringExtra("operation") ?: return
- val type = UserOperationType.valueOf(typeName)
- val serial = intent.getIntExtra("serial", -1)
- if (serial == -1) return
- doUserOperationWithContext(this, type, serial, false)
}
}
Log.d(TAG, "Received intent: $action")
@@ -50,12 +64,13 @@ class ShortcutsReceiverActivity : Activity() {
} else {
showOperationResultToast(false)
}
- } catch(e: Exception) {
+ } catch (e: Exception) {
e.printStackTrace()
} finally {
finish()
}
}
+
companion object {
private const val TAG = "ShortcutsReceiver"
}
diff --git a/app/src/main/java/com/bintianqi/owndroid/activity/CheckPolicyComplianceActivity.kt b/app/src/main/java/com/bintianqi/owndroid/activity/CheckPolicyComplianceActivity.kt
new file mode 100644
index 0000000..a19710c
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/activity/CheckPolicyComplianceActivity.kt
@@ -0,0 +1,41 @@
+package com.bintianqi.owndroid.activity
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.theme.OwnDroidTheme
+
+class CheckPolicyComplianceActivity: ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ val myApp = application as MyApplication
+ setContent {
+ val theme by myApp.container.themeState.collectAsState()
+ OwnDroidTheme(theme) {
+ AlertDialog(
+ text = {
+ Text(stringResource(R.string.info_personal_apps_suspended))
+ },
+ confirmButton = {
+ TextButton(::finish) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = {
+ finish()
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt b/app/src/main/java/com/bintianqi/owndroid/activity/ManageSpaceActivity.kt
similarity index 76%
rename from app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt
rename to app/src/main/java/com/bintianqi/owndroid/activity/ManageSpaceActivity.kt
index d756e9e..4600e96 100644
--- a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/activity/ManageSpaceActivity.kt
@@ -1,10 +1,9 @@
-package com.bintianqi.owndroid
+package com.bintianqi.owndroid.activity
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.activity.viewModels
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -16,20 +15,27 @@ import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.screen.AppLockDialog
import com.bintianqi.owndroid.ui.theme.OwnDroidTheme
+import com.bintianqi.owndroid.utils.showOperationResultToast
import kotlin.system.exitProcess
class ManageSpaceActivity: FragmentActivity() {
+ val myApp = application as MyApplication
+ val settingsRepo = myApp.container.settingsRepo
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
- val vm by viewModels()
setContent {
- val theme by vm.theme.collectAsStateWithLifecycle()
+ val theme by myApp.container.themeState.collectAsStateWithLifecycle()
OwnDroidTheme(theme) {
- var appLockDialog by remember { mutableStateOf(!SP.lockPasswordHash.isNullOrEmpty()) }
- if(appLockDialog) {
- AppLockDialog({ appLockDialog = false }, ::finish)
+ var appLockDialog by remember {
+ mutableStateOf(settingsRepo.data.appLock.passwordHash.isNotEmpty())
+ }
+ if (appLockDialog) {
+ AppLockDialog(settingsRepo.data.appLock, { appLockDialog = false }, ::finish)
} else {
AlertDialog(
text = {
@@ -67,4 +73,4 @@ class ManageSpaceActivity: FragmentActivity() {
finish()
exitProcess(0)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt
deleted file mode 100644
index 3b0eb62..0000000
--- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt
+++ /dev/null
@@ -1,600 +0,0 @@
-package com.bintianqi.owndroid.dpm
-
-import android.Manifest
-import android.annotation.SuppressLint
-import android.app.admin.ConnectEvent
-import android.app.admin.DevicePolicyManager
-import android.app.admin.DnsEvent
-import android.app.admin.IDevicePolicyManager
-import android.app.admin.SecurityLog
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.content.pm.IPackageInstaller
-import android.content.pm.PackageInstaller
-import android.os.Build.VERSION
-import android.os.UserHandle
-import android.os.UserManager
-import android.util.Log
-import androidx.annotation.RequiresApi
-import com.bintianqi.owndroid.AppInfo
-import com.bintianqi.owndroid.MyApplication
-import com.bintianqi.owndroid.NotificationType
-import com.bintianqi.owndroid.NotificationUtils
-import com.bintianqi.owndroid.Privilege
-import com.bintianqi.owndroid.Privilege.DAR
-import com.bintianqi.owndroid.Privilege.DPM
-import com.bintianqi.owndroid.R
-import com.bintianqi.owndroid.SP
-import com.bintianqi.owndroid.ShortcutUtils
-import com.bintianqi.owndroid.ui.screen.UserOperationType
-import com.rosan.dhizuku.api.Dhizuku
-import com.rosan.dhizuku.api.DhizukuBinderWrapper
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.launch
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonObject
-
-@SuppressLint("PrivateApi")
-fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager? {
- try {
- val context = appContext.createPackageContext(Dhizuku.getOwnerComponent().packageName, Context.CONTEXT_IGNORE_SECURITY)
- val manager = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
- val field = manager.javaClass.getDeclaredField("mService")
- field.isAccessible = true
- val oldInterface = field[manager] as IDevicePolicyManager
- if (oldInterface is DhizukuBinderWrapper) return manager
- val oldBinder = oldInterface.asBinder()
- val newBinder = Dhizuku.binderWrapper(oldBinder)
- val newInterface = IDevicePolicyManager.Stub.asInterface(newBinder)
- field[manager] = newInterface
- return manager
- } catch (e: Exception) {
- e.printStackTrace()
- dhizukuErrorStatus.value = 1
- }
- return null
-}
-
-@SuppressLint("PrivateApi")
-private fun binderWrapperPackageInstaller(appContext: Context): PackageInstaller? {
- try {
- val context = appContext.createPackageContext(Dhizuku.getOwnerComponent().packageName, Context.CONTEXT_IGNORE_SECURITY)
- val installer = context.packageManager.packageInstaller
- val field = installer.javaClass.getDeclaredField("mInstaller")
- field.isAccessible = true
- val oldInterface = field[installer] as IPackageInstaller
- if (oldInterface is DhizukuBinderWrapper) return installer
- val oldBinder = oldInterface.asBinder()
- val newBinder = Dhizuku.binderWrapper(oldBinder)
- val newInterface = IPackageInstaller.Stub.asInterface(newBinder)
- field[installer] = newInterface
- return installer
- } catch (_: Exception) {
- dhizukuErrorStatus.value = 1
- }
- return null
-}
-
-fun Context.getPackageInstaller(): PackageInstaller {
- if(SP.dhizuku) {
- if (!dhizukuPermissionGranted()) {
- dhizukuErrorStatus.value = 2
- return this.packageManager.packageInstaller
- }
- return binderWrapperPackageInstaller(this) ?: this.packageManager.packageInstaller
- } else {
- return this.packageManager.packageInstaller
- }
-}
-
-val dhizukuErrorStatus = MutableStateFlow(0)
-
-data class PermissionItem(
- val id: String,
- val label: Int,
- val icon: Int,
- val profileOwnerRestricted: Boolean = false,
- val requiresApi: Int = 23
-)
-
-@Suppress("InlinedApi")
-val runtimePermissions = listOf(
- PermissionItem(Manifest.permission.POST_NOTIFICATIONS, R.string.permission_POST_NOTIFICATIONS, R.drawable.notifications_fill0, requiresApi = 33),
- PermissionItem(Manifest.permission.READ_EXTERNAL_STORAGE, R.string.permission_READ_EXTERNAL_STORAGE, R.drawable.folder_fill0),
- PermissionItem(Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_WRITE_EXTERNAL_STORAGE, R.drawable.folder_fill0),
- PermissionItem(Manifest.permission.READ_MEDIA_AUDIO, R.string.permission_READ_MEDIA_AUDIO, R.drawable.music_note_fill0, requiresApi = 33),
- PermissionItem(Manifest.permission.READ_MEDIA_VIDEO, R.string.permission_READ_MEDIA_VIDEO, R.drawable.movie_fill0, requiresApi = 33),
- PermissionItem(Manifest.permission.READ_MEDIA_IMAGES, R.string.permission_READ_MEDIA_IMAGES, R.drawable.image_fill0, requiresApi = 33),
- PermissionItem(Manifest.permission.CAMERA, R.string.permission_CAMERA, R.drawable.photo_camera_fill0, true),
- PermissionItem(Manifest.permission.RECORD_AUDIO, R.string.permission_RECORD_AUDIO, R.drawable.mic_fill0, true),
- PermissionItem(Manifest.permission.ACCESS_COARSE_LOCATION, R.string.permission_ACCESS_COARSE_LOCATION, R.drawable.location_on_fill0, true),
- PermissionItem(Manifest.permission.ACCESS_FINE_LOCATION, R.string.permission_ACCESS_FINE_LOCATION, R.drawable.location_on_fill0, true),
- PermissionItem(Manifest.permission.ACCESS_BACKGROUND_LOCATION, R.string.permission_ACCESS_BACKGROUND_LOCATION, R.drawable.location_on_fill0, true, 29),
- PermissionItem(Manifest.permission.READ_CONTACTS, R.string.permission_READ_CONTACTS, R.drawable.contacts_fill0),
- PermissionItem(Manifest.permission.WRITE_CONTACTS, R.string.permission_WRITE_CONTACTS, R.drawable.contacts_fill0),
- PermissionItem(Manifest.permission.READ_CALENDAR, R.string.permission_READ_CALENDAR, R.drawable.calendar_month_fill0),
- PermissionItem(Manifest.permission.WRITE_CALENDAR, R.string.permission_WRITE_CALENDAR, R.drawable.calendar_month_fill0),
- PermissionItem(Manifest.permission.BLUETOOTH_CONNECT, R.string.permission_BLUETOOTH_CONNECT, R.drawable.bluetooth_fill0, requiresApi = 31),
- PermissionItem(Manifest.permission.BLUETOOTH_SCAN, R.string.permission_BLUETOOTH_SCAN, R.drawable.bluetooth_searching_fill0, requiresApi = 31),
- PermissionItem(Manifest.permission.BLUETOOTH_ADVERTISE, R.string.permission_BLUETOOTH_ADVERTISE, R.drawable.bluetooth_fill0, requiresApi = 31),
- PermissionItem(Manifest.permission.NEARBY_WIFI_DEVICES, R.string.permission_NEARBY_WIFI_DEVICES, R.drawable.wifi_fill0, requiresApi = 33),
- PermissionItem(Manifest.permission.CALL_PHONE, R.string.permission_CALL_PHONE, R.drawable.call_fill0),
- PermissionItem(Manifest.permission.ANSWER_PHONE_CALLS, R.string.permission_ANSWER_PHONE_CALLS, R.drawable.call_fill0, requiresApi = 26),
- PermissionItem(Manifest.permission.READ_PHONE_NUMBERS, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0, requiresApi = 26),
- PermissionItem(Manifest.permission.READ_PHONE_STATE, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0),
- PermissionItem(Manifest.permission.USE_SIP, R.string.permission_USE_SIP, R.drawable.call_fill0),
- PermissionItem(Manifest.permission.UWB_RANGING, R.string.permission_UWB_RANGING, R.drawable.cell_tower_fill0, requiresApi = 31),
- PermissionItem(Manifest.permission.READ_SMS, R.string.permission_READ_SMS, R.drawable.sms_fill0),
- PermissionItem(Manifest.permission.RECEIVE_SMS, R.string.permission_RECEIVE_SMS, R.drawable.sms_fill0),
- PermissionItem(Manifest.permission.SEND_SMS, R.string.permission_SEND_SMS, R.drawable.sms_fill0),
- PermissionItem(Manifest.permission.READ_CALL_LOG, R.string.permission_READ_CALL_LOG, R.drawable.call_log_fill0),
- PermissionItem(Manifest.permission.WRITE_CALL_LOG, R.string.permission_WRITE_CALL_LOG, R.drawable.call_log_fill0),
- PermissionItem(Manifest.permission.RECEIVE_WAP_PUSH, R.string.permission_RECEIVE_WAP_PUSH, R.drawable.wifi_fill0),
- PermissionItem(Manifest.permission.BODY_SENSORS, R.string.permission_BODY_SENSORS, R.drawable.sensors_fill0, true),
- PermissionItem(Manifest.permission.BODY_SENSORS_BACKGROUND, R.string.permission_BODY_SENSORS_BACKGROUND, R.drawable.sensors_fill0, requiresApi = 33),
- PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true, 29)
-).filter { VERSION.SDK_INT >= it.requiresApi }
-
-@Serializable
-class NetworkLog(
- val id: Long?, @SerialName("package") val packageName: String, val time: Long, val type: String,
- val host: String?, val count: Int?, val addresses: List?,
- val address: String?, val port: Int?
-)
-
-@RequiresApi(26)
-fun retrieveNetworkLogs(app: MyApplication, token: Long) {
- CoroutineScope(Dispatchers.IO).launch {
- val logs = DPM.retrieveNetworkLogs(DAR, token)?.mapNotNull {
- when (it) {
- is DnsEvent -> NetworkLog(
- if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, "dns",
- it.hostname, it.totalResolvedAddressCount,
- it.inetAddresses.mapNotNull { address -> address.hostAddress }, null, null
- )
- is ConnectEvent -> NetworkLog(
- if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp,
- "connect", null, null, null, it.inetAddress.hostAddress, it.port
- )
- else -> null
- }
- }
- if (logs.isNullOrEmpty()) return@launch
- app.myRepo.writeNetworkLogs(logs)
- NotificationUtils.sendBasicNotification(
- app, NotificationType.NetworkLogsCollected,
- app.getString(R.string.n_logs_in_total, logs.size)
- )
- }
-}
-
-@Serializable
-class SecurityEvent(
- val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: JsonObject?
-)
-
-@Serializable
-class SecurityEventWithData(
- val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: SecurityEventData?
-)
-
-@Serializable
-sealed class SecurityEventData {
- @Serializable
- class AdbShellCmd(val command: String): SecurityEventData()
- @Serializable
- class AppProcessStart(
- val name: String,
- val time: Long,
- val uid: Int,
- val pid: Int,
- val seinfo: String,
- val hash: String
- ): SecurityEventData()
- @Serializable
- class BackupServiceToggled(
- val admin: String,
- val user: Int,
- val state: Int
- ): SecurityEventData()
- @Serializable
- class BluetoothConnection(
- val mac: String,
- val successful: Int,
- @SerialName("failure_reason") val failureReason: String
- ): SecurityEventData()
- @Serializable
- class BluetoothDisconnection(
- val mac: String,
- val reason: String
- ): SecurityEventData()
- @Serializable
- class CameraPolicySet(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val disabled: Int
- ): SecurityEventData()
- @Serializable
- class CaInstalledRemoved(
- val result: Int,
- val subject: String,
- val user: Int
- ): SecurityEventData()
- @Serializable
- class CertValidationFailure(val reason: String): SecurityEventData()
- @Serializable
- class CryptoSelfTestCompleted(val result: Int): SecurityEventData()
- @Serializable
- class KeyguardDisabledFeaturesSet(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val mask: Int
- ): SecurityEventData()
- @Serializable
- class KeyguardDismissAuthAttempt(
- val result: Int,
- val strength: Int
- ): SecurityEventData()
- @Serializable
- class KeyGeneratedImportDestruction(
- val result: Int,
- val alias: String,
- val uid: Int
- ): SecurityEventData()
- @Serializable
- class KeyIntegrityViolation(
- val alias: String,
- val uid: Int
- ): SecurityEventData()
- @Serializable
- class MaxPasswordAttemptsSet(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val value: Int
- ): SecurityEventData()
- @Serializable
- class MaxScreenLockTimeoutSet(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val timeout: Long
- ): SecurityEventData()
- @Serializable
- class MediaMountUnmount(
- @SerialName("mount_point") val mountPoint: String,
- val label: String
- ): SecurityEventData()
- @Serializable
- class OsStartup(
- @SerialName("verified_boot_state") val verifiedBootState: String,
- @SerialName("dm_verity_mode") val dmVerityMode: String
- ): SecurityEventData()
- @Serializable
- class PackageInstalledUninstalledUpdated(
- val name: String,
- val version: Long,
- val user: Int
- ): SecurityEventData()
- @Serializable
- class PasswordChanged(
- val complexity: Int,
- val user: Int
- ): SecurityEventData()
- @Serializable
- class PasswordComplexityRequired(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val complexity: Int
- ): SecurityEventData()
- @Serializable
- class PasswordComplexitySet(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val length: Int,
- val quality: Int,
- val letters: Int,
- @SerialName("non_letters") val nonLetters: Int,
- val digits: Int,
- val uppercase: Int,
- val lowercase: Int,
- val symbols: Int
- ): SecurityEventData()
- @Serializable
- class PasswordExpirationSet(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val expiration: Long
- ): SecurityEventData()
- @Serializable
- class PasswordHistoryLengthSet(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- val length: Int
- ): SecurityEventData()
- @Serializable
- class RemoteLock(
- val admin: String,
- @SerialName("admin_user") val adminUser: Int,
- @SerialName("target_user") val targetUser: Int,
- ): SecurityEventData()
- @Serializable
- class SyncRecvSendFile(val path: String): SecurityEventData()
- @Serializable
- class UserRestrictionAddedRemoved(
- val admin: String,
- val user: Int,
- val restriction: String
- ): SecurityEventData()
- @Serializable
- class WifiConnection(
- val bssid: String,
- val type: String,
- @SerialName("failure_reason") val failureReason: String
- ): SecurityEventData()
- @Serializable
- class WifiDisconnection(
- val bssid: String,
- val reason: String
- ): SecurityEventData()
-}
-
-fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? {
- return when(tag) {
- SecurityLog.TAG_ADB_SHELL_CMD -> SecurityEventData.AdbShellCmd(payload as String)
- SecurityLog.TAG_ADB_SHELL_INTERACTIVE -> null
- SecurityLog.TAG_APP_PROCESS_START -> {
- val data = payload as Array<*>
- SecurityEventData.AppProcessStart(
- data[0] as String, data[1] as Long, data[2] as Int, data[3] as Int,
- data[4] as String, data[5] as String
- )
- }
- SecurityLog.TAG_BACKUP_SERVICE_TOGGLED -> {
- val data = payload as Array<*>
- SecurityEventData.BackupServiceToggled(data[0] as String, data[1] as Int, data[2] as Int)
- }
- SecurityLog.TAG_BLUETOOTH_CONNECTION -> {
- val data = payload as Array<*>
- SecurityEventData.BluetoothConnection(data[0] as String, data[1] as Int, data[2] as String)
- }
- SecurityLog.TAG_BLUETOOTH_DISCONNECTION -> {
- val data = payload as Array<*>
- SecurityEventData.BluetoothDisconnection(data[0] as String, data[1] as String)
- }
- SecurityLog.TAG_CAMERA_POLICY_SET -> {
- val data = payload as Array<*>
- SecurityEventData.CameraPolicySet(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
- )
- }
- SecurityLog.TAG_CERT_AUTHORITY_INSTALLED, SecurityLog.TAG_CERT_AUTHORITY_REMOVED -> {
- val data = payload as Array<*>
- SecurityEventData.CaInstalledRemoved(data[0] as Int, data[1] as String, data[2] as Int)
- }
- SecurityLog.TAG_CERT_VALIDATION_FAILURE ->
- SecurityEventData.CertValidationFailure(payload as String)
- SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED ->
- SecurityEventData.CryptoSelfTestCompleted(payload as Int)
- SecurityLog.TAG_KEYGUARD_DISABLED_FEATURES_SET -> {
- val data = payload as Array<*>
- SecurityEventData.KeyguardDisabledFeaturesSet(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
- )
- }
- SecurityLog.TAG_KEYGUARD_DISMISSED -> null
- SecurityLog.TAG_KEYGUARD_DISMISS_AUTH_ATTEMPT -> {
- val data = payload as Array<*>
- SecurityEventData.KeyguardDismissAuthAttempt(data[0] as Int, data[1] as Int)
- }
- SecurityLog.TAG_KEYGUARD_SECURED -> null
- SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT, SecurityLog.TAG_KEY_DESTRUCTION -> {
- val data = payload as Array<*>
- SecurityEventData.KeyGeneratedImportDestruction(
- data[0] as Int, data[1] as String, data[2] as Int
- )
- }
- SecurityLog.TAG_LOGGING_STARTED, SecurityLog.TAG_LOGGING_STOPPED -> null
- SecurityLog.TAG_LOG_BUFFER_SIZE_CRITICAL -> null
- SecurityLog.TAG_MAX_PASSWORD_ATTEMPTS_SET -> {
- val data = payload as Array<*>
- SecurityEventData.MaxPasswordAttemptsSet(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
- )
- }
- SecurityLog.TAG_MAX_SCREEN_LOCK_TIMEOUT_SET -> {
- val data = payload as Array<*>
- SecurityEventData.MaxScreenLockTimeoutSet(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long
- )
- }
- SecurityLog.TAG_MEDIA_MOUNT, SecurityLog.TAG_MEDIA_UNMOUNT -> {
- val data = payload as Array<*>
- SecurityEventData.MediaMountUnmount(data[0] as String, data[1] as String)
- }
- SecurityLog.TAG_NFC_ENABLED, SecurityLog.TAG_NFC_DISABLED -> null
- SecurityLog.TAG_OS_SHUTDOWN -> null
- SecurityLog.TAG_OS_STARTUP -> {
- val data = payload as Array<*>
- SecurityEventData.OsStartup(data[0] as String, data[1] as String)
- }
- SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UPDATED,
- SecurityLog.TAG_PACKAGE_UNINSTALLED -> {
- val data = payload as Array<*>
- SecurityEventData.PackageInstalledUninstalledUpdated(
- data[0] as String, data[1] as Long, data[2] as Int
- )
- }
- SecurityLog.TAG_PASSWORD_CHANGED -> {
- val data = payload as Array<*>
- SecurityEventData.PasswordChanged(data[0] as Int, data[1] as Int)
- }
- SecurityLog.TAG_PASSWORD_COMPLEXITY_REQUIRED -> {
- val data = payload as Array<*>
- SecurityEventData.PasswordComplexityRequired(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
- )
- }
- SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> {
- val data = payload as Array<*>
- SecurityEventData.PasswordComplexitySet(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int, data[4] as Int,
- data[5] as Int, data[6] as Int, data[7] as Int, data[8] as Int, data[9] as Int,
- data[10] as Int
- )
- }
- SecurityLog.TAG_PASSWORD_EXPIRATION_SET -> {
- val data = payload as Array<*>
- SecurityEventData.PasswordExpirationSet(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long
- )
- }
- SecurityLog.TAG_PASSWORD_HISTORY_LENGTH_SET -> {
- val data = payload as Array<*>
- SecurityEventData.PasswordHistoryLengthSet(
- data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
- )
- }
- SecurityLog.TAG_REMOTE_LOCK -> {
- val data = payload as Array<*>
- SecurityEventData.RemoteLock(data[0] as String, data[1] as Int, data[2] as Int)
- }
- SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE ->
- SecurityEventData.SyncRecvSendFile(payload as String)
- SecurityLog.TAG_USER_RESTRICTION_ADDED, SecurityLog.TAG_USER_RESTRICTION_REMOVED -> {
- val data = payload as Array<*>
- SecurityEventData.UserRestrictionAddedRemoved(
- data[0] as String, data[1] as Int, data[2] as String
- )
- }
- SecurityLog.TAG_WIFI_CONNECTION -> {
- val data = payload as Array<*>
- SecurityEventData.WifiConnection(data[0] as String, data[1] as String, data[2] as String)
- }
- SecurityLog.TAG_WIFI_DISCONNECTION -> {
- val data = payload as Array<*>
- SecurityEventData.WifiDisconnection(data[0] as String, data[1] as String)
- }
- SecurityLog.TAG_WIPE_FAILURE -> null
- else -> null
- }
-}
-
-@RequiresApi(24)
-fun retrieveSecurityLogs(app: MyApplication) {
- CoroutineScope(Dispatchers.IO).launch {
- val logs = DPM.retrieveSecurityLogs(DAR)
- if (logs.isNullOrEmpty()) return@launch
- app.myRepo.writeSecurityLogs(logs)
- NotificationUtils.sendBasicNotification(
- app, NotificationType.SecurityLogsCollected,
- app.getString(R.string.n_logs_in_total, logs.size)
- )
- }
-}
-
-fun setDefaultAffiliationID() {
- if (VERSION.SDK_INT < 26) return
- if(!SP.isDefaultAffiliationIdSet) {
- try {
- DPM.setAffiliationIds(DAR, setOf("OwnDroid_default_affiliation_id"))
- SP.isDefaultAffiliationIdSet = true
- Log.d("DPM", "Default affiliation id set")
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-}
-
-fun dhizukuPermissionGranted() =
- try {
- Dhizuku.isPermissionGranted()
- } catch(_: Exception) {
- false
- }
-
-fun parsePackageInstallerMessage(context: Context, result: Intent): String {
- val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999)
- val statusMessage = result.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
- val otherPackageName = result.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME)
- return when(status) {
- PackageInstaller.STATUS_FAILURE_BLOCKED ->
- context.getString(
- R.string.status_failure_blocked,
- otherPackageName ?: context.getString(R.string.unknown)
- )
- PackageInstaller.STATUS_FAILURE_ABORTED ->
- context.getString(R.string.status_failure_aborted)
- PackageInstaller.STATUS_FAILURE_INVALID ->
- context.getString(R.string.status_failure_invalid)
- PackageInstaller.STATUS_FAILURE_CONFLICT ->
- context.getString(R.string.status_failure_conflict, otherPackageName ?: "???")
- PackageInstaller.STATUS_FAILURE_STORAGE ->
- context.getString(R.string.status_failure_storage) +
- result.getStringExtra(PackageInstaller.EXTRA_STORAGE_PATH).let { if(it == null) "" else "\n$it" }
- PackageInstaller.STATUS_FAILURE_INCOMPATIBLE ->
- context.getString(R.string.status_failure_incompatible)
- PackageInstaller.STATUS_FAILURE_TIMEOUT ->
- context.getString(R.string.timeout)
- else -> ""
- } + statusMessage.let { if(it == null) "" else "\n$it" }
-}
-
-
-fun handlePrivilegeChange(context: Context) {
- val privilege = Privilege.status.value
- SP.dhizukuServer = false
- SP.shortcuts = privilege.activated
- if (privilege.activated) {
- ShortcutUtils.setAllShortcuts(context, true)
- if (!privilege.dhizuku) {
- setDefaultAffiliationID()
- }
- } else {
- SP.isDefaultAffiliationIdSet = false
- ShortcutUtils.setAllShortcuts(context, false)
- SP.apiKeyHash = ""
- }
-}
-
-fun doUserOperationWithContext(
- context: Context, type: UserOperationType, id: Int, isUserId: Boolean
-): Boolean {
- val um = context.getSystemService(Context.USER_SERVICE) as UserManager
- val handle = if (isUserId && VERSION.SDK_INT >= 24) {
- UserHandle.getUserHandleForUid(id * 100000)
- } else {
- um.getUserForSerialNumber(id.toLong())
- }
- if (handle == null) return false
- return when (type) {
- UserOperationType.Start -> {
- if (VERSION.SDK_INT >= 28)
- DPM.startUserInBackground(DAR, handle) == UserManager.USER_OPERATION_SUCCESS
- else false
- }
- UserOperationType.Switch -> DPM.switchUser(DAR, handle)
- UserOperationType.Stop -> {
- if (VERSION.SDK_INT >= 28)
- DPM.stopUser(DAR, handle) == UserManager.USER_OPERATION_SUCCESS
- else false
- }
- UserOperationType.Delete -> DPM.removeUser(DAR, handle)
- }
-}
-
-const val ACTIVATE_DEVICE_OWNER_COMMAND = "dpm set-device-owner com.bintianqi.owndroid/.Receiver"
-
-data class DelegatedAdmin(val app: AppInfo, val scopes: List)
-
-data class DeviceAdmin(val app: AppInfo, val admin: ComponentName)
diff --git a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserScreen.kt
similarity index 55%
rename from app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt
rename to app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserScreen.kt
index eb7d60a..181e41a 100644
--- a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserScreen.kt
@@ -1,8 +1,6 @@
-package com.bintianqi.owndroid
+package com.bintianqi.owndroid.feature.applications
import android.content.pm.ApplicationInfo
-import android.graphics.drawable.Drawable
-import android.os.Build
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -22,25 +20,26 @@ import androidx.compose.foundation.text.KeyboardActions
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.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -55,7 +54,6 @@ 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
@@ -63,124 +61,92 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.searchInString
import com.google.accompanist.drawablepainter.rememberDrawablePainter
-import kotlinx.coroutines.flow.MutableStateFlow
-
-data class AppInfo(
- val name: String,
- val label: String,
- val icon: Drawable,
- val flags: Int
-)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun AppChooserScreen(
- params: Destination.ApplicationsList, packageList: MutableStateFlow>,
- refreshProgress: MutableStateFlow, onChoosePackage: (String?) -> Unit,
- onSwitchView: () -> Unit, onRefresh: () -> Unit,
- setPackagesSuspend: (List, Boolean) -> Unit,
- setPackagesHidden: (List, Boolean) -> Unit,
+ params: Destination.ApplicationsList, vm: AppChooserViewModel,
+ onChoosePackage: (String?) -> Unit, onSwitchView: () -> Unit,
) {
- val packages by packageList.collectAsStateWithLifecycle()
- val context = LocalContext.current
+ val packages by vm.packagesState.collectAsStateWithLifecycle()
val hf = LocalHapticFeedback.current
- val progress by refreshProgress.collectAsStateWithLifecycle()
- var system by rememberSaveable { mutableStateOf(false) }
+ val progress by vm.progressState.collectAsStateWithLifecycle()
+ var showUserApps by rememberSaveable { mutableStateOf(true) }
+ var showSystemApps by rememberSaveable { mutableStateOf(false) }
var query by rememberSaveable { mutableStateOf("") }
var searchMode by rememberSaveable { mutableStateOf(false) }
val filteredPackages = packages.filter {
- system == (it.flags and ApplicationInfo.FLAG_SYSTEM != 0) &&
- (query.isEmpty() || (searchInString(query, it.label) || searchInString(query, it.name)))
+ ((showUserApps && it.flags and ApplicationInfo.FLAG_SYSTEM == 0) ||
+ (showSystemApps && it.flags and ApplicationInfo.FLAG_SYSTEM != 0)) &&
+ (!searchMode || query.isBlank() || searchInString(query, it.name) ||
+ searchInString(query, it.label))
}
val selectedPackages = remember { mutableStateListOf() }
val focusMgr = LocalFocusManager.current
LaunchedEffect(Unit) {
- if(packages.size <= 1) onRefresh()
+ if (packages.size <= 1) vm.refreshPackageList()
}
Scaffold(
topBar = {
TopAppBar(
actions = {
- if(!searchMode) {
- IconButton({ searchMode = true }) {
- Icon(painter = painterResource(R.drawable.search_fill0), contentDescription = stringResource(R.string.search))
- }
+ if (!searchMode) IconButton({ searchMode = true }) {
+ Icon(painterResource(R.drawable.search_fill0), stringResource(R.string.search))
+ }
+ var dropdown by remember { mutableStateOf(false) }
+ Box {
IconButton({
- system = !system
- context.popToast(if(system) R.string.show_system_app else R.string.show_user_app)
+ dropdown = !dropdown
}) {
- Icon(painterResource(R.drawable.filter_alt_fill0), null)
+ Icon(Icons.Default.MoreVert, 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)
+ DropdownMenu(dropdown, { dropdown = false }) {
+ DropdownMenuItem(
+ { Text(stringResource(R.string.refresh)) },
+ {
+ vm.refreshPackageList()
+ dropdown = false
+ },
+ leadingIcon = { Icon(Icons.Default.Refresh, null) }
+ )
+ HorizontalDivider()
+ DropdownMenuItem(
+ { Text(stringResource(R.string.user_apps)) },
+ { showUserApps = !showUserApps },
+ leadingIcon = { Checkbox(showUserApps, null) }
+ )
+ DropdownMenuItem(
+ { Text(stringResource(R.string.system_apps)) },
+ { showSystemApps = !showSystemApps },
+ leadingIcon = { Checkbox(showSystemApps, null) }
+ )
+ if (params.canSwitchView) {
+ HorizontalDivider()
+ DropdownMenuItem(
+ { Text(stringResource(R.string.apps_view)) },
+ {},
+ leadingIcon = { RadioButton(true, null) }
+ )
+ DropdownMenuItem(
+ { Text(stringResource(R.string.features_view)) },
+ {
+ dropdown = false
+ onSwitchView()
+ },
+ leadingIcon = { RadioButton(false, 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 {
+ if (!params.canSwitchView) {
FilledIconButton({
onChoosePackage(selectedPackages.joinToString("\n") { it.name })
}) {
@@ -194,8 +160,7 @@ fun AppChooserScreen(
val fr = remember { FocusRequester() }
LaunchedEffect(Unit) { fr.requestFocus() }
OutlinedTextField(
- value = query,
- onValueChange = { query = it },
+ query, { query = it },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions { focusMgr.clearFocus() },
placeholder = { Text(stringResource(R.string.search)) },
@@ -208,7 +173,9 @@ fun AppChooserScreen(
}
},
textStyle = typography.bodyLarge,
- modifier = Modifier.fillMaxWidth().focusRequester(fr)
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(fr)
)
} else {
if (selectedPackages.isNotEmpty()) {
@@ -220,13 +187,16 @@ fun AppChooserScreen(
IconButton({ onChoosePackage(null) }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, null)
}
- },
- colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer)
+ }
)
},
contentWindowInsets = adaptiveInsets()
) { paddingValues ->
- LazyColumn(Modifier.fillMaxSize().padding(paddingValues)) {
+ LazyColumn(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
if (progress < 1F) stickyHeader {
LinearProgressIndicator({ progress }, Modifier.fillMaxWidth())
}
@@ -258,7 +228,9 @@ fun AppChooserScreen(
) {
Image(
painter = rememberDrawablePainter(it.icon), contentDescription = null,
- modifier = Modifier.padding(start = 12.dp, end = 18.dp).size(40.dp)
+ modifier = Modifier
+ .padding(start = 12.dp, end = 18.dp)
+ .size(40.dp)
)
Column {
Text(text = it.label, style = typography.titleLarge)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserViewModel.kt
new file mode 100644
index 0000000..e4cc45a
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserViewModel.kt
@@ -0,0 +1,36 @@
+package com.bintianqi.owndroid.feature.applications
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.getAppInfo
+import com.bintianqi.owndroid.utils.getInstalledAppsFlags
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class AppChooserViewModel(val application: MyApplication) : ViewModel() {
+ val packagesState = MutableStateFlow(emptyList())
+ val progressState = MutableStateFlow(0F)
+
+ fun refreshPackageList() {
+ viewModelScope.launch(Dispatchers.IO) {
+ packagesState.value = emptyList()
+ val apps = application.packageManager.getInstalledApplications(getInstalledAppsFlags)
+ apps.forEachIndexed { index, info ->
+ packagesState.update {
+ it + getAppInfo(application.packageManager, info)
+ }
+ progressState.value = (index + 1).toFloat() / apps.size
+ }
+ }
+ }
+
+ fun onPackageRemoved(name: String) {
+ packagesState.update { list ->
+ list.filter { it.name != name }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsModel.kt
new file mode 100644
index 0000000..31982a8
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsModel.kt
@@ -0,0 +1,10 @@
+package com.bintianqi.owndroid.feature.applications
+
+data class AppDetailsUiState(
+ val suspend: Boolean = false,
+ val hide: Boolean = false,
+ val uninstallBlocked: Boolean = false,
+ val userControlDisabled: Boolean = false,
+ val meteredDataDisabled: Boolean = false,
+ val keepUninstalled: Boolean = false
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsScreen.kt
new file mode 100644
index 0000000..a19dc0f
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsScreen.kt
@@ -0,0 +1,158 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT
+import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED
+import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED
+import android.os.Build.VERSION
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.FunctionItem
+import com.bintianqi.owndroid.ui.MyLazyScaffold
+import com.bintianqi.owndroid.ui.MySmallTitleScaffold
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.PermissionItem
+import com.bintianqi.owndroid.utils.runtimePermissions
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+
+@Composable
+fun ApplicationDetailsScreen(
+ vm: AppDetailsViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ var dialog by rememberSaveable { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall
+ val uiState by vm.uiState.collectAsStateWithLifecycle()
+ MySmallTitleScaffold(R.string.place_holder, onNavigateUp, 0.dp) {
+ Column(
+ Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(rememberDrawablePainter(vm.appInfo.icon), null, Modifier.size(50.dp))
+ Text(vm.appInfo.label, Modifier.padding(top = 4.dp))
+ Text(
+ vm.appInfo.name,
+ Modifier
+ .alpha(0.7F)
+ .padding(bottom = 8.dp),
+ style = typography.bodyMedium
+ )
+ }
+ FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) {
+ onNavigate(Destination.AppPermissionsManager(vm.packageName))
+ }
+ if (VERSION.SDK_INT >= 24) SwitchItem(
+ R.string.suspend, uiState.suspend, vm::setSuspended, R.drawable.block_fill0
+ )
+ SwitchItem(
+ R.string.hide, uiState.hide, vm::setHidden, R.drawable.visibility_off_fill0
+ )
+ SwitchItem(
+ R.string.block_uninstall, uiState.uninstallBlocked,
+ vm::setUninstallBlocked, R.drawable.delete_forever_fill0
+ )
+ if (VERSION.SDK_INT >= 30) SwitchItem(
+ R.string.disable_user_control, uiState.userControlDisabled,
+ vm::setUserControlDisabled, R.drawable.do_not_touch_fill0
+ )
+ if (VERSION.SDK_INT >= 28) SwitchItem(
+ R.string.disable_metered_data, uiState.meteredDataDisabled,
+ vm::setMeteredDataDisabled, R.drawable.money_off_fill0
+ )
+ if (privilege.device && VERSION.SDK_INT >= 28) SwitchItem(
+ R.string.keep_after_uninstall, uiState.keepUninstalled,
+ vm::setKeepUninstalled, R.drawable.delete_fill0
+ )
+ FunctionItem(R.string.managed_configuration, icon = R.drawable.description_fill0) {
+ onNavigate(Destination.ManagedConfiguration(vm.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({
+ vm.clearData { dialog = 0 }
+ }) { dialog = 0 }
+ if (dialog == 2) UninstallAppDialog(vm::uninstall) {
+ dialog = 0
+ if (it) onNavigateUp()
+ }
+}
+
+@Composable
+fun AppPermissionsManagerScreen(
+ vm: AppDetailsViewModel, onNavigateUp: () -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ var selectedPermission by remember { mutableStateOf(null) }
+ val permissions by vm.permissionsState.collectAsState()
+ MyLazyScaffold(R.string.permissions, onNavigateUp) {
+ items(runtimePermissions) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ selectedPermission = it
+ }
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp))
+ Column {
+ val stateStr = when (permissions[it.id]) {
+ PERMISSION_GRANT_STATE_DEFAULT -> R.string.default_stringres
+ PERMISSION_GRANT_STATE_GRANTED -> R.string.granted
+ PERMISSION_GRANT_STATE_DENIED -> R.string.denied
+ else -> R.string.unknown
+ }
+ Text(stringResource(it.label))
+ Text(
+ stringResource(stateStr), Modifier.alpha(0.7F),
+ style = typography.bodyMedium
+ )
+ }
+ }
+ }
+ item {
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+ if (selectedPermission != null) PackagePermissionDialog(
+ selectedPermission!!, permissions[selectedPermission!!.id]!!, privilege.profile,
+ {
+ vm.setPermission(selectedPermission!!.id, it)
+ selectedPermission = null
+ }
+ ) { selectedPermission = null }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsViewModel.kt
new file mode 100644
index 0000000..940f245
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsViewModel.kt
@@ -0,0 +1,126 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import com.bintianqi.owndroid.utils.getAppInfo
+import com.bintianqi.owndroid.utils.plusOrMinus
+import com.bintianqi.owndroid.utils.runtimePermissions
+import com.bintianqi.owndroid.utils.uninstallPackage
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+class AppDetailsViewModel(
+ val packageName: String, val application: MyApplication, val ph: PrivilegeHelper,
+ val privilegeState: StateFlow, val toastChannel: ToastChannel
+) : ViewModel() {
+ val appInfo = getAppInfo(application.packageManager, packageName)
+ val uiState = MutableStateFlow(AppDetailsUiState())
+
+ init {
+ getStatus()
+ }
+
+ fun getStatus() = ph.safeDpmCall {
+ uiState.value = AppDetailsUiState(
+ if (VERSION.SDK_INT >= 24) dpm.isPackageSuspended(dar, packageName) else false,
+ dpm.isApplicationHidden(dar, packageName),
+ dpm.isUninstallBlocked(dar, packageName),
+ if (VERSION.SDK_INT >= 30) packageName in dpm.getUserControlDisabledPackages(dar)
+ else false,
+ if (VERSION.SDK_INT >= 28) packageName in dpm.getMeteredDataDisabledPackages(dar)
+ else false,
+ if (VERSION.SDK_INT >= 28 && privilegeState.value.device)
+ dpm.getKeepUninstalledPackages(dar)?.contains(packageName) == true
+ else false
+ )
+ }
+
+ @RequiresApi(24)
+ fun setSuspended(status: Boolean) = ph.safeDpmCall {
+ try {
+ dpm.setPackagesSuspended(dar, arrayOf(packageName), status)
+ uiState.update { it.copy(suspend = dpm.isPackageSuspended(dar, packageName)) }
+ } catch (_: Exception) {
+ }
+ }
+
+ fun setHidden(status: Boolean) = ph.safeDpmCall {
+ dpm.setApplicationHidden(dar, packageName, status)
+ uiState.update { it.copy(hide = dpm.isApplicationHidden(dar, packageName)) }
+ }
+
+ fun setUninstallBlocked(status: Boolean) = ph.safeDpmCall {
+ dpm.setUninstallBlocked(dar, packageName, status)
+ uiState.update { it.copy(uninstallBlocked = dpm.isUninstallBlocked(dar, packageName)) }
+ }
+
+ @RequiresApi(30)
+ fun setUserControlDisabled(state: Boolean) = ph.safeDpmCall {
+ dpm.setUserControlDisabledPackages(
+ dar,
+ dpm.getUserControlDisabledPackages(dar).plusOrMinus(state, packageName)
+ )
+ uiState.update {
+ it.copy(userControlDisabled = packageName in dpm.getUserControlDisabledPackages(dar))
+ }
+ }
+
+ @RequiresApi(28)
+ fun setMeteredDataDisabled(state: Boolean) = ph.safeDpmCall {
+ dpm.setMeteredDataDisabledPackages(
+ dar,
+ dpm.getMeteredDataDisabledPackages(dar).plusOrMinus(state, packageName)
+ )
+ uiState.update {
+ it.copy(meteredDataDisabled = packageName in dpm.getMeteredDataDisabledPackages(dar))
+ }
+ }
+
+ @RequiresApi(28)
+ fun setKeepUninstalled(state: Boolean) = ph.safeDpmCall {
+ dpm.setKeepUninstalledPackages(
+ dar,
+ (dpm.getKeepUninstalledPackages(dar) ?: emptyList()).plusOrMinus(state, packageName)
+ )
+ uiState.update {
+ it.copy(
+ keepUninstalled = dpm.getKeepUninstalledPackages(dar)?.contains(packageName) == true
+ )
+ }
+ }
+
+ val permissionsState = MutableStateFlow(emptyMap())
+
+ fun getPermissions() = ph.safeDpmCall {
+ permissionsState.value = runtimePermissions.associate {
+ it.id to dpm.getPermissionGrantState(dar, packageName, it.id)
+ }
+ }
+
+ fun setPermission(permission: String, status: Int) = ph.safeDpmCall {
+ val result = dpm.setPermissionGrantState(dar, packageName, permission, status)
+ if (result) {
+ getPermissions()
+ } else {
+ toastChannel.sendStatus(false)
+ }
+ }
+
+ @RequiresApi(28)
+ fun clearData(callback: () -> Unit) = ph.safeDpmCall {
+ dpm.clearApplicationUserData(dar, packageName, application.mainExecutor) { _, result ->
+ callback()
+ toastChannel.sendStatus(result)
+ }
+ }
+
+ fun uninstall(callback: (String?) -> Unit) {
+ uninstallPackage(application, ph, packageName, callback)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesScreen.kt
new file mode 100644
index 0000000..f81ecba
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesScreen.kt
@@ -0,0 +1,781 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.outlined.Clear
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+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.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.AppInstallerActivity
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.FunctionItem
+import com.bintianqi.owndroid.ui.MyLazyScaffold
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.PackageNameTextField
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.isValidPackageName
+import com.bintianqi.owndroid.utils.parsePackageNames
+import com.bintianqi.owndroid.utils.runtimePermissions
+import com.bintianqi.owndroid.utils.searchInString
+import com.bintianqi.owndroid.utils.showOperationResultToast
+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
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ApplicationsFeaturesScreen(
+ vm: AppFeaturesViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit,
+ onSwitchView: () -> Unit
+) {
+ val context = LocalContext.current
+ val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ Scaffold(
+ Modifier.nestedScroll(sb.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ { Text(stringResource(R.string.applications)) },
+ navigationIcon = { NavIcon(onNavigateUp) },
+ actions = {
+ Box {
+ var dropdown by remember { mutableStateOf(false) }
+ IconButton({ dropdown = true }) {
+ Icon(Icons.Default.MoreVert, null)
+ }
+ DropdownMenu(dropdown, { dropdown = false }) {
+ DropdownMenuItem(
+ { Text(stringResource(R.string.apps_view)) },
+ {
+ dropdown = false
+ onSwitchView()
+ },
+ leadingIcon = { RadioButton(false, null) }
+ )
+ DropdownMenuItem(
+ { Text(stringResource(R.string.features_view)) },
+ {},
+ leadingIcon = { RadioButton(true, null) }
+ )
+ }
+ }
+ },
+ scrollBehavior = sb
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = 80.dp)
+ ) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ if (VERSION.SDK_INT >= 24) FunctionItem(
+ R.string.suspend, icon = R.drawable.block_fill0
+ ) {
+ onNavigate(Destination.Suspend)
+ }
+ FunctionItem(R.string.hide, icon = R.drawable.visibility_off_fill0) {
+ onNavigate(Destination.Hide)
+ }
+ FunctionItem(R.string.block_uninstall, icon = R.drawable.delete_forever_fill0) {
+ onNavigate(Destination.BlockUninstall)
+ }
+ 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(Destination.DisableUserControl)
+ }
+ }
+ FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) {
+ onNavigate(Destination.PermissionManager)
+ }
+ if (VERSION.SDK_INT >= 28) {
+ FunctionItem(R.string.disable_metered_data, icon = R.drawable.money_off_fill0) {
+ onNavigate(Destination.DisableMeteredData)
+ }
+ }
+ if (VERSION.SDK_INT >= 28) {
+ FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) {
+ onNavigate(Destination.ClearAppStorage)
+ }
+ }
+ FunctionItem(R.string.install_app, icon = R.drawable.install_mobile_fill0) {
+ context.startActivity(Intent(context, AppInstallerActivity::class.java))
+ }
+ FunctionItem(R.string.uninstall_app, icon = R.drawable.delete_fill0) {
+ onNavigate(Destination.UninstallApp)
+ }
+ if (VERSION.SDK_INT >= 28 && privilege.device) {
+ FunctionItem(R.string.keep_uninstalled_packages, icon = R.drawable.delete_fill0) {
+ onNavigate(Destination.KeepUninstalledPackages)
+ }
+ }
+ if (VERSION.SDK_INT >= 28 && (privilege.device || (privilege.profile && privilege.affiliated))) {
+ FunctionItem(
+ R.string.install_existing_app, icon = R.drawable.install_mobile_fill0
+ ) {
+ onNavigate(Destination.InstallExistingApp)
+ }
+ }
+ if (VERSION.SDK_INT >= 30 && privilege.work) {
+ FunctionItem(R.string.cross_profile_apps, icon = R.drawable.work_fill0) {
+ onNavigate(Destination.CrossProfilePackages)
+ }
+ }
+ if (privilege.work) {
+ FunctionItem(R.string.cross_profile_widget, icon = R.drawable.widgets_fill0) {
+ onNavigate(Destination.CrossProfileWidgetProviders)
+ }
+ }
+ if (VERSION.SDK_INT >= 34 && privilege.device) {
+ FunctionItem(R.string.credential_manager_policy, icon = R.drawable.license_fill0) {
+ onNavigate(Destination.CredentialManagerPolicy)
+ }
+ }
+ FunctionItem(
+ R.string.permitted_accessibility_services,
+ icon = R.drawable.settings_accessibility_fill0
+ ) {
+ onNavigate(Destination.PermittedAccessibilityServices)
+ }
+ FunctionItem(R.string.permitted_ime, icon = R.drawable.keyboard_fill0) {
+ onNavigate(Destination.PermittedInputMethods)
+ }
+ FunctionItem(R.string.enable_system_app, icon = R.drawable.enable_fill0) {
+ onNavigate(Destination.EnableSystemApp)
+ }
+ if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.work)) {
+ FunctionItem(R.string.set_default_dialer, icon = R.drawable.call_fill0) {
+ onNavigate(Destination.SetDefaultDialer)
+ }
+ }
+ }
+ }
+}
+
+
+@Composable
+fun PermissionManagerScreen(
+ onNavigate: (Destination.PermissionDetail) -> Unit, onNavigateUp: () -> Unit
+) {
+ MyLazyScaffold(R.string.permissions, onNavigateUp) {
+ items(runtimePermissions) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onNavigate(Destination.PermissionDetail(it.id))
+ }
+ .padding(8.dp, 12.dp)
+ ) {
+ Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp))
+ Text(stringResource(it.label))
+ }
+ }
+ item {
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PermissionDetailScreen(
+ param: Destination.PermissionDetail, vm: AppFeaturesViewModel, onNavigateUp: () -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ val permissionItem = runtimePermissions.find { it.id == param.permission }!!
+ val packagesList by vm.permissionPackagesState.collectAsState()
+ var selectedPackage by remember { mutableStateOf?>(null) }
+ var showUserApps by rememberSaveable { mutableStateOf(true) }
+ var showSystemApps by rememberSaveable { mutableStateOf(false) }
+ var searchMode by rememberSaveable { mutableStateOf(false) }
+ var query by rememberSaveable { mutableStateOf("") }
+ val displayedPackagesList = packagesList.filter {
+ ((showUserApps && it.first.flags and ApplicationInfo.FLAG_SYSTEM == 0) ||
+ (showSystemApps && it.first.flags and ApplicationInfo.FLAG_SYSTEM != 0)) &&
+ (!searchMode || query.isBlank() || searchInString(query, it.first.name) ||
+ searchInString(query, it.first.label))
+ }
+ val fm = LocalFocusManager.current
+ LaunchedEffect(Unit) {
+ vm.getPermissionPackages(param.permission)
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ {
+ if (searchMode) {
+ val fr = remember { FocusRequester() }
+ LaunchedEffect(Unit) { fr.requestFocus() }
+ OutlinedTextField(
+ query, { query = it },
+ Modifier
+ .fillMaxWidth()
+ .focusRequester(fr),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions { fm.clearFocus() },
+ placeholder = { Text(stringResource(R.string.search)) },
+ trailingIcon = {
+ IconButton({
+ query = ""
+ searchMode = false
+ }) {
+ Icon(Icons.Outlined.Clear, null)
+ }
+ },
+ textStyle = typography.bodyLarge
+ )
+ } else {
+ Text(stringResource(permissionItem.label))
+ }
+ },
+ navigationIcon = { NavIcon(onNavigateUp) },
+ actions = {
+ if (!searchMode) {
+ IconButton({ searchMode = true }) {
+ Icon(Icons.Default.Search, null)
+ }
+ }
+ var menu by remember { mutableStateOf(false) }
+ Box {
+ IconButton({ menu = true }) {
+ Icon(painterResource(R.drawable.filter_alt_fill0), null)
+ }
+ DropdownMenu(menu, { menu = false }) {
+ DropdownMenuItem(
+ { Text(stringResource(R.string.user_apps)) },
+ { showUserApps = !showUserApps },
+ leadingIcon = { Checkbox(showUserApps, null) }
+ )
+ DropdownMenuItem(
+ { Text(stringResource(R.string.system_apps)) },
+ { showSystemApps = !showSystemApps },
+ leadingIcon = { Checkbox(showSystemApps, null) }
+ )
+ }
+ }
+ }
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ LazyColumn(Modifier.padding(paddingValues)) {
+ items(displayedPackagesList, { it.first.name }) { (info, grantState) ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable { selectedPackage = info.name to grantState }
+ .padding(horizontal = 8.dp, vertical = 6.dp)
+ .animateItem(),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ rememberDrawablePainter(info.icon), null,
+ Modifier
+ .padding(start = 12.dp, end = 18.dp)
+ .size(30.dp)
+ )
+ Column {
+ Text(info.label)
+ Text(info.name, Modifier.alpha(0.8F), style = typography.bodyMedium)
+ }
+ }
+ if (grantState != 0) {
+ Icon(
+ painterResource(
+ if (grantState == 1) R.drawable.check_circle_fill0
+ else R.drawable.cancel_fill0
+ ),
+ null
+ )
+ }
+ }
+ }
+ item {
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+ }
+ if (selectedPackage != null) PackagePermissionDialog(
+ permissionItem, selectedPackage!!.second, privilege.profile,
+ {
+ vm.setPackagePermission(selectedPackage!!.first, param.permission, it)
+ selectedPackage = null
+ }
+ ) { selectedPackage = null }
+}
+
+@RequiresApi(28)
+@Composable
+fun ClearAppStorageScreen(
+ vm: AppFeaturesViewModel,
+ chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit
+) {
+ var dialog by rememberSaveable { mutableStateOf(false) }
+ var packageName by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ packageName = chosenPackage.receive()
+ }
+ MyScaffold(R.string.clear_app_storage, onNavigateUp) {
+ PackageNameTextField(
+ packageName, onChoosePackage,
+ Modifier.padding(vertical = 8.dp)
+ ) { packageName = it }
+ Button(
+ { dialog = true },
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.clear))
+ }
+ }
+ if (dialog) ClearAppStorageDialog({
+ vm.clearStorage(packageName) { dialog = false }
+ }) { dialog = false }
+}
+
+
+@Composable
+fun UninstallAppScreen(
+ vm: AppFeaturesViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit,
+ onNavigateUp: () -> Unit
+) {
+ var dialog by rememberSaveable { mutableStateOf(false) }
+ var packageName by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ packageName = chosenPackage.receive()
+ }
+ MyScaffold(R.string.uninstall_app, onNavigateUp) {
+ PackageNameTextField(
+ packageName, onChoosePackage,
+ Modifier.padding(vertical = 8.dp)
+ ) { packageName = it }
+ Button(
+ { dialog = true },
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.uninstall))
+ }
+ }
+ if (dialog) UninstallAppDialog({
+ vm.uninstallApp(packageName, it)
+ }) {
+ packageName = ""
+ dialog = false
+ }
+}
+
+@RequiresApi(28)
+@Composable
+fun InstallExistingAppScreen(
+ vm: AppFeaturesViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit,
+ onNavigateUp: () -> Unit
+) {
+ var packageName by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ packageName = chosenPackage.receive()
+ }
+ MyScaffold(R.string.install_existing_app, onNavigateUp) {
+ PackageNameTextField(
+ packageName, onChoosePackage,
+ Modifier.padding(vertical = 8.dp)
+ ) { packageName = it }
+ Button(
+ {
+ vm.installExistingApp(packageName)
+ },
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.install))
+ }
+ Notes(R.string.info_install_existing_app)
+ }
+}
+
+@RequiresApi(34)
+@Composable
+fun CredentialManagerPolicyScreen(
+ vm: AppFeaturesViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit,
+ onNavigateUp: () -> Unit
+) {
+ val policy by vm.cmPolicyState.collectAsState()
+ val packages by vm.cmPackagesState.collectAsState()
+ var input by rememberSaveable { mutableStateOf("") }
+ val inputPackages = parsePackageNames(input)
+ LaunchedEffect(Unit) {
+ input = chosenPackage.receive()
+ }
+ MyLazyScaffold(R.string.credential_manager_policy, onNavigateUp) {
+ item {
+ mapOf(
+ -1 to R.string.none,
+ 1 to R.string.blacklist,
+ 2 to R.string.whitelist_and_system_app,
+ 3 to R.string.whitelist
+ ).forEach { (key, value) ->
+ FullWidthRadioButtonItem(value, policy == key) { vm.setCmPolicy(key) }
+ }
+ Spacer(Modifier.padding(vertical = 4.dp))
+ }
+ if (policy != -1) items(packages, { it.name }) {
+ ApplicationItem(it) { vm.setCmPackage(listOf(it.name), false) }
+ }
+ item {
+ Column(Modifier.padding(horizontal = HorizontalPadding)) {
+ if (policy != -1) {
+ PackageNameTextField(
+ input, onChoosePackage,
+ Modifier.padding(vertical = 8.dp)
+ ) { input = it }
+ Button(
+ {
+ vm.setCmPackage(inputPackages, true)
+ input = ""
+ },
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ }
+ Button(
+ vm::applyCmPolicy,
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+ }
+}
+
+@Composable
+fun PermittedAsAndImPackagesScreen(
+ title: Int, note: Int, chosenPackage: Channel, onChoosePackage: () -> Unit,
+ policyState: StateFlow, packagesState: MutableStateFlow>,
+ getPolicy: () -> Unit, setPolicy: (Boolean) -> Unit,
+ setPackage: (List, Boolean) -> Unit, applyPolicy: () -> Unit,
+ onNavigateUp: () -> Unit
+) {
+ val allowAll by policyState.collectAsState()
+ val packages by packagesState.collectAsStateWithLifecycle()
+ var input by rememberSaveable { mutableStateOf("") }
+ val inputPackages = parsePackageNames(input)
+ var initialized by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ if (!initialized) {
+ getPolicy()
+ initialized = true
+ }
+ }
+ LaunchedEffect(Unit) {
+ input = chosenPackage.receive()
+ }
+ MyLazyScaffold(title, onNavigateUp) {
+ item {
+ SwitchItem(R.string.allow_all, allowAll, setPolicy)
+ }
+ if (!allowAll) items(packages, { it.name }) {
+ ApplicationItem(it) { setPackage(listOf(it.name), false) }
+ }
+ item {
+ if (!allowAll) {
+ PackageNameTextField(
+ input, onChoosePackage,
+ Modifier.padding(HorizontalPadding, 8.dp)
+ ) { input = it }
+ Button(
+ {
+ setPackage(inputPackages, true)
+ input = ""
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding)
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ }
+ Button(
+ applyPolicy,
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .padding(horizontal = HorizontalPadding)
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ Spacer(Modifier.height(10.dp))
+ Notes(note, HorizontalPadding)
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+}
+
+@Composable
+fun EnableSystemAppScreen(
+ chosenPackage: Channel, onChoosePackage: () -> Unit,
+ onEnable: (String) -> Unit, onNavigateUp: () -> Unit
+) {
+ val context = LocalContext.current
+ var packageName by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ packageName = chosenPackage.receive()
+ }
+ MyScaffold(R.string.enable_system_app, onNavigateUp) {
+ Spacer(Modifier.padding(vertical = 4.dp))
+ PackageNameTextField(
+ packageName, onChoosePackage,
+ Modifier.padding(bottom = 8.dp)
+ ) { packageName = it }
+ Button(
+ {
+ onEnable(packageName)
+ packageName = ""
+ context.showOperationResultToast(true)
+ },
+ Modifier.fillMaxWidth(),
+ packageName.isValidPackageName
+ ) {
+ Text(stringResource(R.string.enable))
+ }
+ Notes(R.string.info_enable_system_app)
+ }
+}
+
+@RequiresApi(34)
+@Composable
+fun SetDefaultDialerScreen(
+ chosenPackage: Channel, onChoosePackage: () -> Unit,
+ onSet: (String) -> Unit, onNavigateUp: () -> Unit
+) {
+ var packageName by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ packageName = chosenPackage.receive()
+ }
+ MyScaffold(R.string.set_default_dialer, onNavigateUp) {
+ Spacer(Modifier.padding(vertical = 4.dp))
+ PackageNameTextField(
+ packageName, onChoosePackage,
+ Modifier.padding(bottom = 8.dp)
+ ) { packageName = it }
+ Button(
+ {
+ onSet(packageName)
+ },
+ Modifier.fillMaxWidth(),
+ packageName.isValidPackageName
+ ) {
+ Text(stringResource(R.string.set))
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PackageFunctionScreen(
+ title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit,
+ onSet: (List, Boolean) -> Unit, onNavigateUp: () -> Unit,
+ chosenPackage: Channel, onChoosePackage: () -> Unit,
+ navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null
+) {
+ val groups by appGroups.collectAsStateWithLifecycle()
+ val packages by packagesState.collectAsStateWithLifecycle()
+ 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 res = LocalResources.current
+ val coroutine = rememberCoroutineScope()
+ LaunchedEffect(Unit) {
+ onGet()
+ input = chosenPackage.receive()
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ { Text(stringResource(title)) },
+ navigationIcon = { NavIcon(onNavigateUp) },
+ actions = {
+ var expand by remember { mutableStateOf(false) }
+ Box {
+ IconButton({
+ expand = true
+ }) {
+ Icon(Icons.Default.MoreVert, null)
+ }
+ DropdownMenu(expand, { expand = false }) {
+ groups.forEach {
+ DropdownMenuItem(
+ { Text("(${it.apps.size}) ${it.name}") },
+ {
+ selectedGroup = it
+ dialog = true
+ expand = false
+ }
+ )
+ }
+ if (groups.isNotEmpty()) HorizontalDivider()
+ DropdownMenuItem(
+ { Text(stringResource(R.string.manage_app_groups)) },
+ {
+ navigateToGroups()
+ expand = false
+ }
+ )
+ }
+ }
+ }
+ )
+ },
+ snackbarHost = {
+ SnackbarHost(snackbar)
+ }
+ ) { paddingValues ->
+ LazyColumn(Modifier.padding(paddingValues)) {
+ items(packages, { it.name }) {
+ ApplicationItem(it) {
+ onSet(listOf(it.name), false)
+ coroutine.launch {
+ val result = snackbar.showSnackbar(
+ res.getString(R.string.package_removed, it.name),
+ res.getString(R.string.undo),
+ true, SnackbarDuration.Short
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ onSet(listOf(it.name), true)
+ }
+ }
+ }
+ }
+ item {
+ PackageNameTextField(
+ input, onChoosePackage,
+ Modifier.padding(HorizontalPadding, 8.dp)
+ ) { input = it }
+ Button(
+ {
+ onSet(inputPackages, true)
+ input = ""
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding)
+ .padding(bottom = 10.dp),
+ packages.none { it.name in inputPackages }
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ if (notes != null) Notes(notes, HorizontalPadding)
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+ }
+ if (dialog) AlertDialog(
+ text = {
+ Column {
+ Text(selectedGroup!!.name, style = typography.titleLarge)
+ Spacer(Modifier.height(6.dp))
+ Button({
+ onSet(selectedGroup!!.apps, true)
+ dialog = false
+ }) {
+ Text(stringResource(R.string.add_to_list))
+ }
+ Button({
+ onSet(selectedGroup!!.apps, false)
+ dialog = false
+ }) {
+ Text(stringResource(R.string.remove_from_list))
+ }
+ }
+ },
+ confirmButton = {
+ TextButton({ dialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = { dialog = false }
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesViewModel.kt
new file mode 100644
index 0000000..190b7a5
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesViewModel.kt
@@ -0,0 +1,324 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.app.admin.PackagePolicy
+import android.content.pm.PackageManager
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import com.bintianqi.owndroid.utils.getAppInfo
+import com.bintianqi.owndroid.utils.getInstalledAppsFlags
+import com.bintianqi.owndroid.utils.plusOrMinus
+import com.bintianqi.owndroid.utils.uninstallPackage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class AppFeaturesViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper,
+ val privilegeState: StateFlow,
+ val toastChannel: ToastChannel
+) : ViewModel() {
+ val pm = application.packageManager!!
+
+ val suspendedPackages = MutableStateFlow(emptyList())
+
+ @RequiresApi(24)
+ fun getSuspendedPackaged() = ph.safeDpmCall {
+ val packages = pm.getInstalledApplications(getInstalledAppsFlags).filter {
+ dpm.isPackageSuspended(dar, it.packageName)
+ }
+ suspendedPackages.value = packages.map { getAppInfo(pm, it) }
+ }
+
+ @RequiresApi(24)
+ fun setPackageSuspended(packages: List, status: Boolean) = ph.safeDpmCall {
+ dpm.setPackagesSuspended(dar, packages.toTypedArray(), status)
+ getSuspendedPackaged()
+ }
+
+ val hiddenPackages = MutableStateFlow(emptyList())
+ fun getHiddenPackages() = ph.safeDpmCall {
+ hiddenPackages.value = pm.getInstalledApplications(getInstalledAppsFlags).filter {
+ dpm.isApplicationHidden(dar, it.packageName)
+ }.map { getAppInfo(pm, it) }
+ }
+
+ fun setPackageHidden(packages: List, status: Boolean) = ph.safeDpmCall {
+ for (name in packages) {
+ dpm.setApplicationHidden(dar, name, status)
+ }
+ getHiddenPackages()
+ }
+
+ // Uninstall blocked packages
+ val ubPackages = MutableStateFlow(emptyList())
+ fun getUbPackages() = ph.safeDpmCall {
+ ubPackages.value = pm.getInstalledApplications(getInstalledAppsFlags).filter {
+ dpm.isUninstallBlocked(dar, it.packageName)
+ }.map { getAppInfo(pm, it) }
+ }
+
+ fun setPackageUb(packages: List, status: Boolean) = ph.safeDpmCall {
+ for (name in packages) {
+ dpm.setUninstallBlocked(dar, name, status)
+ }
+ getUbPackages()
+ }
+
+ // User control disabled packages
+ val ucdPackages = MutableStateFlow(emptyList())
+
+ @RequiresApi(30)
+ fun getUcdPackages() = ph.safeDpmCall {
+ ucdPackages.value = dpm.getUserControlDisabledPackages(dar).distinct().map {
+ getAppInfo(pm, it)
+ }
+ }
+
+ @RequiresApi(30)
+ fun setPackageUcd(packages: List, status: Boolean) = ph.safeDpmCall {
+ dpm.setUserControlDisabledPackages(
+ dar,
+ ucdPackages.value.map { it.name }.plusOrMinus(status, packages)
+ )
+ getUcdPackages()
+ }
+
+ val permissionPackagesState = MutableStateFlow(emptyList>())
+
+ fun getPermissionPackages(permission: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ permissionPackagesState.value = emptyList()
+ ph.safeDpmCall {
+ permissionPackagesState.value = pm.getInstalledPackages(
+ getInstalledAppsFlags or PackageManager.GET_PERMISSIONS
+ ).filter {
+ it.requestedPermissions?.contains(permission) ?: false
+ }.map {
+ getAppInfo(pm, it.packageName) to
+ dpm.getPermissionGrantState(dar, it.packageName, permission)
+ }
+ }
+ }
+ }
+
+ fun setPackagePermission(
+ packageName: String, permission: String, state: Int
+ ) = ph.safeDpmCall {
+ val result = dpm.setPermissionGrantState(dar, packageName, permission, state)
+ if (result) {
+ getPermissionPackages(permission)
+ } else {
+ toastChannel.sendStatus(false)
+ }
+ }
+
+ @RequiresApi(28)
+ fun clearStorage(packageName: String, callback: () -> Unit) = ph.safeDpmCall {
+ dpm.clearApplicationUserData(dar, packageName, application.mainExecutor) { _, result ->
+ callback()
+ toastChannel.sendStatus(result)
+ }
+ }
+
+ fun uninstallApp(packageName: String, callback: (String?) -> Unit) {
+ uninstallPackage(application, ph, packageName, callback)
+ }
+
+ // Metered data disabled packages
+ val mddPackages = MutableStateFlow(emptyList())
+
+ @RequiresApi(28)
+ fun getMddPackages() = ph.safeDpmCall {
+ mddPackages.value =
+ dpm.getMeteredDataDisabledPackages(dar).distinct().map { getAppInfo(pm, it) }
+ }
+
+ @RequiresApi(28)
+ fun setPackageMdd(packages: List, status: Boolean) = ph.safeDpmCall {
+ dpm.setMeteredDataDisabledPackages(
+ dar, mddPackages.value.map { it.name }.plusOrMinus(status, packages)
+ )
+ getMddPackages()
+ }
+
+ // Keep uninstalled packages
+ val kuPackages = MutableStateFlow(emptyList())
+
+ @RequiresApi(28)
+ fun getKuPackages() = ph.safeDpmCall {
+ kuPackages.value =
+ dpm.getKeepUninstalledPackages(dar)?.distinct()?.map { getAppInfo(pm, it) } ?: emptyList()
+ }
+
+ @RequiresApi(28)
+ fun setPackageKu(packages: List, status: Boolean) = ph.safeDpmCall {
+ dpm.setKeepUninstalledPackages(
+ dar, kuPackages.value.map { it.name }.plusOrMinus(status, packages)
+ )
+ getKuPackages()
+ }
+
+ // Cross profile packages
+ val cpPackages = MutableStateFlow(emptyList())
+
+ @RequiresApi(30)
+ fun getCpPackages() = ph.safeDpmCall {
+ cpPackages.value = dpm.getCrossProfilePackages(dar).map { getAppInfo(pm, it) }
+ }
+
+ @RequiresApi(30)
+ fun setPackageCp(packages: List, status: Boolean) = ph.safeDpmCall {
+ dpm.setCrossProfilePackages(
+ dar,
+ cpPackages.value.map { it.name }.toSet().run {
+ if (status) plus(packages) else minus(packages)
+ }
+ )
+ getCpPackages()
+ }
+
+ // Cross-profile widget providers
+ val cpwProviders = MutableStateFlow(emptyList())
+ fun getCpwProviders() = ph.safeDpmCall {
+ cpwProviders.value =
+ dpm.getCrossProfileWidgetProviders(dar).distinct().map { getAppInfo(pm, it) }
+ }
+
+ fun setCpwProvider(packages: List, status: Boolean) = ph.safeDpmCall {
+ for (name in packages) {
+ if (status) {
+ dpm.addCrossProfileWidgetProvider(dar, name)
+ } else {
+ dpm.removeCrossProfileWidgetProvider(dar, name)
+ }
+ }
+ getCpwProviders()
+ }
+
+ @RequiresApi(28)
+ fun installExistingApp(name: String) = ph.safeDpmCall {
+ val result = dpm.installExistingPackage(dar, name)
+ toastChannel.sendStatus(result)
+ }
+
+ // Credential manager policy
+ val cmPolicyState = MutableStateFlow(-1)
+ val cmPackagesState = MutableStateFlow(emptyList())
+
+ @RequiresApi(34)
+ fun getCmPolicy() = ph.safeDpmCall {
+ val policy = dpm.credentialManagerPolicy
+ if (policy != null) {
+ cmPackagesState.value = policy.packageNames.distinct().map { getAppInfo(pm, it) }
+ }
+ cmPolicyState.value = policy?.policyType ?: -1
+ }
+
+ fun setCmPolicy(type: Int) {
+ cmPolicyState.value = type
+ }
+
+ fun setCmPackage(packages: List, status: Boolean) {
+ cmPackagesState.update {
+ updateAppInfoList(it, packages, status)
+ }
+ }
+
+ @RequiresApi(34)
+ fun applyCmPolicy() = ph.safeDpmCall {
+ val type = cmPolicyState.value
+ dpm.credentialManagerPolicy = if (type != -1 && cmPackagesState.value.isNotEmpty()) {
+ PackagePolicy(type, cmPackagesState.value.map { it.name }.toSet())
+ } else null
+ getCmPolicy()
+ }
+
+ fun updateAppInfoList(
+ origin: List, input: List, status: Boolean
+ ): List {
+ return if (status) {
+ origin + input.map { getAppInfo(pm, it) }
+ } else {
+ origin.filter { it.name !in input }
+ }
+ }
+
+ // Permitted input method
+ val pimAllowAll = MutableStateFlow(true)
+ val pimPackages = MutableStateFlow(emptyList())
+
+ fun getPimPolicy() = ph.safeDpmCall {
+ val packages = dpm.getPermittedInputMethods(dar)
+ pimAllowAll.value = packages == null
+ if (packages != null) pimPackages.value = packages.distinct().map { getAppInfo(pm, it) }
+ }
+
+ fun setPimAllowAll(state: Boolean) {
+ pimAllowAll.value = state
+ }
+
+ fun setPimPackage(packages: List, status: Boolean) {
+ pimPackages.update {
+ updateAppInfoList(it, packages, status)
+ }
+ }
+
+ fun applyPimPolicy() = ph.safeDpmCall {
+ val result = dpm.setPermittedInputMethods(
+ dar, if (pimAllowAll.value) null else pimPackages.value.map { it.name }
+ )
+ toastChannel.sendStatus(result)
+ getPimPolicy()
+ }
+
+ // Permitted accessibility services
+ val pasAllowAll = MutableStateFlow(true)
+ val pasPackages = MutableStateFlow(emptyList())
+ fun getPasPolicy() = ph.safeDpmCall {
+ val packages = dpm.getPermittedAccessibilityServices(dar)
+ pasAllowAll.value = packages == null
+ if (packages != null) pasPackages.value = packages.distinct().map { getAppInfo(pm, it) }
+ }
+
+ fun setPasAllowAll(state: Boolean) {
+ pasAllowAll.value = state
+ }
+
+ fun setPasPackage(packages: List, status: Boolean) {
+ pasPackages.update {
+ updateAppInfoList(it, packages, status)
+ }
+ }
+
+ fun applyPasPolicy() = ph.safeDpmCall {
+ val result = dpm.setPermittedAccessibilityServices(
+ dar, if (pasAllowAll.value) null else pasPackages.value.map { it.name }
+ )
+ toastChannel.sendStatus(result)
+ getPasPolicy()
+ }
+
+ fun enableSystemApp(name: String) = ph.safeDpmCall {
+ dpm.enableSystemApp(dar, name)
+ }
+
+ @RequiresApi(34)
+ fun setDefaultDialer(name: String) = ph.safeDpmCall {
+ val result = try {
+ dpm.setDefaultDialerApplication(name)
+ true
+ } catch (e: IllegalArgumentException) {
+ e.printStackTrace()
+ false
+ }
+ toastChannel.sendStatus(result)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupModel.kt
new file mode 100644
index 0000000..a24e367
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupModel.kt
@@ -0,0 +1,15 @@
+package com.bintianqi.owndroid.feature.applications
+
+import com.bintianqi.owndroid.utils.AppInfo
+import kotlinx.serialization.Serializable
+
+@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)
+
+data class AppGroupEditorUiState(
+ val id: Int? = null, val name: String = "", val apps: List = emptyList()
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupRepository.kt
new file mode 100644
index 0000000..f9b74c6
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupRepository.kt
@@ -0,0 +1,31 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.content.ContentValues
+import com.bintianqi.owndroid.MyDbHelper
+
+class AppGroupRepository(val dbHelper: MyDbHelper) {
+ fun getAppGroups(): List {
+ val list = mutableListOf()
+ dbHelper.readableDatabase.rawQuery("SELECT * FROM app_groups", null).use {
+ while (it.moveToNext()) {
+ list += AppGroup(it.getInt(0), it.getString(1), it.getString(2).split(','))
+ }
+ }
+ return list
+ }
+
+ fun setAppGroup(id: Int?, name: String, apps: List) {
+ val cv = ContentValues()
+ cv.put("name", name)
+ cv.put("apps", apps.joinToString(","))
+ if (id == null) {
+ dbHelper.writableDatabase.insert("app_groups", null, cv)
+ } else {
+ dbHelper.writableDatabase.update("app_groups", cv, "id = ?", arrayOf(id.toString()))
+ }
+ }
+
+ fun deleteAppGroup(id: Int) {
+ dbHelper.writableDatabase.delete("app_groups", "id = ?", arrayOf(id.toString()))
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupScreen.kt
new file mode 100644
index 0000000..6c59bca
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupScreen.kt
@@ -0,0 +1,218 @@
+package com.bintianqi.owndroid.feature.applications
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.ui.PackageNameTextField
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.parsePackageNames
+import kotlinx.coroutines.channels.Channel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppGroupsScreen(
+ vm: AppGroupViewModel, navigateToEditScreen: () -> Unit, navigateUp: () -> Unit
+) {
+ val groups by vm.appGroupsState.collectAsStateWithLifecycle()
+ val exportLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("application/json")
+ ) {
+ if (it != null) vm.exportGroups(it)
+ }
+ val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
+ if (it != null) vm.importGroups(it)
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ { Text(stringResource(R.string.app_group)) },
+ 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 = {
+ FloatingActionButton({
+ vm.selectAppGroup(-1)
+ navigateToEditScreen()
+ }) {
+ Icon(Icons.Default.Add, null)
+ }
+ }
+ ) { paddingValues ->
+ LazyColumn(Modifier.padding(paddingValues)) {
+ itemsIndexed(groups, { _, it -> it.id }) { index, it ->
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ vm.selectAppGroup(index)
+ navigateToEditScreen()
+ }
+ .padding(HorizontalPadding, 8.dp)
+ ) {
+ Text(it.name)
+ Text(
+ it.apps.size.toString() + " apps", Modifier.alpha(0.7F),
+ style = typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditAppGroupScreen(
+ vm: AppGroupViewModel, navigateUp: () -> Unit,
+ onChoosePackage: () -> Unit, chosenPackage: Channel
+) {
+ val uiState by vm.editorUiState.collectAsState()
+ var input by rememberSaveable { mutableStateOf("") }
+ val inputPackages = parsePackageNames(input)
+ LaunchedEffect(Unit) {
+ parsePackageNames(chosenPackage.receive()).forEach {
+ vm.setGroupApp(it, true)
+ }
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ {},
+ navigationIcon = { NavIcon(navigateUp) },
+ actions = {
+ if (uiState.id != null) IconButton({
+ vm.deleteGroup()
+ navigateUp()
+ }) {
+ Icon(Icons.Outlined.Delete, null)
+ }
+ FilledIconButton(
+ {
+ vm.setGroup()
+ navigateUp()
+ },
+ enabled = uiState.name.isNotBlank() && uiState.apps.isNotEmpty()
+ ) {
+ Icon(Icons.Default.Check, null)
+ }
+ }
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ LazyColumn(Modifier.padding(paddingValues)) {
+ item {
+ OutlinedTextField(
+ uiState.name, vm::setGroupName,
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 8.dp),
+ label = { Text(stringResource(R.string.name)) }
+ )
+ }
+ items(uiState.apps, { it.name }) {
+ ApplicationItem(it) {
+ vm.setGroupApp(it.name, false)
+ }
+ }
+ item {
+ PackageNameTextField(input, onChoosePackage,
+ Modifier.padding(HorizontalPadding, 8.dp)) { input = it }
+ Button(
+ {
+ inputPackages.forEach {
+ vm.setGroupApp(it, true)
+ }
+ input = ""
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding)
+ .padding(bottom = 10.dp),
+ inputPackages.all { pkg -> pkg !in uiState.apps.map { it.name } }
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupViewModel.kt
new file mode 100644
index 0000000..1eb6c00
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupViewModel.kt
@@ -0,0 +1,79 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.utils.getAppInfo
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.serialization.json.Json
+
+class AppGroupViewModel(
+ val application: MyApplication, val repo: AppGroupRepository,
+ val appGroupsState: MutableStateFlow>
+) : ViewModel() {
+
+ fun getAppGroups() {
+ appGroupsState.value = repo.getAppGroups()
+ }
+
+ fun selectAppGroup(index: Int) {
+ if (index == -1) {
+ editorUiState.value = AppGroupEditorUiState()
+ } else {
+ val group = appGroupsState.value[index]
+ val pm = application.packageManager
+ editorUiState.value = AppGroupEditorUiState(
+ group.id, group.name, group.apps.map { getAppInfo(pm, it) }
+ )
+ }
+ }
+
+ val editorUiState = MutableStateFlow(AppGroupEditorUiState())
+
+ fun setGroupName(name: String) {
+ editorUiState.update { it.copy(name = name) }
+ }
+
+ fun setGroupApp(name: String, state: Boolean) {
+ editorUiState.update { uiState ->
+ val newList = uiState.apps.let { list ->
+ if (state) {
+ list.plus(getAppInfo(application.packageManager, name))
+ } else {
+ list.filter { it.name != name }
+ }
+ }
+ uiState.copy(apps = newList)
+ }
+ }
+
+ fun setGroup() {
+ val uiState = editorUiState.value
+ repo.setAppGroup(uiState.id, uiState.name, uiState.apps.map { it.name })
+ getAppGroups()
+ }
+
+ fun deleteGroup() {
+ repo.deleteAppGroup(editorUiState.value.id!!)
+ appGroupsState.update { group ->
+ group.filter { it.id != editorUiState.value.id }
+ }
+ }
+
+ fun exportGroups(uri: Uri) {
+ application.contentResolver.openOutputStream(uri)!!.use {
+ val list: List = appGroupsState.value
+ it.write(Json.encodeToString(list).encodeToByteArray())
+ }
+ }
+
+ fun importGroups(uri: Uri) {
+ application.contentResolver.openInputStream(uri)!!.use {
+ Json.decodeFromString>(it.readBytes().decodeToString())
+ }.forEach {
+ repo.setAppGroup(null, it.name, it.apps)
+ }
+ getAppGroups()
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerModel.kt
new file mode 100644
index 0000000..c0e5c91
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerModel.kt
@@ -0,0 +1,13 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.content.pm.PackageInfo
+import android.content.pm.PackageInstaller
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SessionParamsOptions(
+ val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL,
+ val keepOriginalEnabledSetting: Boolean = false,
+ val noKill: Boolean = false,
+ val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY,
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerScreen.kt
similarity index 53%
rename from app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt
rename to app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerScreen.kt
index 3d52595..e93c4f2 100644
--- a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerScreen.kt
@@ -1,4 +1,4 @@
-package com.bintianqi.owndroid.ui
+package com.bintianqi.owndroid.feature.applications
import android.content.Intent
import android.content.pm.PackageInfo
@@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
@@ -35,15 +34,15 @@ import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
-import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@@ -52,32 +51,27 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import com.bintianqi.owndroid.APK_MIME
-import com.bintianqi.owndroid.AppInstallerViewModel
-import com.bintianqi.owndroid.AppLockDialog
import com.bintianqi.owndroid.R
-import com.bintianqi.owndroid.SP
-import com.bintianqi.owndroid.SerializableSaver
-import com.bintianqi.owndroid.SessionParamsOptions
-import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage
+import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.screen.AppLockDialog
+import com.bintianqi.owndroid.utils.APK_MIME
+import com.bintianqi.owndroid.utils.SerializableSaver
+import com.bintianqi.owndroid.utils.parsePackageInstallerMessage
import kotlinx.coroutines.launch
import java.net.URLDecoder
-
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
@Composable
fun AppInstaller(
- uiState: AppInstallerViewModel.UiState = AppInstallerViewModel.UiState(),
- onPackagesAdd: (List) -> Unit = {},
- onPackageRemove: (Uri) -> Unit = {},
- onStartInstall: (SessionParamsOptions) -> Unit = {},
- onResultDialogClose: () -> Unit = {}
+ vm: AppInstallerViewModel
) {
+ val uiState by vm.uiState.collectAsState()
var appLockDialog by rememberSaveable { mutableStateOf(false) }
- var options by rememberSaveable(stateSaver = SerializableSaver(SessionParamsOptions.serializer())) {
+ var options by rememberSaveable(
+ stateSaver = SerializableSaver(SessionParamsOptions.serializer())
+ ) {
mutableStateOf(SessionParamsOptions())
}
val coroutine = rememberCoroutineScope()
@@ -88,53 +82,52 @@ fun AppInstaller(
)
},
floatingActionButton = {
- if(uiState.packages.isNotEmpty()) ExtendedFloatingActionButton(
+ if (uiState.packages.isNotEmpty()) ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.start)) },
icon = {
- if(uiState.installing) CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ if (uiState.installing) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(Icons.Default.PlayArrow, null)
},
onClick = {
- if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall(options) else appLockDialog = true
+ if (vm.getAppLockConfig().passwordHash.isEmpty()) vm.startInstall(options)
+ else appLockDialog = true
},
expanded = !uiState.installing
)
}
) { paddingValues ->
- var tab by rememberSaveable { mutableIntStateOf(0) }
val pagerState = rememberPagerState { 2 }
- val scrollState = rememberScrollState()
- tab = pagerState.targetPage
- Column(modifier = Modifier.padding(paddingValues)) {
- TabRow(tab) {
+ val tab = pagerState.targetPage
+ Column(Modifier.padding(paddingValues)) {
+ PrimaryTabRow(tab) {
Tab(
tab == 0,
- onClick = {
- coroutine.launch { scrollState.animateScrollTo(0) }
+ {
coroutine.launch { pagerState.animateScrollToPage(0) }
},
text = { Text(stringResource(R.string.packages)) }
)
Tab(
tab == 1,
- onClick = {
- coroutine.launch { scrollState.animateScrollTo(0) }
+ {
coroutine.launch { pagerState.animateScrollToPage(1) }
},
text = { Text(stringResource(R.string.options)) }
)
}
- HorizontalPager(pagerState, Modifier.fillMaxHeight(), verticalAlignment = Alignment.Top) { page ->
- if (page == 0) Packages(uiState, onPackageRemove, onPackagesAdd)
+ HorizontalPager(
+ pagerState, Modifier.fillMaxHeight(), verticalAlignment = Alignment.Top
+ ) { page ->
+ if (page == 0) Packages(uiState, vm::onPackageRemove, vm::onPackagesAdd)
else Options(options) { options = it }
}
}
- ResultDialog(uiState.result, onResultDialogClose)
+ ResultDialog(uiState.result, vm::closeResultDialog)
}
- if(appLockDialog) {
- AppLockDialog({
+ if (appLockDialog) {
+ AppLockDialog(vm.getAppLockConfig(), {
appLockDialog = false
- onStartInstall(options)
+ vm.startInstall(options)
}) { appLockDialog = false }
}
}
@@ -144,13 +137,14 @@ fun AppInstaller(
private fun Packages(
uiState: AppInstallerViewModel.UiState, onRemove: (Uri) -> Unit, onAdd: (List) -> Unit
) {
- val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onAdd)
+ val chooseSplitPackage =
+ rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onAdd)
LazyColumn(Modifier.padding(top = 8.dp)) {
itemsIndexed(uiState.packages, { _, it -> it }) { i, it ->
val status = when {
uiState.packageWriting < 0 -> 0
i < uiState.packageWriting -> 3
- i == uiState.packageWriting -> 2
+ i == uiState.packageWriting -> 2
else -> 1
}
PackageItem(it, status) { onRemove(it) }
@@ -158,13 +152,21 @@ private fun Packages(
if (!uiState.installing) {
item {
Row(
- Modifier.fillMaxWidth().animateItem().padding(vertical = 4.dp).clickable {
- chooseSplitPackage.launch(APK_MIME)
- }.padding(vertical = 12.dp),
+ Modifier
+ .fillMaxWidth()
+ .animateItem()
+ .padding(vertical = 4.dp)
+ .clickable {
+ chooseSplitPackage.launch(APK_MIME)
+ }
+ .padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp))
- Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium)
+ Text(
+ stringResource(R.string.add_packages),
+ style = MaterialTheme.typography.titleMedium
+ )
}
}
}
@@ -179,7 +181,11 @@ private fun LazyItemScope.PackageItem(uri: Uri, status: Int, onRemove: () -> Uni
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxWidth().animateItem().padding(start = 8.dp, end = 6.dp, bottom = 6.dp).heightIn(min = 40.dp)
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateItem()
+ .padding(start = 8.dp, end = 6.dp, bottom = 6.dp)
+ .heightIn(min = 40.dp)
) {
Text(
URLDecoder.decode(URLDecoder.decode(uri.path ?: uri.toString())),
@@ -190,56 +196,86 @@ private fun LazyItemScope.PackageItem(uri: Uri, status: Int, onRemove: () -> Uni
0 -> IconButton(onRemove) {
Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove))
}
- 2 -> CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp))
- 3 -> Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary)
+
+ 2 -> CircularProgressIndicator(Modifier
+ .padding(end = 8.dp)
+ .size(24.dp))
+ 3 -> Icon(
+ Icons.Default.Check, null, Modifier.padding(end = 8.dp),
+ MaterialTheme.colorScheme.secondary
+ )
}
}
}
@Composable
-private fun Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) = Column {
- Text(
- stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
- style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
- )
- FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) {
- onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false))
- }
- FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) {
- onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING))
- }
- if(Build.VERSION.SDK_INT >= 34) {
- AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) {
- FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) {
- onChange(options.copy(noKill = it))
+private fun Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) =
+ Column {
+ Text(
+ stringResource(R.string.mode),
+ modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
+ style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
+ )
+ FullWidthRadioButtonItem(
+ R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL
+ ) {
+ onChange(
+ options.copy(
+ mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false
+ )
+ )
+ }
+ FullWidthRadioButtonItem(
+ R.string.inherit_existing,
+ options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING
+ ) {
+ onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING))
+ }
+ if (Build.VERSION.SDK_INT >= 34) {
+ AnimatedVisibility(
+ options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING
+ ) {
+ FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) {
+ onChange(options.copy(noKill = it))
+ }
+ }
+ FullWidthCheckBoxItem(
+ R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting
+ ) {
+ onChange(options.copy(keepOriginalEnabledSetting = it))
}
}
- FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) {
- onChange(options.copy(keepOriginalEnabledSetting = it))
+ Text(
+ stringResource(R.string.install_location),
+ modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
+ style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
+ )
+ FullWidthRadioButtonItem(
+ R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO
+ ) {
+ onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO))
+ }
+ FullWidthRadioButtonItem(
+ R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY
+ ) {
+ onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY))
+ }
+ FullWidthRadioButtonItem(
+ R.string.prefer_external,
+ options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL
+ ) {
+ onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL))
}
}
- Text(
- stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
- style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
- )
- FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) {
- onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO))
- }
- FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) {
- onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY))
- }
- FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) {
- onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL))
- }
-}
@Composable
private fun ResultDialog(result: Intent?, onDialogClose: () -> Unit) {
- if(result != null) {
+ if (result != null) {
val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999)
AlertDialog(
title = {
- val text = if(status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure
+ val text =
+ if (status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure
Text(stringResource(text))
},
text = {
diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerViewModel.kt
similarity index 76%
rename from app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt
rename to app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerViewModel.kt
index 75cc03c..c8f38dd 100644
--- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerViewModel.kt
@@ -1,28 +1,30 @@
-package com.bintianqi.owndroid
+package com.bintianqi.owndroid.feature.applications
-import android.app.Application
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
-import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import androidx.core.content.ContextCompat
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.application
+import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import kotlinx.serialization.Serializable
-class AppInstallerViewModel(application: Application): AndroidViewModel(application) {
+class AppInstallerViewModel(
+ val application: MyApplication, val settingsRepo: SettingsRepository
+) : ViewModel() {
+ fun getAppLockConfig() = settingsRepo.data.appLock
val uiState = MutableStateFlow(UiState())
+
data class UiState(
val packages: List = emptyList(),
val installing: Boolean = false,
@@ -36,16 +38,17 @@ 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..= 34) {
- if(options.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent()
+ if (Build.VERSION.SDK_INT >= 34) {
+ if (options.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent()
setDontKillApp(options.noKill)
}
setInstallLocation(options.location)
@@ -93,25 +96,27 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat
}
uiState.update { it.copy(packageWriting = it.packageWriting + 1) }
}
- } catch(e: Exception) {
+ } catch (e: Exception) {
e.printStackTrace()
session.abandon()
uiState.update { it.copy(installing = false, packageWriting = -1) }
return
}
val intent = Intent(ACTION).setPackage(application.packageName)
- val pi = if(Build.VERSION.SDK_INT >= 34) {
+ val pi = if (Build.VERSION.SDK_INT >= 34) {
PendingIntent.getBroadcast(
application, sessionId, intent,
PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE
).intentSender
} else {
- PendingIntent.getBroadcast(application, sessionId, intent, PendingIntent.FLAG_MUTABLE).intentSender
+ PendingIntent.getBroadcast(
+ application, sessionId, intent, PendingIntent.FLAG_MUTABLE
+ ).intentSender
}
session.commit(pi)
}
- inner class Receiver() : BroadcastReceiver() {
+ val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999)
if (statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) {
@@ -127,26 +132,23 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat
}
fun closeResultDialog() {
- if (uiState.value.result?.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) == PackageInstaller.STATUS_SUCCESS) {
- uiState.update { it.copy(emptyList(), packageWriting = -1, result = null) }
+ if (uiState.value.result?.getIntExtra(
+ PackageInstaller.EXTRA_STATUS, 999
+ ) == PackageInstaller.STATUS_SUCCESS
+ ) {
+ uiState.update { it.copy(packages = emptyList(), packageWriting = -1, result = null) }
} else {
uiState.update { it.copy(packageWriting = -1, result = null) }
}
}
override fun onCleared() {
- super.onCleared()
viewModelScope.cancel()
+ application.unregisterReceiver(broadcastReceiver)
+ super.onCleared()
}
+
companion object {
const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED"
}
}
-
-@Serializable
-data class SessionParamsOptions(
- val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL,
- val keepOriginalEnabledSetting: Boolean = false,
- val noKill: Boolean = false,
- val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY,
-)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/Components.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/Components.kt
new file mode 100644
index 0000000..6fb02ef
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/Components.kt
@@ -0,0 +1,192 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT
+import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED
+import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.PermissionItem
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+
+@Composable
+fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 6.dp)
+ .animateItem(),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ rememberDrawablePainter(info.icon), null,
+ Modifier
+ .padding(start = 12.dp, end = 18.dp)
+ .size(30.dp)
+ )
+ Column {
+ Text(info.label)
+ Text(info.name, Modifier.alpha(0.8F), style = typography.bodyMedium)
+ }
+ }
+ IconButton(onClear) {
+ Icon(Icons.Default.Clear, null)
+ }
+ }
+}
+
+@Composable
+fun PackagePermissionDialog(
+ permission: PermissionItem, currentState: Int, isProfileOwner: Boolean, onSet: (Int) -> Unit,
+ onClose: () -> Unit
+) {
+ @Composable
+ fun GrantPermissionItem(label: Int, stateId: Int) {
+ val selected = currentState == stateId
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(if (selected) colorScheme.primaryContainer else Color.Transparent)
+ .clickable { onSet(stateId) }
+ .padding(vertical = 16.dp, horizontal = 12.dp),
+ Arrangement.SpaceBetween, Alignment.CenterVertically,
+ ) {
+ Text(
+ stringResource(label),
+ color = if(selected) colorScheme.primary else Color.Unspecified
+ )
+ if (selected) Icon(Icons.Outlined.CheckCircle, null, tint = colorScheme.primary)
+ }
+ }
+ AlertDialog(
+ onDismissRequest = onClose,
+ confirmButton = { TextButton(onClose) { Text(stringResource(R.string.cancel)) } },
+ title = { Text(stringResource(permission.label)) },
+ text = {
+ Column {
+ Text(permission.id)
+ Spacer(Modifier.padding(vertical = 4.dp))
+ if(!(VERSION.SDK_INT >= 31 && permission.profileOwnerRestricted && isProfileOwner)) {
+ GrantPermissionItem(R.string.granted, PERMISSION_GRANT_STATE_GRANTED)
+ }
+ GrantPermissionItem(R.string.denied, PERMISSION_GRANT_STATE_DENIED)
+ GrantPermissionItem(R.string.default_stringres, PERMISSION_GRANT_STATE_DEFAULT)
+ }
+ }
+ )
+}
+
+@RequiresApi(28)
+@Composable
+internal fun ClearAppStorageDialog(
+ onClear: () -> Unit, onClose: () -> Unit
+) {
+ var clearing by rememberSaveable { mutableStateOf(false) }
+ AlertDialog(
+ title = { Text(stringResource(R.string.clear_app_storage)) },
+ text = {
+ if (clearing) LinearProgressIndicator(Modifier.fillMaxWidth())
+ else Text(stringResource(R.string.clear_app_storage_confirmation))
+ },
+ confirmButton = {
+ TextButton(
+ {
+ clearing = true
+ onClear()
+ },
+ enabled = !clearing,
+ colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error)
+ ) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClose, enabled = !clearing) { Text(stringResource(R.string.cancel)) }
+ },
+ onDismissRequest = {
+ if (!clearing) onClose()
+ },
+ properties = DialogProperties(false, false)
+ )
+}
+
+
+@Composable
+internal fun UninstallAppDialog(
+ onUninstall: ((String?) -> Unit) -> Unit, onClose: (Boolean) -> Unit
+) {
+ var uninstalling by rememberSaveable { mutableStateOf(false) }
+ var errorMessage by rememberSaveable { mutableStateOf(null) }
+ AlertDialog(
+ title = { Text(stringResource(R.string.uninstall)) },
+ text = {
+ if (errorMessage != null) Text(errorMessage!!)
+ if (uninstalling) LinearProgressIndicator(Modifier.fillMaxWidth())
+ },
+ confirmButton = {
+ TextButton(
+ {
+ if (errorMessage == null) {
+ uninstalling = true
+ onUninstall {
+ uninstalling = false
+ if (it == null) onClose(true) else errorMessage = it
+ }
+ } else {
+ onClose(false)
+ }
+ },
+ enabled = !uninstalling
+ ) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ dismissButton = {
+ if (errorMessage == null) TextButton({
+ onClose(false)
+ }, enabled = !uninstalling) { Text(stringResource(R.string.cancel)) }
+ },
+ onDismissRequest = { onClose(false) },
+ properties = DialogProperties(false, false)
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationModel.kt
new file mode 100644
index 0000000..272f150
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationModel.kt
@@ -0,0 +1,42 @@
+package com.bintianqi.owndroid.feature.applications
+
+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/feature/applications/ManagedConfigurationScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationScreen.kt
new file mode 100644
index 0000000..06cf90d
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationScreen.kt
@@ -0,0 +1,441 @@
+package com.bintianqi.owndroid.feature.applications
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.icons.Icons
+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.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+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.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.searchInString
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ManagedConfigurationScreen(
+ vm: ManagedConfigurationViewModel, navigateUp: () -> Unit
+) {
+ val restrictions by vm.restrictionsState.collectAsStateWithLifecycle()
+ var searchMode by rememberSaveable { mutableStateOf(false) }
+ var searchKeyword by rememberSaveable { mutableStateOf("") }
+ val displayRestrictions = if (!searchMode || searchKeyword.isBlank()) {
+ restrictions
+ } else {
+ restrictions.filter {
+ searchInString(searchKeyword, it.key) || 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,
+ ) {
+ ManagedConfigurationDialogContent(dialog!!) {
+ if (it != null) {
+ vm.setRestriction(it)
+ }
+ dialog = null
+ }
+ }
+ }
+ if (clearRestrictionDialog) AlertDialog(
+ text = {
+ Text(stringResource(R.string.clear_configurations))
+ },
+ confirmButton = {
+ TextButton({
+ vm.clearRestrictions()
+ clearRestrictionDialog = false
+ }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ dismissButton = {
+ TextButton({
+ clearRestrictionDialog = false
+ }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = {
+ clearRestrictionDialog = false
+ }
+ )
+}
+
+@Composable
+fun ManagedConfigurationDialogContent(
+ 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))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationViewModel.kt
new file mode 100644
index 0000000..bb7cce9
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationViewModel.kt
@@ -0,0 +1,118 @@
+package com.bintianqi.owndroid.feature.applications
+
+import android.content.RestrictionEntry
+import android.content.RestrictionsManager
+import android.os.Bundle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+class ManagedConfigurationViewModel(
+ val packageName: String, val application: MyApplication, val ph: PrivilegeHelper
+) : ViewModel() {
+ val restrictionsState = MutableStateFlow(emptyList())
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ getRestrictionsWithoutCoroutine()
+ }
+ }
+
+ private fun getRestrictionsWithoutCoroutine() {
+ try {
+ val rm = application.getSystemService(RestrictionsManager::class.java)
+ ph.safeDpmCall {
+ val bundle = dpm.getApplicationRestrictions(dar, packageName)
+ restrictionsState.value = rm.getManifestRestrictions(packageName)?.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()
+ }
+ }
+
+ fun setRestriction(item: AppRestriction) {
+ viewModelScope.launch(Dispatchers.IO) {
+ ph.safeDpmCall {
+ val bundle = transformAppRestriction(
+ restrictionsState.value.filter { it.key != item.key }.plus(item)
+ )
+ dpm.setApplicationRestrictions(dar, packageName, bundle)
+ getRestrictionsWithoutCoroutine()
+ }
+ }
+ }
+
+ fun clearRestrictions() {
+ viewModelScope.launch(Dispatchers.IO) {
+ ph.safeDpmCall {
+ dpm.setApplicationRestrictions(dar, packageName, Bundle())
+ getRestrictionsWithoutCoroutine()
+ }
+ }
+ }
+
+ private 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
+ }
+ }
+
+ private 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
+ }
+
+ override fun onCleared() {
+ viewModelScope.cancel()
+ super.onCleared()
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingModel.kt
new file mode 100644
index 0000000..bca9a8f
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingModel.kt
@@ -0,0 +1,17 @@
+package com.bintianqi.owndroid.feature.network
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+class NetworkLog(
+ val id: Long?,
+ @SerialName("package") val packageName: String,
+ val time: Long,
+ val type: String,
+ val host: String?,
+ val count: Int?,
+ val addresses: List?,
+ val address: String?,
+ val port: Int?
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingRepository.kt
new file mode 100644
index 0000000..8300111
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingRepository.kt
@@ -0,0 +1,97 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.database.DatabaseUtils
+import androidx.core.database.getIntOrNull
+import androidx.core.database.getLongOrNull
+import androidx.core.database.getStringOrNull
+import com.bintianqi.owndroid.MyDbHelper
+import kotlinx.serialization.json.Json
+import java.io.OutputStream
+
+class NetworkLoggingRepository(val dbHelper: MyDbHelper) {
+
+ fun getNetworkLogsCount(): Long {
+ return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "network_logs")
+ }
+
+ fun writeNetworkLogs(logs: List) {
+ val db = dbHelper.writableDatabase
+ val statement = db.compileStatement(
+ "INSERT INTO network_logs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ )
+ db.beginTransaction()
+ logs.forEach { event ->
+ if (event.id == null) statement.bindNull(1)
+ else statement.bindLong(1, event.id)
+ statement.bindString(2, event.packageName)
+ statement.bindLong(3, event.time)
+ statement.bindString(4, event.type)
+ if (event.host == null) {
+ statement.bindNull(5)
+ } else {
+ statement.bindString(5, event.host)
+ }
+ if (event.count == null) {
+ statement.bindNull(6)
+ } else {
+ statement.bindLong(6, event.count.toLong())
+ }
+ if (event.addresses == null) {
+ statement.bindNull(7)
+ } else {
+ statement.bindString(7, event.addresses.joinToString(","))
+ }
+ if (event.address == null) {
+ statement.bindNull(8)
+ } else {
+ statement.bindString(8, event.address)
+ }
+ if (event.port == null) {
+ statement.bindNull(9)
+ } else {
+ statement.bindLong(9, event.port.toLong())
+ }
+ statement.executeInsert()
+ statement.clearBindings()
+ }
+ db.setTransactionSuccessful()
+ db.endTransaction()
+ statement.close()
+ }
+
+ fun exportNetworkLogs(stream: OutputStream) {
+ val bw = stream.bufferedWriter()
+ val json = Json {
+ explicitNulls = false
+ }
+ var offset = 0
+ var addComma = false
+ bw.write("[")
+ while (true) {
+ val cursor = dbHelper.readableDatabase.rawQuery(
+ "SELECT * FROM network_logs LIMIT ? OFFSET ?",
+ arrayOf(100.toString(), offset.toString())
+ )
+ if (cursor.count == 0) break
+ while (cursor.moveToNext()) {
+ if (addComma) bw.write(",")
+ addComma = true
+ val log = NetworkLog(
+ cursor.getLongOrNull(0), cursor.getString(1), cursor.getLong(2),
+ cursor.getString(3), cursor.getStringOrNull(4), cursor.getIntOrNull(5),
+ cursor.getStringOrNull(6)?.split(',')?.filter { it.isNotEmpty() },
+ cursor.getStringOrNull(7), cursor.getIntOrNull(8)
+ )
+ bw.write(json.encodeToString(log))
+ offset += 100
+ }
+ cursor.close()
+ }
+ bw.write("]")
+ bw.close()
+ }
+
+ fun deleteNetworkLogs() {
+ dbHelper.writableDatabase.execSQL("DELETE FROM network_logs")
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingScreen.kt
new file mode 100644
index 0000000..9791628
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingScreen.kt
@@ -0,0 +1,109 @@
+package com.bintianqi.owndroid.feature.network
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.CircularProgressDialog
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.showOperationResultToast
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@RequiresApi(26)
+@Composable
+fun NetworkLoggingScreen(
+ vm: NetworkLoggingViewModel, onNavigateUp: () -> Unit
+) {
+ val context = LocalContext.current
+ var enabled by remember { mutableStateOf(false) }
+ var count by remember { mutableIntStateOf(0) }
+ var dialog by rememberSaveable { mutableStateOf(false) }
+ var exporting by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ vm.getEnabled()
+ vm.getCount()
+ }
+ val exportLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("application/json")
+ ) { uri ->
+ if (uri != null) {
+ exporting = true
+ vm.exportLogs(uri) {
+ exporting = false
+ context.showOperationResultToast(true)
+ }
+ }
+ }
+ MyScaffold(R.string.network_logging, onNavigateUp, 0.dp) {
+ SwitchItem(R.string.enable, enabled, vm::setEnabled)
+ Text(
+ stringResource(R.string.n_logs_in_total, count),
+ Modifier.padding(HorizontalPadding, 5.dp)
+ )
+ Button(
+ {
+ val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
+ exportLauncher.launch("network_logs_$date")
+ },
+ Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding),
+ count > 0
+ ) {
+ Text(stringResource(R.string.export_logs))
+ }
+ if (count > 0) Button(
+ {
+ dialog = true
+ },
+ Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding),
+ ) {
+ Text(stringResource(R.string.delete_logs))
+ }
+ Spacer(Modifier.height(10.dp))
+ Notes(R.string.info_network_log, HorizontalPadding)
+ }
+ if (exporting) CircularProgressDialog { }
+ if (dialog) AlertDialog(
+ text = {
+ Text(stringResource(R.string.delete_logs))
+ },
+ confirmButton = {
+ TextButton({
+ vm.deleteLogs()
+ dialog = false
+ }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ dismissButton = {
+ TextButton({ dialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = { dialog = false }
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingViewModel.kt
new file mode 100644
index 0000000..9ff5e35
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingViewModel.kt
@@ -0,0 +1,51 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.net.Uri
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class NetworkLoggingViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper,
+ val repo: NetworkLoggingRepository
+) : ViewModel() {
+ val enabledState = MutableStateFlow(false)
+
+ @RequiresApi(26)
+ fun getEnabled() = ph.safeDpmCall {
+ enabledState.value = dpm.isNetworkLoggingEnabled(dar)
+ }
+
+ @RequiresApi(26)
+ fun setEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.setNetworkLoggingEnabled(dar, enabled)
+ getEnabled()
+ }
+
+ val countState = MutableStateFlow(0)
+
+ fun getCount() {
+ viewModelScope.launch(Dispatchers.IO) {
+ countState.value = repo.getNetworkLogsCount().toInt()
+ }
+ }
+
+ fun exportLogs(uri: Uri, callback: () -> Unit) {
+ viewModelScope.launch(Dispatchers.IO) {
+ application.contentResolver.openOutputStream(uri)?.use {
+ repo.exportNetworkLogs(it)
+ }
+ withContext(Dispatchers.Main) { callback() }
+ }
+ }
+
+ fun deleteLogs() {
+ repo.deleteNetworkLogs()
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkModel.kt
new file mode 100644
index 0000000..080f8a3
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkModel.kt
@@ -0,0 +1,19 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.admin.DevicePolicyManager
+import com.bintianqi.owndroid.R
+
+@Suppress("InlinedApi")
+enum class PrivateDnsMode(val id: Int, val text: Int) {
+ Opportunistic(DevicePolicyManager.PRIVATE_DNS_MODE_OPPORTUNISTIC, R.string.automatic),
+ Host(DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME, R.string.enabled)
+}
+
+enum class ProxyType(val text: Int) {
+ Off(R.string.proxy_type_off), Pac(R.string.proxy_type_pac), Direct(R.string.proxy_type_direct)
+}
+
+data class RecommendedProxyConf(
+ val type: ProxyType, val url: String, val host: String, val specifyPort: Boolean,
+ val port: Int, val exclude: List
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkScreen.kt
new file mode 100644
index 0000000..c6c0e85
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkScreen.kt
@@ -0,0 +1,271 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.FunctionItem
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.PackageNameTextField
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import kotlinx.coroutines.channels.Channel
+
+@Composable
+fun NetworkScreen(
+ vm: NetworkViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit
+) {
+ val privilege by vm.ps.collectAsStateWithLifecycle()
+ MyScaffold(R.string.network, onNavigateUp, 0.dp) {
+ if (!privilege.dhizuku) FunctionItem(R.string.wifi, icon = R.drawable.wifi_fill0) {
+ onNavigate(Destination.WiFi)
+ }
+ if (VERSION.SDK_INT >= 30) {
+ FunctionItem(R.string.options, icon = R.drawable.tune_fill0) {
+ onNavigate(Destination.NetworkOptions)
+ }
+ }
+ if (!privilege.dhizuku)
+ FunctionItem(R.string.network_stats, icon = R.drawable.query_stats_fill0) {
+ onNavigate(Destination.NetworkStats)
+ }
+ if (VERSION.SDK_INT >= 29 && privilege.device) {
+ FunctionItem(R.string.private_dns, icon = R.drawable.dns_fill0) {
+ onNavigate(Destination.PrivateDns)
+ }
+ }
+ if (VERSION.SDK_INT >= 24) {
+ FunctionItem(R.string.always_on_vpn, icon = R.drawable.vpn_key_fill0) {
+ onNavigate(Destination.AlwaysOnVpnPackage)
+ }
+ }
+ if (privilege.device) {
+ FunctionItem(R.string.recommended_global_proxy, icon = R.drawable.vpn_key_fill0) {
+ onNavigate(Destination.RecommendedGlobalProxy)
+ }
+ }
+ if (VERSION.SDK_INT >= 26 && !privilege.dhizuku && (privilege.device || privilege.work)) {
+ FunctionItem(R.string.network_logging, icon = R.drawable.description_fill0) {
+ onNavigate(Destination.NetworkLogging)
+ }
+ }
+ /*if(VERSION.SDK_INT >= 31) {
+ FunctionItem(R.string.wifi_auth_keypair, icon = R.drawable.key_fill0) { onNavigate(Destination.WifiAuthKeypair) }
+ }*/
+ if (VERSION.SDK_INT >= 33 && (privilege.work || privilege.device)) {
+ FunctionItem(R.string.preferential_network_service, icon = R.drawable.globe_fill0) {
+ onNavigate(Destination.PreferentialNetworkService)
+ }
+ }
+ if (VERSION.SDK_INT >= 28 && privilege.device) {
+ FunctionItem(R.string.override_apn, icon = R.drawable.cell_tower_fill0) {
+ onNavigate(Destination.OverrideApn)
+ }
+ }
+ }
+}
+
+@Composable
+fun NetworkOptionsScreen(
+ vm: NetworkViewModel, onNavigateUp: () -> Unit
+) {
+ val privilege by vm.ps.collectAsState()
+ MyScaffold(R.string.options, onNavigateUp, 0.dp) {
+ if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) {
+ val lanEnabled by vm.lanEnabledState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getLanEnabled()
+ }
+ SwitchItem(
+ R.string.lockdown_admin_configured_network, lanEnabled, vm::setLanEnabled,
+ R.drawable.wifi_password_fill0
+ )
+ Notes(R.string.info_lockdown_admin_configured_network, HorizontalPadding)
+ }
+ }
+}
+
+@RequiresApi(29)
+@Composable
+fun PrivateDnsScreen(
+ vm: NetworkViewModel, onNavigateUp: () -> Unit
+) {
+ val mode by vm.privateDnsModeState.collectAsState()
+ val host by vm.privateDnsHostState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getPrivateDnsConf()
+ }
+ MyScaffold(R.string.private_dns, onNavigateUp, 0.dp) {
+ PrivateDnsMode.entries.forEach {
+ FullWidthRadioButtonItem(it.text, mode == it) { vm.setPrivateDnsMode(it) }
+ }
+ if (mode == PrivateDnsMode.Host) {
+ OutlinedTextField(
+ host, vm::setPrivateDnsHost,
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp),
+ label = { Text(stringResource(R.string.dns_hostname)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ }
+ Button(
+ vm::applyPrivateDnsConf,
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding),
+ mode != null
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ }
+}
+
+@RequiresApi(24)
+@Composable
+fun AlwaysOnVpnPackageScreen(
+ vm: NetworkViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit,
+ onNavigateUp: () -> Unit
+) {
+ val lockdown by vm.alwaysOnVpnLockdownState.collectAsState()
+ val pkgName by vm.alwaysOnVpnPackageState.collectAsState()
+ var initialized by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ if (!initialized) {
+ if (VERSION.SDK_INT >= 29) vm.getAlwaysOnVpnLockdown()
+ vm.getAlwaysOnVpnPackage()
+ }
+ vm.setAlwaysOnVpnPackage(chosenPackage.receive())
+ }
+ MyScaffold(R.string.always_on_vpn, onNavigateUp, 0.dp) {
+ PackageNameTextField(
+ pkgName, onChoosePackage,
+ Modifier.padding(HorizontalPadding, 4.dp),
+ vm::setAlwaysOnVpnPackage
+ )
+ SwitchItem(R.string.enable_lockdown, lockdown, vm::setAlwaysOnVpnLockdown)
+ Button(
+ vm::applyAlwaysOnVpn,
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding)
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ Button(
+ vm::clearAlwaysOnVpnConfig,
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 5.dp)
+ ) {
+ Text(stringResource(R.string.clear_current_config))
+ }
+ Notes(R.string.info_always_on_vpn, HorizontalPadding)
+ }
+}
+
+@Composable
+fun RecommendedGlobalProxyScreen(
+ vm: NetworkViewModel, onNavigateUp: () -> Unit
+) {
+ var type by rememberSaveable { mutableStateOf(ProxyType.Off) }
+ var pacUrl by rememberSaveable { mutableStateOf("") }
+ var specifyPort by rememberSaveable { mutableStateOf(false) }
+ var host by rememberSaveable { mutableStateOf("") }
+ var port by rememberSaveable { mutableStateOf("") }
+ var exclList by rememberSaveable { mutableStateOf("") }
+ MyScaffold(R.string.recommended_global_proxy, onNavigateUp, 0.dp) {
+ ProxyType.entries.forEach {
+ FullWidthRadioButtonItem(it.text, type == it) { type = it }
+ }
+ AnimatedVisibility(type == ProxyType.Pac) {
+ OutlinedTextField(
+ pacUrl, { pacUrl = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp),
+ label = { Text("URL") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ }
+ AnimatedVisibility(type == ProxyType.Direct) {
+ OutlinedTextField(
+ host, { host = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp),
+ label = { Text("Host") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ }
+ AnimatedVisibility(type == ProxyType.Pac && VERSION.SDK_INT >= 30) {
+ FullWidthCheckBoxItem(R.string.specify_port, specifyPort) { specifyPort = it }
+ }
+ AnimatedVisibility((specifyPort && VERSION.SDK_INT >= 30) || type == ProxyType.Direct) {
+ OutlinedTextField(
+ port, { port = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp),
+ label = { Text(stringResource(R.string.port)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ }
+ AnimatedVisibility(type == ProxyType.Direct) {
+ OutlinedTextField(
+ exclList, { exclList = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp),
+ label = { Text(stringResource(R.string.excluded_hosts)) },
+ maxLines = 5,
+ minLines = 2,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ }
+ Button(
+ {
+ vm.setRecommendedGlobalProxy(
+ RecommendedProxyConf(
+ type, pacUrl, host, specifyPort, port.toIntOrNull() ?: 0,
+ exclList.lines().filter { it.isNotBlank() }
+ ))
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp),
+ enabled = type == ProxyType.Off ||
+ (type == ProxyType.Pac && pacUrl.isNotBlank() &&
+ (!specifyPort || port.toIntOrNull() != null)) ||
+ (type == ProxyType.Direct && port.toIntOrNull() != null)
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ Notes(R.string.info_recommended_global_proxy, HorizontalPadding)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsModel.kt
new file mode 100644
index 0000000..c3a372c
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsModel.kt
@@ -0,0 +1,66 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.usage.NetworkStats
+import android.net.ConnectivityManager
+import com.bintianqi.owndroid.R
+
+enum class NetworkStatsMenu {
+ None, Type, Target, NetworkType, StartTime, EndTime, Uid, Tag, State
+}
+
+enum class NetworkStatsType(val text: Int) { Summary(R.string.summary), Details(R.string.details) }
+
+@Suppress("DEPRECATION")
+enum class NetworkType(val type: Int, val text: Int) {
+ Mobile(ConnectivityManager.TYPE_MOBILE, R.string.mobile),
+ Wifi(ConnectivityManager.TYPE_WIFI, R.string.wifi),
+ Bluetooth(ConnectivityManager.TYPE_BLUETOOTH, R.string.bluetooth),
+ Ethernet(ConnectivityManager.TYPE_ETHERNET, R.string.ethernet),
+ Vpn(ConnectivityManager.TYPE_VPN, R.string.vpn),
+}
+
+enum class NetworkStatsTarget(val text: Int, val type: NetworkStatsType, val minApi: Int = 23) {
+ Device(R.string.device, NetworkStatsType.Summary),
+ User(R.string.user, NetworkStatsType.Summary),
+ Uid(R.string.uid, NetworkStatsType.Details),
+ UidTag(R.string.uid_tag, NetworkStatsType.Details, 24),
+ UidTagState(R.string.uid_tag_state, NetworkStatsType.Details, 28)
+}
+
+@Suppress("InlinedApi")
+enum class NetworkStatsState(val id: Int, val text: Int) {
+ All(NetworkStats.Bucket.STATE_ALL, R.string.all),
+ Default(NetworkStats.Bucket.STATE_DEFAULT, R.string.default_str),
+ Foreground(NetworkStats.Bucket.STATE_FOREGROUND, R.string.foreground)
+}
+
+enum class NetworkStatsUID(val uid: Int, val text: Int) {
+ All(NetworkStats.Bucket.UID_ALL, R.string.all),
+ Removed(NetworkStats.Bucket.UID_REMOVED, R.string.uninstalled),
+ Tethering(NetworkStats.Bucket.UID_TETHERING, R.string.tethering)
+}
+
+class QueryNetworkStatsParams(
+ val type: NetworkStatsType,
+ val target: NetworkStatsTarget,
+ val networkType: NetworkType,
+ val startTime: Long,
+ val endTime: Long,
+ val uid: Int,
+ val tag: Int,
+ val state: NetworkStatsState
+)
+
+class NetworkStatsData(
+ val rxBytes: Long,
+ val rxPackets: Long,
+ val txBytes: Long,
+ val txPackets: Long,
+ val uid: Int,
+ val state: Int,
+ val startTime: Long,
+ val endTime: Long,
+ val tag: Int?,
+ val roaming: Int?,
+ val metered: Int?
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsScreen.kt
new file mode 100644
index 0000000..1ef8c60
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsScreen.kt
@@ -0,0 +1,524 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.usage.NetworkStats
+import android.os.Build.VERSION
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material3.Button
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDialog
+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.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.CircularProgressDialog
+import com.bintianqi.owndroid.ui.ErrorDialog
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.MySmallTitleScaffold
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.clickableTextField
+import com.bintianqi.owndroid.utils.formatDate
+import com.bintianqi.owndroid.utils.formatFileSize
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NetworkStatsScreen(
+ vm: NetworkStatsViewModel, choosePackage: () -> Unit, chosenPackage: Channel,
+ navigateUp: () -> Unit, navigateToViewer: () -> Unit
+) {
+ val res = LocalResources.current
+ val privilege by vm.privilegeState.collectAsState()
+ fun getDefaultSummaryTarget(): NetworkStatsTarget {
+ return if (privilege.device) NetworkStatsTarget.Device else NetworkStatsTarget.User
+ }
+ var menu by remember { mutableStateOf(NetworkStatsMenu.None) }
+ var type by rememberSaveable { mutableStateOf(NetworkStatsType.Summary) }
+ var target by rememberSaveable { mutableStateOf(getDefaultSummaryTarget()) }
+ var networkType by rememberSaveable { mutableStateOf(NetworkType.Mobile) }
+ var startTime by rememberSaveable {
+ mutableLongStateOf(System.currentTimeMillis() - 7 * 24 * 60 * 60 * 1000)
+ }
+ var endTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) }
+ var uid by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.UID_ALL) }
+ var tag by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.TAG_NONE) }
+ var state by rememberSaveable { mutableStateOf(NetworkStatsState.All) }
+ var errorMessage by rememberSaveable { mutableStateOf(null) }
+ var querying by rememberSaveable { mutableStateOf(false) }
+ MyScaffold(R.string.network_stats, navigateUp) {
+ ExposedDropdownMenuBox(
+ menu == NetworkStatsMenu.Type,
+ { menu = if (it) NetworkStatsMenu.Type else NetworkStatsMenu.None },
+ Modifier.padding(top = 8.dp, bottom = 4.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(type.text), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true, label = { Text(stringResource(R.string.type)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Type)
+ }
+ )
+ ExposedDropdownMenu(
+ menu == NetworkStatsMenu.Type, { menu = NetworkStatsMenu.None }
+ ) {
+ NetworkStatsType.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ type = it
+ target = if (it == NetworkStatsType.Summary) getDefaultSummaryTarget()
+ else NetworkStatsTarget.Uid
+ menu = NetworkStatsMenu.None
+ }
+ )
+ }
+ }
+ }
+ ExposedDropdownMenuBox(
+ menu == NetworkStatsMenu.Target,
+ { menu = if (it) NetworkStatsMenu.Target else NetworkStatsMenu.None },
+ Modifier.padding(bottom = 4.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(target.text), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text(stringResource(R.string.target)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Target)
+ }
+ )
+ ExposedDropdownMenu(
+ menu == NetworkStatsMenu.Target, { menu = NetworkStatsMenu.None }
+ ) {
+ NetworkStatsTarget.entries.filter {
+ VERSION.SDK_INT >= it.minApi && type == it.type
+ }.forEach {
+ DropdownMenuItem(
+ text = { Text(stringResource(it.text)) },
+ onClick = {
+ target = it
+ menu = NetworkStatsMenu.None
+ }
+ )
+ }
+ }
+ }
+ ExposedDropdownMenuBox(
+ menu == NetworkStatsMenu.NetworkType,
+ { menu = if (it) NetworkStatsMenu.NetworkType else NetworkStatsMenu.None },
+ Modifier.padding(bottom = 4.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(networkType.text), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text(stringResource(R.string.network_type)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.NetworkType)
+ }
+ )
+ ExposedDropdownMenu(
+ menu == NetworkStatsMenu.NetworkType, { menu = NetworkStatsMenu.None }
+ ) {
+ NetworkType.entries.forEach {
+ DropdownMenuItem(
+ text = { Text(stringResource(it.text)) },
+ onClick = {
+ networkType = it
+ menu = NetworkStatsMenu.None
+ }
+ )
+ }
+ }
+ }
+ OutlinedTextField(
+ formatDate(startTime), {},
+ Modifier
+ .fillMaxWidth()
+ .clickableTextField { menu = NetworkStatsMenu.StartTime }
+ .padding(bottom = 4.dp),
+ readOnly = true, label = { Text(stringResource(R.string.start_time)) },
+ isError = startTime >= endTime
+ )
+ OutlinedTextField(
+ formatDate(endTime), {},
+ Modifier
+ .fillMaxWidth()
+ .clickableTextField { menu = NetworkStatsMenu.EndTime }
+ .padding(bottom = 4.dp),
+ readOnly = true,
+ label = { Text(stringResource(R.string.end_time)) },
+ isError = startTime >= endTime
+ )
+ if (
+ target == NetworkStatsTarget.Uid || target == NetworkStatsTarget.UidTag ||
+ target == NetworkStatsTarget.UidTagState
+ ) {
+ ExposedDropdownMenuBox(
+ menu == NetworkStatsMenu.Uid,
+ { menu = if (it) NetworkStatsMenu.Uid else NetworkStatsMenu.None }
+ ) {
+ var uidText by rememberSaveable {
+ mutableStateOf(res.getString(NetworkStatsUID.All.text))
+ }
+ var readOnly by rememberSaveable { mutableStateOf(true) }
+ if (VERSION.SDK_INT >= 24) LaunchedEffect(Unit) {
+ val pkg = chosenPackage.receive()
+ uid = vm.getPackageUid(pkg)
+ uidText = "$uid ($pkg)"
+ }
+ OutlinedTextField(
+ uidText,
+ {
+ uidText = it
+ it.toIntOrNull()?.let { num -> uid = num }
+ },
+ readOnly = readOnly,
+ label = { Text(stringResource(R.string.uid)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Uid)
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ isError = !readOnly && uidText.toIntOrNull() == null,
+ modifier = Modifier
+ .menuAnchor(
+ if (readOnly) ExposedDropdownMenuAnchorType.PrimaryNotEditable
+ else ExposedDropdownMenuAnchorType.PrimaryEditable
+ )
+ .fillMaxWidth()
+ .padding(bottom = 4.dp)
+ )
+ ExposedDropdownMenu(
+ menu == NetworkStatsMenu.Uid, { menu = NetworkStatsMenu.None }
+ ) {
+ NetworkStatsUID.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ uid = it.uid
+ readOnly = true
+ uidText = res.getString(it.text)
+ menu = NetworkStatsMenu.None
+ }
+ )
+ }
+ if (VERSION.SDK_INT >= 24) DropdownMenuItem(
+ { Text(stringResource(R.string.choose_an_app)) },
+ {
+ readOnly = true
+ menu = NetworkStatsMenu.None
+ choosePackage()
+ }
+ )
+ DropdownMenuItem(
+ { Text(stringResource(R.string.input)) },
+ {
+ readOnly = false
+ uidText = ""
+ menu = NetworkStatsMenu.None
+ }
+ )
+ }
+ }
+ }
+ if (
+ VERSION.SDK_INT >= 24 &&
+ (target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState)
+ ) {
+ ExposedDropdownMenuBox(
+ menu == NetworkStatsMenu.Tag,
+ { menu = if (it) NetworkStatsMenu.Tag else NetworkStatsMenu.None },
+ Modifier.padding(bottom = 4.dp)
+ ) {
+ var tagText by rememberSaveable { mutableStateOf(res.getString(R.string.all)) }
+ var readOnly by rememberSaveable { mutableStateOf(true) }
+ OutlinedTextField(
+ tagText,
+ {
+ tagText = it
+ it.toIntOrNull()?.let { num -> tag = num }
+ },
+ Modifier
+ .menuAnchor(
+ if (readOnly) ExposedDropdownMenuAnchorType.PrimaryNotEditable
+ else ExposedDropdownMenuAnchorType.PrimaryEditable
+ )
+ .fillMaxWidth()
+ .padding(bottom = 4.dp),
+ readOnly = readOnly,
+ label = { Text(stringResource(R.string.uid)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Tag)
+ },
+ isError = !readOnly && tagText.toIntOrNull() == null
+ )
+ ExposedDropdownMenu(
+ menu == NetworkStatsMenu.Tag, { menu = NetworkStatsMenu.None }
+ ) {
+ DropdownMenuItem(
+ { Text(stringResource(R.string.all)) },
+ {
+ tag = NetworkStats.Bucket.TAG_NONE
+ tagText = res.getString(R.string.all)
+ readOnly = true
+ menu = NetworkStatsMenu.None
+ }
+ )
+ DropdownMenuItem(
+ { Text(stringResource(R.string.input)) },
+ {
+ tagText = ""
+ readOnly = false
+ menu = NetworkStatsMenu.None
+ }
+ )
+ }
+ }
+ }
+ if (VERSION.SDK_INT >= 28 && target == NetworkStatsTarget.UidTagState) {
+ ExposedDropdownMenuBox(
+ menu == NetworkStatsMenu.State,
+ { menu = if (it) NetworkStatsMenu.State else NetworkStatsMenu.None },
+ Modifier.padding(bottom = 4.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(state.text), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true, label = { Text(stringResource(R.string.uid)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.State)
+ }
+ )
+ ExposedDropdownMenu(
+ menu == NetworkStatsMenu.State, { menu = NetworkStatsMenu.None }
+ ) {
+ NetworkStatsState.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ state = it
+ menu = NetworkStatsMenu.None
+ }
+ )
+ }
+ }
+ }
+ }
+ Button(
+ {
+ querying = true
+ vm.queryStats(
+ QueryNetworkStatsParams(
+ type, target, networkType, startTime, endTime, uid, tag, state
+ )
+ ) {
+ querying = false
+ errorMessage = it
+ if (it == null) navigateToViewer()
+ }
+ },
+ Modifier.fillMaxWidth().padding(top = 8.dp),
+ !querying
+ ) {
+ Text(stringResource(R.string.query))
+ }
+ if (menu == NetworkStatsMenu.StartTime || menu == NetworkStatsMenu.EndTime) {
+ val datePickerState = rememberDatePickerState(
+ if (menu == NetworkStatsMenu.StartTime) startTime else endTime
+ )
+ DatePickerDialog(
+ onDismissRequest = { menu = NetworkStatsMenu.None },
+ dismissButton = {
+ TextButton(onClick = { menu = NetworkStatsMenu.None }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (menu == NetworkStatsMenu.StartTime) {
+ startTime = datePickerState.selectedDateMillis!!
+ } else {
+ endTime = datePickerState.selectedDateMillis!!
+ }
+ menu = NetworkStatsMenu.None
+ },
+ enabled = datePickerState.selectedDateMillis != null
+ ) {
+ Text(stringResource(R.string.confirm))
+ }
+ }
+ ) {
+ DatePicker(datePickerState)
+ }
+ }
+ }
+ if (querying) CircularProgressDialog { }
+ ErrorDialog(errorMessage) { errorMessage = null }
+}
+
+@Composable
+fun NetworkStatsViewerScreen(
+ vm: NetworkStatsViewModel, onNavigateUp: () -> Unit
+) {
+ var index by rememberSaveable { mutableIntStateOf(0) }
+ val size = vm.statsData.size
+ val ps = rememberPagerState { size }
+ index = ps.currentPage
+ val coroutine = rememberCoroutineScope()
+ MySmallTitleScaffold(R.string.network_stats, onNavigateUp, 0.dp) {
+ if (size > 1) Row(
+ Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ {
+ coroutine.launch {
+ ps.animateScrollToPage(index - 1)
+ }
+ },
+ enabled = index > 0
+ ) {
+ Icon(Icons.AutoMirrored.Default.KeyboardArrowLeft, null)
+ }
+ Text("${index + 1} / $size", modifier = Modifier.padding(horizontal = 8.dp))
+ IconButton(
+ {
+ coroutine.launch {
+ ps.animateScrollToPage(index + 1)
+ }
+ },
+ enabled = index < size - 1
+ ) {
+ Icon(Icons.AutoMirrored.Default.KeyboardArrowRight, null)
+ }
+ }
+ HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page ->
+ val item = vm.statsData[page]
+ Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) {
+ Text(
+ formatDate(item.startTime) + "\n~\n" + formatDate(item.endTime),
+ Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
+ )
+ Spacer(Modifier.height(5.dp))
+ val txBytes = item.txBytes
+ Text(
+ stringResource(R.string.transmitted),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Column(Modifier.padding(start = 8.dp, bottom = 4.dp)) {
+ Text("$txBytes bytes (${formatFileSize(txBytes)})")
+ Text(item.txPackets.toString() + " packets")
+ }
+ val rxBytes = item.rxBytes
+ Text(
+ stringResource(R.string.received), style = MaterialTheme.typography.titleMedium
+ )
+ Column(Modifier.padding(start = 8.dp, bottom = 8.dp)) {
+ Text("$rxBytes bytes (${formatFileSize(rxBytes)})")
+ Text(item.rxPackets.toString() + " packets")
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val text = NetworkStatsState.entries.find { it.id == item.state }!!.text
+ Text(
+ stringResource(R.string.state), Modifier.padding(end = 8.dp),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(stringResource(text))
+ }
+ if (VERSION.SDK_INT >= 24) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val tag = item.tag
+ Text(
+ stringResource(R.string.tag),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(
+ if (tag == NetworkStats.Bucket.TAG_NONE) stringResource(
+ R.string.all
+ ) else tag.toString()
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val text = when (item.roaming) {
+ NetworkStats.Bucket.ROAMING_ALL -> R.string.all
+ NetworkStats.Bucket.ROAMING_YES -> R.string.yes
+ NetworkStats.Bucket.ROAMING_NO -> R.string.no
+ else -> R.string.unknown
+ }
+ Text(
+ stringResource(R.string.roaming),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(stringResource(text))
+ }
+ }
+ if (VERSION.SDK_INT >= 26) Row(verticalAlignment = Alignment.CenterVertically) {
+ val text = when (item.metered) {
+ NetworkStats.Bucket.METERED_ALL -> R.string.all
+ NetworkStats.Bucket.METERED_YES -> R.string.yes
+ NetworkStats.Bucket.METERED_NO -> R.string.no
+ else -> R.string.unknown
+ }
+ Text(
+ stringResource(R.string.metered),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(stringResource(text))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsViewModel.kt
new file mode 100644
index 0000000..6b4492e
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsViewModel.kt
@@ -0,0 +1,106 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.usage.NetworkStats
+import android.app.usage.NetworkStatsManager
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class NetworkStatsViewModel(
+ val application: MyApplication, val privilegeState: StateFlow
+) : ViewModel() {
+ var statsData = emptyList()
+ fun readNetworkStats(stats: NetworkStats): List {
+ val list = mutableListOf()
+ while (stats.hasNextBucket()) {
+ val bucket = NetworkStats.Bucket()
+ stats.getNextBucket(bucket)
+ list += readDataFromBucket(bucket)
+ }
+ stats.close()
+ return list
+ }
+
+ @RequiresApi(24)
+ fun getPackageUid(name: String): Int {
+ return application.packageManager.getPackageUid(name, 0)
+ }
+
+ fun readDataFromBucket(bucket: NetworkStats.Bucket): NetworkStatsData {
+ return NetworkStatsData(
+ bucket.rxBytes, bucket.rxPackets, bucket.txBytes, bucket.txPackets,
+ bucket.uid, bucket.state, bucket.startTimeStamp, bucket.endTimeStamp,
+ if (VERSION.SDK_INT >= 24) bucket.tag else null,
+ if (VERSION.SDK_INT >= 24) bucket.roaming else null,
+ if (VERSION.SDK_INT >= 26) bucket.metered else null
+ )
+ }
+
+ @Suppress("NewApi")
+ fun queryStats(params: QueryNetworkStatsParams, callback: (String?) -> Unit) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val nsm = application.getSystemService(NetworkStatsManager::class.java)
+ try {
+ val data = when (params.target) {
+ NetworkStatsTarget.Device -> listOf(
+ readDataFromBucket(
+ nsm.querySummaryForDevice(
+ params.networkType.type, null, params.startTime, params.endTime
+ )
+ )
+ )
+
+ NetworkStatsTarget.User -> listOf(
+ readDataFromBucket(
+ nsm.querySummaryForUser(
+ params.networkType.type, null, params.startTime, params.endTime
+ )
+ )
+ )
+
+ NetworkStatsTarget.Uid -> readNetworkStats(
+ nsm.queryDetailsForUid(
+ params.networkType.type, null, params.startTime, params.endTime,
+ params.uid
+ )
+ )
+
+ NetworkStatsTarget.UidTag -> readNetworkStats(
+ nsm.queryDetailsForUidTag(
+ params.networkType.type, null, params.startTime, params.endTime,
+ params.uid, params.tag
+ )
+ )
+
+ NetworkStatsTarget.UidTagState -> readNetworkStats(
+ nsm.queryDetailsForUidTagState(
+ params.networkType.type, null, params.startTime, params.endTime,
+ params.uid, params.tag, params.state.id
+ )
+ )
+ }
+ statsData = data
+ withContext(Dispatchers.Main) {
+ if (data.isEmpty()) {
+ callback(application.getString(R.string.no_data))
+ } else {
+ callback(null)
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ withContext(Dispatchers.Main) {
+ callback(e.message ?: "")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkViewModel.kt
new file mode 100644
index 0000000..f2a2501
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkViewModel.kt
@@ -0,0 +1,128 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.admin.DevicePolicyManager
+import android.app.admin.IDevicePolicyManager
+import android.net.ProxyInfo
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.core.net.toUri
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class NetworkViewModel(
+ val ph: PrivilegeHelper, val toastChannel: ToastChannel,
+ val ps: MutableStateFlow
+) : ViewModel() {
+ // Lockdown admin configured networks
+ val lanEnabledState = MutableStateFlow(false)
+
+ @RequiresApi(30)
+ fun getLanEnabled() = ph.safeDpmCall {
+ lanEnabledState.value = dpm.hasLockdownAdminConfiguredNetworks(dar)
+ }
+
+ @RequiresApi(30)
+ fun setLanEnabled(state: Boolean) = ph.safeDpmCall {
+ dpm.setConfiguredNetworksLockdownState(dar, state)
+ getLanEnabled()
+ }
+
+ val privateDnsModeState = MutableStateFlow(null)
+ val privateDnsHostState = MutableStateFlow("")
+
+ @RequiresApi(29)
+ fun getPrivateDnsConf() = ph.safeDpmCall {
+ val mode = dpm.getGlobalPrivateDnsMode(dar)
+ privateDnsModeState.value = PrivateDnsMode.entries.find { it.id == mode }
+ privateDnsHostState.value = dpm.getGlobalPrivateDnsHost(dar) ?: ""
+ }
+
+ fun setPrivateDnsMode(mode: PrivateDnsMode) {
+ privateDnsModeState.value = mode
+ }
+
+ fun setPrivateDnsHost(host: String) {
+ privateDnsHostState.value = host
+ }
+
+ @Suppress("PrivateApi")
+ @RequiresApi(29)
+ fun applyPrivateDnsConf() = ph.safeDpmCall {
+ val result = try {
+ val field = DevicePolicyManager::class.java.getDeclaredField("mService")
+ field.isAccessible = true
+ val dpm = field.get(dpm) as IDevicePolicyManager
+ val host =
+ if (privateDnsModeState.value == PrivateDnsMode.Host) privateDnsHostState.value
+ else null
+ val ret = dpm.setGlobalPrivateDns(dar, privateDnsModeState.value!!.id, host)
+ ret == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ toastChannel.sendStatus(result)
+ }
+
+ val alwaysOnVpnPackageState = MutableStateFlow("")
+ val alwaysOnVpnLockdownState = MutableStateFlow(false)
+
+ @RequiresApi(24)
+ fun getAlwaysOnVpnPackage() = ph.safeDpmCall {
+ alwaysOnVpnPackageState.value = dpm.getAlwaysOnVpnPackage(dar) ?: ""
+ }
+
+ @RequiresApi(29)
+ fun getAlwaysOnVpnLockdown() = ph.safeDpmCall {
+ alwaysOnVpnLockdownState.value = dpm.isAlwaysOnVpnLockdownEnabled(dar)
+ }
+
+ fun setAlwaysOnVpnPackage(name: String) {
+ alwaysOnVpnPackageState.value = name
+ }
+
+ fun setAlwaysOnVpnLockdown(state: Boolean) {
+ alwaysOnVpnLockdownState.value = state
+ }
+
+ @RequiresApi(24)
+ fun applyAlwaysOnVpn() = ph.safeDpmCall {
+ val result = try {
+ dpm.setAlwaysOnVpnPackage(
+ dar, alwaysOnVpnPackageState.value, alwaysOnVpnLockdownState.value
+ )
+ true
+ } catch (_: Exception) {
+ false
+ }
+ toastChannel.sendStatus(result)
+ }
+
+ @RequiresApi(24)
+ fun clearAlwaysOnVpnConfig() = ph.safeDpmCall {
+ dpm.setAlwaysOnVpnPackage(dar, null, false)
+ alwaysOnVpnPackageState.value = ""
+ alwaysOnVpnLockdownState.value = false
+ }
+
+ fun setRecommendedGlobalProxy(conf: RecommendedProxyConf) = ph.safeDpmCall {
+ val info = when (conf.type) {
+ ProxyType.Off -> null
+ ProxyType.Pac -> {
+ if (VERSION.SDK_INT >= 30 && conf.specifyPort) {
+ ProxyInfo.buildPacProxy(conf.url.toUri(), conf.port)
+ } else {
+ ProxyInfo.buildPacProxy(conf.url.toUri())
+ }
+ }
+ ProxyType.Direct -> {
+ ProxyInfo.buildDirectProxy(conf.host, conf.port, conf.exclude)
+ }
+ }
+ dpm.setRecommendedGlobalProxy(dar, info)
+ toastChannel.sendStatus(true)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnModel.kt
new file mode 100644
index 0000000..3281797
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnModel.kt
@@ -0,0 +1,112 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.annotation.SuppressLint
+import android.os.Build.VERSION
+import android.telephony.TelephonyManager
+import android.telephony.data.ApnSetting
+
+enum class ApnMenu {
+ None, ApnType, AuthType, Protocol, RoamingProtocol, NetworkType, MvnoType, OperatorNumeric
+}
+
+data class ApnType(val id: Int, val name: String, val requiresApi: Int = 0)
+
+@SuppressLint("InlinedApi")
+val apnTypes = listOf(
+ ApnType(ApnSetting.TYPE_DEFAULT, "Default"),
+ ApnType(ApnSetting.TYPE_MMS, "MMS"),
+ ApnType(ApnSetting.TYPE_SUPL, "SUPL"),
+ ApnType(ApnSetting.TYPE_DUN, "DUN"),
+ ApnType(ApnSetting.TYPE_HIPRI, "HiPri"),
+ ApnType(ApnSetting.TYPE_FOTA, "FOTA"),
+ ApnType(ApnSetting.TYPE_IMS, "IMS"),
+ ApnType(ApnSetting.TYPE_CBS, "CBS"),
+ ApnType(ApnSetting.TYPE_IA, "IA"),
+ ApnType(ApnSetting.TYPE_EMERGENCY, "Emergency"),
+ ApnType(ApnSetting.TYPE_MCX, "MCX", 29),
+ ApnType(ApnSetting.TYPE_XCAP, "XCAP", 30),
+ ApnType(ApnSetting.TYPE_VSIM, "VSIM", 31),
+ ApnType(ApnSetting.TYPE_BIP, "BIP", 31),
+ ApnType(ApnSetting.TYPE_ENTERPRISE, "Enterprise", 33),
+ ApnType(ApnSetting.TYPE_RCS, "RCS", 35),
+ ApnType(ApnSetting.TYPE_OEM_PAID, "OEM paid"),
+ ApnType(ApnSetting.TYPE_OEM_PRIVATE, "OEM private")
+).filter { VERSION.SDK_INT >= it.requiresApi }
+
+class ApnProtocol(val id: Int, val text: String, val requiresApi: Int = 28)
+
+@Suppress("InlinedApi")
+val apnProtocols = listOf(
+ ApnProtocol(ApnSetting.PROTOCOL_IP, "IPv4"),
+ ApnProtocol(ApnSetting.PROTOCOL_IPV6, "IPv6"),
+ ApnProtocol(ApnSetting.PROTOCOL_IPV4V6, "IPv4/IPv6"),
+ ApnProtocol(ApnSetting.PROTOCOL_PPP, "PPP"),
+ ApnProtocol(ApnSetting.PROTOCOL_NON_IP, "Non-IP", 29),
+ ApnProtocol(ApnSetting.PROTOCOL_UNSTRUCTURED, "Unstructured", 29)
+)
+
+class ApnAuthType(val id: Int, val text: String)
+
+@Suppress("InlinedApi")
+val apnAuthTypes = listOf(
+ ApnAuthType(ApnSetting.AUTH_TYPE_NONE, "None"),
+ ApnAuthType(ApnSetting.AUTH_TYPE_PAP, "PAP"),
+ ApnAuthType(ApnSetting.AUTH_TYPE_CHAP, "CHAP"),
+ ApnAuthType(ApnSetting.AUTH_TYPE_PAP_OR_CHAP, "PAP/CHAP")
+)
+
+data class ApnNetworkType(val id: Int, val text: String, val requiresApi: Int = 0)
+
+@Suppress("InlinedApi", "DEPRECATION")
+val apnNetworkTypes = listOf(
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_LTE, "LTE"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPAP, "HSPA+"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPA, "HSPA"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSUPA, "HSUPA"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSDPA, "HSDPA"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_UMTS, "UMTS"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_EDGE, "EDGE"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_GPRS, "GPRS"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_EHRPD, "CDMA - eHRPD"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_B, "CDMA - EvDo rev. B"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_A, "CDMA - EvDo rev. A"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_0, "CDMA - EvDo rev. 0"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_1xRTT, "CDMA - 1xRTT"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_CDMA, "CDMA"),
+ ApnNetworkType(TelephonyManager.NETWORK_TYPE_NR, "NR", 29)
+).filter { VERSION.SDK_INT >= it.requiresApi }
+
+@Suppress("InlinedApi")
+enum class ApnMvnoType(val id: Int, val text: String) {
+ SPN(ApnSetting.MVNO_TYPE_SPN, "SPN"),
+ IMSI(ApnSetting.MVNO_TYPE_IMSI, "IMSI"),
+ GID(ApnSetting.MVNO_TYPE_GID, "GID"),
+ ICCID(ApnSetting.MVNO_TYPE_ICCID, "ICCID")
+}
+
+data class ApnConfig(
+ val enabled: Boolean,
+ val name: String,
+ val apn: String,
+ val proxy: String,
+ val port: Int?,
+ val username: String,
+ val password: String,
+ val apnType: Int,
+ val mmsc: String,
+ val mmsProxy: String,
+ val mmsPort: Int?,
+ val authType: Int,
+ val protocol: Int,
+ val roamingProtocol: Int,
+ val networkType: Int,
+ val profileId: Int?,
+ val carrierId: Int?,
+ val mtuV4: Int?,
+ val mtuV6: Int?,
+ val mvno: ApnMvnoType,
+ val operatorNumeric: String,
+ val persistent: Boolean,
+ val alwaysOn: Boolean,
+ val id: Int = -1
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnScreen.kt
new file mode 100644
index 0000000..a326d5d
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnScreen.kt
@@ -0,0 +1,495 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.os.Build.VERSION
+import android.provider.Telephony
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.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.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.MySmallTitleScaffold
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.utils.clickableTextField
+
+@RequiresApi(28)
+@Composable
+fun OverrideApnScreen(
+ vm: OverrideApnViewModel, onNavigateUp: () -> Unit, onNavigateToAddSetting: () -> Unit
+) {
+ val enabled by vm.enabledState.collectAsState()
+ val configs by vm.configsState.collectAsState()
+ LaunchedEffect(Unit) { vm.getConfigs() }
+ MyScaffold(R.string.override_apn, onNavigateUp, 0.dp) {
+ SwitchItem(R.string.enable, enabled, vm::setEnabled)
+ configs.forEach {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp, 8.dp, 8.dp, 8.dp),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Row {
+ Text(it.id.toString(), Modifier.padding(end = 8.dp))
+ Column {
+ Text(it.name)
+ Text(
+ it.apn, Modifier.alpha(0.7F),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ IconButton({
+ vm.selectedConfig = it
+ onNavigateToAddSetting()
+ }) {
+ Icon(Icons.Outlined.Edit, null)
+ }
+ }
+ }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ vm.selectedConfig = null
+ onNavigateToAddSetting()
+ }
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Add, null, Modifier.padding(horizontal = 8.dp))
+ Text(stringResource(R.string.add_config), style = MaterialTheme.typography.labelLarge)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@RequiresApi(28)
+@Composable
+fun AddApnSettingScreen(
+ vm: OverrideApnViewModel, onNavigateUp: () -> Unit
+) {
+ val origin = vm.selectedConfig
+ var menu by remember { mutableStateOf(ApnMenu.None) }
+ var enabled by rememberSaveable { mutableStateOf(true) }
+ var entryName by rememberSaveable { mutableStateOf(origin?.name ?: "") }
+ var apnName by rememberSaveable { mutableStateOf(origin?.apn ?: "") }
+ var apnType by rememberSaveable { mutableIntStateOf(origin?.apnType ?: 0) }
+ var profileId by rememberSaveable { mutableStateOf(origin?.profileId?.toString() ?: "") }
+ var carrierId by rememberSaveable { mutableStateOf(origin?.carrierId?.toString() ?: "") }
+ var authType by rememberSaveable { mutableIntStateOf(0) }
+ var user by rememberSaveable { mutableStateOf(origin?.username ?: "") }
+ var password by rememberSaveable { mutableStateOf(origin?.password ?: "") }
+ var proxy by rememberSaveable { mutableStateOf(origin?.proxy ?: "") }
+ var port by rememberSaveable { mutableStateOf(origin?.port?.toString() ?: "") }
+ var mmsProxy by rememberSaveable { mutableStateOf(origin?.mmsProxy ?: "") }
+ var mmsPort by rememberSaveable { mutableStateOf(origin?.mmsPort?.toString() ?: "") }
+ var mmsc by rememberSaveable { mutableStateOf(origin?.mmsc ?: "") }
+ var mtuV4 by rememberSaveable { mutableStateOf(origin?.mtuV4?.toString() ?: "") }
+ var mtuV6 by rememberSaveable { mutableStateOf(origin?.mtuV6?.toString() ?: "") }
+ var mvnoType by rememberSaveable { mutableStateOf(origin?.mvno ?: ApnMvnoType.SPN) }
+ var networkType by rememberSaveable { mutableIntStateOf(origin?.networkType ?: 0) }
+ var operatorNumeric by rememberSaveable { mutableStateOf(origin?.operatorNumeric ?: "") }
+ var protocol by rememberSaveable { mutableIntStateOf(origin?.protocol ?: 0) }
+ var roamingProtocol by rememberSaveable { mutableIntStateOf(origin?.roamingProtocol ?: 0) }
+ var persistent by rememberSaveable { mutableStateOf(origin?.persistent == true) }
+ var alwaysOn by rememberSaveable { mutableStateOf(origin?.alwaysOn == true) }
+ var errorMessage: String? by rememberSaveable { mutableStateOf(null) }
+ MySmallTitleScaffold(R.string.apn_setting, onNavigateUp) {
+ SwitchItem(
+ R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false
+ )
+ OutlinedTextField(
+ entryName, { entryName = it }, Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ label = { Text("Name") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ OutlinedTextField(
+ apnName, { apnName = it }, Modifier.fillMaxWidth(),
+ label = { Text("APN") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ OutlinedTextField(
+ proxy, { proxy = it }, Modifier.fillMaxWidth(),
+ label = { Text("Proxy") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ OutlinedTextField(
+ port, { port = it }, Modifier.fillMaxWidth(),
+ label = { Text("Port") },
+ isError = port.isNotEmpty() && port.toIntOrNull() == null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ OutlinedTextField(
+ user, { user = it }, Modifier.fillMaxWidth(),
+ label = { Text("Username") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ OutlinedTextField(
+ password, { password = it }, Modifier.fillMaxWidth(),
+ label = { Text("Password") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ OutlinedTextField(
+ apnTypes.filter { apnType and it.id == it.id }.joinToString { it.name }, {},
+ Modifier
+ .fillMaxWidth()
+ .clickableTextField { menu = ApnMenu.ApnType },
+ readOnly = true, label = { Text("APN type") }
+ )
+ OutlinedTextField(
+ mmsc, { mmsc = it }, Modifier.fillMaxWidth(),
+ label = { Text("MMSC") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ OutlinedTextField(
+ mmsProxy, { mmsProxy = it }, Modifier.fillMaxWidth(),
+ label = { Text("MMS proxy") },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ OutlinedTextField(
+ mmsPort, { mmsPort = it }, Modifier.fillMaxWidth(),
+ label = { Text("MMS port") },
+ isError = mmsPort.isNotEmpty() && mmsPort.toIntOrNull() == null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ ExposedDropdownMenuBox(
+ menu == ApnMenu.AuthType, { menu = if (it) ApnMenu.AuthType else ApnMenu.None }
+ ) {
+ OutlinedTextField(
+ apnAuthTypes.find { it.id == authType }!!.text, {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text("Authentication type") },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.AuthType)
+ }
+ )
+ ExposedDropdownMenu(menu == ApnMenu.AuthType, { menu = ApnMenu.None }) {
+ apnAuthTypes.forEach {
+ DropdownMenuItem(
+ { Text(it.text) },
+ {
+ authType = it.id
+ menu = ApnMenu.None
+ }
+ )
+ }
+ }
+ }
+ ExposedDropdownMenuBox(
+ menu == ApnMenu.Protocol, { menu = if (it) ApnMenu.Protocol else ApnMenu.None }
+ ) {
+ OutlinedTextField(
+ apnProtocols.find { it.id == protocol }!!.text, {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text("APN protocol") },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.Protocol)
+ }
+ )
+ ExposedDropdownMenu(menu == ApnMenu.Protocol, { menu = ApnMenu.None }) {
+ apnProtocols.filter { VERSION.SDK_INT >= it.requiresApi }.forEach {
+ DropdownMenuItem(
+ { Text(it.text) },
+ {
+ protocol = it.id
+ menu = ApnMenu.None
+ }
+ )
+ }
+ }
+ }
+ ExposedDropdownMenuBox(
+ menu == ApnMenu.RoamingProtocol,
+ { menu = if (it) ApnMenu.RoamingProtocol else ApnMenu.None }
+ ) {
+ OutlinedTextField(
+ apnProtocols.find { it.id == roamingProtocol }!!.text, {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text("APN roaming protocol") },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol)
+ }
+ )
+ ExposedDropdownMenu(menu == ApnMenu.RoamingProtocol, { menu = ApnMenu.None }) {
+ apnProtocols.filter { VERSION.SDK_INT >= it.requiresApi }.forEach {
+ DropdownMenuItem(
+ { Text(it.text) },
+ {
+ roamingProtocol = it.id
+ menu = ApnMenu.None
+ }
+ )
+ }
+ }
+ }
+ OutlinedTextField(
+ apnNetworkTypes.filter { networkType and it.id == it.id }.joinToString { it.text }, {},
+ Modifier
+ .fillMaxWidth()
+ .clickableTextField { menu = ApnMenu.NetworkType },
+ readOnly = true, label = { Text("Network type") }
+ )
+ if (VERSION.SDK_INT >= 33) OutlinedTextField(
+ profileId, { profileId = it },
+ Modifier.fillMaxWidth(),
+ label = { Text("Profile id") },
+ isError = profileId.isNotEmpty() && profileId.toIntOrNull() == null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ if (VERSION.SDK_INT >= 29) OutlinedTextField(
+ carrierId, { carrierId = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ label = { Text("Carrier id") },
+ isError = carrierId.isNotEmpty() && carrierId.toIntOrNull() == null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ if (VERSION.SDK_INT >= 33) Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp), Arrangement.SpaceBetween
+ ) {
+ OutlinedTextField(
+ mtuV4, { mtuV4 = it }, Modifier.fillMaxWidth(0.49F),
+ label = { Text("MTU (IPv4)") },
+ isError = mtuV4.isNotEmpty() && mtuV4.toIntOrNull() == null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ OutlinedTextField(
+ mtuV6, { mtuV6 = it }, Modifier.fillMaxWidth(0.96F),
+ label = { Text("MTU (IPv6)") },
+ isError = mtuV6.isNotEmpty() && mtuV6.toIntOrNull() == null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ }
+ ExposedDropdownMenuBox(
+ menu == ApnMenu.MvnoType, { menu = if (it) ApnMenu.MvnoType else ApnMenu.None }
+ ) {
+ OutlinedTextField(
+ mvnoType.text, {},
+ Modifier
+ .fillMaxWidth()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
+ readOnly = true, label = { Text("MVNO type") },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol)
+ }
+ )
+ ExposedDropdownMenu(menu == ApnMenu.MvnoType, { menu = ApnMenu.None }) {
+ ApnMvnoType.entries.forEach {
+ DropdownMenuItem(
+ { Text(it.text) },
+ {
+ mvnoType = it
+ menu = ApnMenu.None
+ }
+ )
+ }
+ }
+ }
+ ExposedDropdownMenuBox(
+ menu == ApnMenu.OperatorNumeric,
+ { menu = if (it) ApnMenu.OperatorNumeric else ApnMenu.None }
+ ) {
+ OutlinedTextField(
+ operatorNumeric, {},
+ Modifier
+ .fillMaxWidth()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
+ readOnly = true, label = { Text("Numeric operator ID") }
+ )
+ ExposedDropdownMenu(menu == ApnMenu.OperatorNumeric, { menu = ApnMenu.None }) {
+ listOf(Telephony.Carriers.MCC, Telephony.Carriers.MNC).forEach {
+ DropdownMenuItem({ Text(it) }, {
+ operatorNumeric = it
+ menu = ApnMenu.None
+ })
+ }
+ }
+ }
+ if (VERSION.SDK_INT >= 33) Row(
+ Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Text("Persistent")
+ Switch(persistent, { persistent = it })
+ }
+ Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
+ Text("Always on")
+ Switch(alwaysOn, { alwaysOn = it })
+ }
+ Button(
+ {
+ vm.setConfig(
+ ApnConfig(
+ enabled, entryName, apnName, proxy, port.toIntOrNull(), user, password,
+ apnType,
+ mmsc, mmsProxy, mmsPort.toIntOrNull(), authType, protocol, roamingProtocol,
+ networkType, profileId.toIntOrNull(), carrierId.toIntOrNull(),
+ mtuV4.toIntOrNull(), mtuV6.toIntOrNull(), mvnoType,
+ operatorNumeric, persistent, alwaysOn
+ )
+ ) {
+ onNavigateUp()
+ }
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ ) {
+ Text(stringResource(if (origin != null) R.string.update else R.string.add))
+ }
+ if (origin != null) Button(
+ {
+ vm.removeConfig(origin.id) {
+ onNavigateUp()
+ }
+ },
+ Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError
+ )
+ ) {
+ Text(stringResource(R.string.delete))
+ }
+ if (errorMessage != null) AlertDialog(
+ title = { Text(stringResource(R.string.error)) },
+ text = { Text(errorMessage ?: "") },
+ confirmButton = {
+ TextButton({ errorMessage = null }) { Text(stringResource(R.string.confirm)) }
+ },
+ onDismissRequest = { errorMessage = null }
+ )
+ }
+ if (menu == ApnMenu.ApnType) AlertDialog(
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ apnTypes.forEach { type ->
+ val checked = apnType and type.id == type.id
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ .clickable {
+ apnType = if (checked) apnType and type.id.inv()
+ else apnType or type.id
+ }
+ .padding(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(checked, null)
+ Text(
+ type.name, Modifier.padding(start = 8.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton({ menu = ApnMenu.None }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = { menu = ApnMenu.None }
+ )
+ if (menu == ApnMenu.NetworkType) AlertDialog(
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ apnNetworkTypes.forEach { type ->
+ val checked = type.id and networkType == type.id
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ .clickable {
+ networkType = if (checked) networkType and type.id.inv()
+ else networkType or type.id
+ }
+ .padding(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(checked, null)
+ Text(
+ type.text, Modifier.padding(start = 6.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton({ menu = ApnMenu.None }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = {
+ menu = ApnMenu.None
+ }
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnViewModel.kt
new file mode 100644
index 0000000..7af5a87
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnViewModel.kt
@@ -0,0 +1,116 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.os.Build.VERSION
+import android.telephony.data.ApnSetting
+import androidx.annotation.RequiresApi
+import androidx.core.net.toUri
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import java.net.InetAddress
+
+class OverrideApnViewModel(
+ val ph: PrivilegeHelper, val tc: ToastChannel
+) : ViewModel() {
+ val enabledState = MutableStateFlow(false)
+ @RequiresApi(28)
+ fun getEnabled() = ph.safeDpmCall {
+ dpm.isOverrideApnEnabled(dar)
+ }
+
+ @RequiresApi(28)
+ fun setEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.setOverrideApnsEnabled(dar, enabled)
+ getEnabled()
+ }
+
+ val configsState = MutableStateFlow(listOf())
+
+ @RequiresApi(28)
+ fun getConfigs() = ph.safeDpmCall {
+ configsState.value = dpm.getOverrideApns(dar).map {
+ val proxy =
+ if (VERSION.SDK_INT >= 29) it.proxyAddressAsString else it.proxyAddress.hostName
+ val mmsProxy =
+ if (VERSION.SDK_INT >= 29) it.mmsProxyAddressAsString
+ else it.mmsProxyAddress.hostName
+ ApnConfig(
+ it.isEnabled, it.entryName, it.apnName, proxy, it.proxyPort,
+ it.user, it.password, it.apnTypeBitmask, it.mmsc.toString(),
+ mmsProxy, it.mmsProxyPort,
+ it.authType,
+ it.protocol,
+ it.roamingProtocol,
+ it.networkTypeBitmask,
+ if (VERSION.SDK_INT >= 33) it.profileId else 0,
+ if (VERSION.SDK_INT >= 29) it.carrierId else 0,
+ if (VERSION.SDK_INT >= 33) it.mtuV4 else 0,
+ if (VERSION.SDK_INT >= 33) it.mtuV6 else 0,
+ ApnMvnoType.entries.find { type -> type.id == it.mvnoType }!!,
+ it.operatorNumeric,
+ if (VERSION.SDK_INT >= 33) it.isPersistent else true,
+ if (VERSION.SDK_INT >= 35) it.isAlwaysOn else true,
+ it.id
+ )
+ }
+ }
+
+ var selectedConfig: ApnConfig? = null
+
+ @RequiresApi(28)
+ fun buildApnSetting(config: ApnConfig): ApnSetting? {
+ val builder = ApnSetting.Builder()
+ builder.setCarrierEnabled(config.enabled)
+ builder.setEntryName(config.name)
+ builder.setApnName(config.apn)
+ if (VERSION.SDK_INT >= 29) builder.setProxyAddress(config.proxy)
+ else builder.setProxyAddress(InetAddress.getByName(config.proxy))
+ config.port?.let { builder.setProxyPort(it) }
+ builder.setUser(config.username)
+ builder.setPassword(config.password)
+ builder.setApnTypeBitmask(config.apnType)
+ builder.setMmsc(config.mmsc.toUri())
+ if (VERSION.SDK_INT >= 29) builder.setMmsProxyAddress(config.mmsProxy)
+ else builder.setMmsProxyAddress(InetAddress.getByName(config.mmsProxy))
+ builder.setAuthType(config.authType)
+ builder.setProtocol(config.protocol)
+ builder.setRoamingProtocol(config.roamingProtocol)
+ builder.setNetworkTypeBitmask(config.networkType)
+ if (VERSION.SDK_INT >= 33) config.profileId?.let { builder.setProfileId(it) }
+ if (VERSION.SDK_INT >= 29) config.carrierId?.let { builder.setCarrierId(it) }
+ if (VERSION.SDK_INT >= 33) {
+ config.mtuV4?.let { builder.setMtuV4(it) }
+ config.mtuV6?.let { builder.setMtuV6(it) }
+ }
+ builder.setMvnoType(config.mvno.id)
+ builder.setOperatorNumeric(config.operatorNumeric)
+ if (VERSION.SDK_INT >= 33) builder.setPersistent(config.persistent)
+ if (VERSION.SDK_INT >= 35) builder.setAlwaysOn(config.alwaysOn)
+ return builder.build()
+ }
+
+ @RequiresApi(28)
+ fun setConfig(config: ApnConfig, succeedCallback: () -> Unit) = ph.safeDpmCall {
+ val settings = buildApnSetting(config)
+ val result = if (settings == null) {
+ false
+ } else {
+ if (config.id == -1) {
+ dpm.addOverrideApn(dar, settings) != -1
+ } else {
+ dpm.updateOverrideApn(dar, config.id, settings)
+ }
+ }
+ if (result) succeedCallback() else tc.sendStatus(false)
+ }
+
+ @RequiresApi(28)
+ fun removeConfig(id: Int, succeedCallback: () -> Unit) = ph.safeDpmCall {
+ val result = dpm.removeOverrideApn(dar, id)
+ if (result) {
+ succeedCallback()
+ getConfigs()
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkModel.kt
new file mode 100644
index 0000000..ac94f36
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkModel.kt
@@ -0,0 +1,10 @@
+package com.bintianqi.owndroid.feature.network
+
+class PreferentialNetworkServiceInfo(
+ val enabled: Boolean = true,
+ val id: Int = -1,
+ val allowFallback: Boolean = false,
+ val blockNonMatching: Boolean = false,
+ val excludedUids: List = emptyList(),
+ val includedUids: List = emptyList()
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkScreen.kt
new file mode 100644
index 0000000..382a2df
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkScreen.kt
@@ -0,0 +1,199 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Edit
+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.FilledTonalButton
+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
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.MySmallTitleScaffold
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.HorizontalPadding
+
+@RequiresApi(33)
+@Composable
+fun PreferentialNetworkServiceScreen(
+ vm: PreferentialNetworkViewModel,
+ onNavigateUp: () -> Unit, onNavigate: (Destination.AddPreferentialNetworkServiceConfig) -> Unit
+) {
+ val masterEnabled by vm.enabledState.collectAsState()
+ val configs by vm.configsState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) {
+ vm.getConfigs()
+ }
+ MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp, 0.dp) {
+ SwitchItem(R.string.enabled, masterEnabled, vm::setEnabled)
+ Spacer(Modifier.padding(vertical = 4.dp))
+ configs.forEachIndexed { index, config ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp, 4.dp, 8.dp, 4.dp),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Text(config.id.toString())
+ IconButton({
+ vm.selectedConfigIndex = index
+ onNavigate(Destination.AddPreferentialNetworkServiceConfig)
+ }) {
+ Icon(Icons.Default.Edit, stringResource(R.string.edit))
+ }
+ }
+ }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .clickable {
+ vm.selectedConfigIndex = -1
+ onNavigate(Destination.AddPreferentialNetworkServiceConfig)
+ }
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Add, null, Modifier.padding(horizontal = 8.dp))
+ Text(stringResource(R.string.add_config))
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@RequiresApi(33)
+@Composable
+fun AddPreferentialNetworkServiceConfigScreen(
+ vm: PreferentialNetworkViewModel, onNavigateUp: () -> Unit
+) {
+ val configList by vm.configsState.collectAsState()
+ val origin =
+ if (vm.selectedConfigIndex != -1) configList[vm.selectedConfigIndex]
+ else PreferentialNetworkServiceInfo()
+ val updateMode = origin.id != -1
+ var enabled by rememberSaveable { mutableStateOf(origin.enabled) }
+ var id by rememberSaveable { mutableIntStateOf(origin.id) }
+ var allowFallback by rememberSaveable { mutableStateOf(origin.allowFallback) }
+ var blockNonMatching by rememberSaveable { mutableStateOf(origin.blockNonMatching) }
+ var excludedUids by rememberSaveable { mutableStateOf(origin.excludedUids.joinToString("\n")) }
+ var includedUids by rememberSaveable { mutableStateOf(origin.includedUids.joinToString("\n")) }
+ var dropdown by remember { mutableStateOf(false) }
+ MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp, 0.dp) {
+ SwitchItem(R.string.enabled, enabled, { enabled = it })
+ ExposedDropdownMenuBox(
+ dropdown, { dropdown = it }, Modifier.padding(horizontal = HorizontalPadding)
+ ) {
+ OutlinedTextField(
+ if (id == -1) "" else id.toString(), {},
+ Modifier
+ .fillMaxWidth()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
+ readOnly = true, label = { Text("id") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) }
+ )
+ ExposedDropdownMenu(dropdown, { dropdown = false }) {
+ for (i in 1..5) {
+ DropdownMenuItem(
+ { Text(i.toString()) },
+ {
+ id = i
+ dropdown = false
+ }
+ )
+ }
+ }
+ }
+ SwitchItem(
+ R.string.allow_fallback_to_default_connection,
+ allowFallback, { allowFallback = it }
+ )
+ if (VERSION.SDK_INT >= 34) SwitchItem(
+ R.string.block_non_matching_networks, blockNonMatching, { blockNonMatching = it }
+ )
+ val includedUidsLegal = includedUids.lines().filter { it.isNotBlank() }.let { uid ->
+ uid.isEmpty() || (uid.all { it.toIntOrNull() != null } && excludedUids.isBlank())
+ }
+ OutlinedTextField(
+ includedUids, { includedUids = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding)
+ .padding(bottom = 6.dp),
+ minLines = 2,
+ label = { Text(stringResource(R.string.included_uids)) },
+ supportingText = { Text(stringResource(R.string.one_uid_per_line)) },
+ isError = !includedUidsLegal
+ )
+ val excludedUidsLegal = excludedUids.lines().filter { it.isNotBlank() }.let { uid ->
+ uid.isEmpty() || (uid.all { it.toIntOrNull() != null } && includedUids.isBlank())
+ }
+ OutlinedTextField(
+ excludedUids, { excludedUids = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding)
+ .padding(bottom = 6.dp),
+ minLines = 2,
+ label = { Text(stringResource(R.string.excluded_uids)) },
+ supportingText = { Text(stringResource(R.string.one_uid_per_line)) },
+ isError = !excludedUidsLegal
+ )
+ Button(
+ {
+ vm.setConfig(
+ PreferentialNetworkServiceInfo(
+ enabled, id, allowFallback, blockNonMatching,
+ excludedUids.lines().mapNotNull { it.toIntOrNull() },
+ includedUids.lines().mapNotNull { it.toIntOrNull() }
+ ), true)
+ onNavigateUp()
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp),
+ includedUidsLegal && excludedUidsLegal && id in 1..5
+ ) {
+ Text(stringResource(if (updateMode) R.string.update else R.string.add))
+ }
+ if (updateMode) FilledTonalButton(
+ {
+ vm.setConfig(origin, false)
+ onNavigateUp()
+ },
+ Modifier
+ .padding(horizontal = HorizontalPadding)
+ .fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.delete))
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkViewModel.kt
new file mode 100644
index 0000000..286f500
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkViewModel.kt
@@ -0,0 +1,67 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.admin.PreferentialNetworkServiceConfig
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.PrivilegeHelper
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class PreferentialNetworkViewModel(
+ val ph: PrivilegeHelper
+) : ViewModel() {
+ val enabledState = MutableStateFlow(false)
+
+ @RequiresApi(31)
+ fun getEnabled() = ph.safeDpmCall {
+ enabledState.value = dpm.isPreferentialNetworkServiceEnabled
+ }
+
+ @RequiresApi(31)
+ fun setEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.isPreferentialNetworkServiceEnabled = enabled
+ getEnabled()
+ }
+
+ val configsState = MutableStateFlow(emptyList())
+
+ @RequiresApi(33)
+ fun getConfigs() = ph.safeDpmCall {
+ configsState.value = dpm.preferentialNetworkServiceConfigs.map {
+ PreferentialNetworkServiceInfo(
+ it.isEnabled, it.networkId, it.isFallbackToDefaultConnectionAllowed,
+ if (VERSION.SDK_INT >= 34) it.shouldBlockNonMatchingNetworks() else false,
+ it.excludedUids.toList(), it.includedUids.toList()
+ )
+ }
+ }
+
+ var selectedConfigIndex = -1
+
+ @RequiresApi(33)
+ private fun buildConfig(
+ info: PreferentialNetworkServiceInfo
+ ): PreferentialNetworkServiceConfig {
+ return PreferentialNetworkServiceConfig.Builder().apply {
+ setEnabled(info.enabled)
+ @Suppress("WrongConstant")
+ setNetworkId(info.id)
+ setFallbackToDefaultConnectionAllowed(info.allowFallback)
+ if (VERSION.SDK_INT >= 34) setShouldBlockNonMatchingNetworks(info.blockNonMatching)
+ setIncludedUids(info.includedUids.toIntArray())
+ setExcludedUids(info.excludedUids.toIntArray())
+ }.build()
+ }
+
+ @RequiresApi(33)
+ fun setConfig(info: PreferentialNetworkServiceInfo, state: Boolean) = ph.safeDpmCall {
+ val originList = configsState.value.toMutableList()
+ if (selectedConfigIndex == -1) {
+ originList += info
+ } else {
+ if (state) originList[selectedConfigIndex] = info
+ else originList.removeAt(selectedConfigIndex)
+ }
+ dpm.preferentialNetworkServiceConfigs = originList.map { buildConfig(it) }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiModel.kt
new file mode 100644
index 0000000..6ff989b
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiModel.kt
@@ -0,0 +1,69 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.admin.WifiSsidPolicy
+import android.net.wifi.WifiConfiguration
+import com.bintianqi.owndroid.R
+
+class WifiInfo(
+ val id: Int,
+ val ssid: String,
+ val hiddenSsid: Boolean?,
+ val bssid: String,
+ val macRandomization: WifiMacRandomization?,
+ val status: WifiStatus,
+ val security: WifiSecurity?,
+ val password: String,
+ val ipMode: IpMode?,
+ val ipConf: IpConf?,
+ val proxyMode: ProxyMode?,
+ val proxyConf: ProxyConf?
+)
+
+enum class AddNetworkMenu {
+ None, Status, Security, MacRandomization, Ip, Proxy, HiddenSSID
+}
+
+@Suppress("InlinedApi", "DEPRECATION")
+enum class WifiMacRandomization(val id: Int, val text: Int) {
+ None(WifiConfiguration.RANDOMIZATION_NONE, R.string.none),
+ Persistent(WifiConfiguration.RANDOMIZATION_PERSISTENT, R.string.persistent),
+ NonPersistent(WifiConfiguration.RANDOMIZATION_NON_PERSISTENT, R.string.non_persistent),
+ Auto(WifiConfiguration.RANDOMIZATION_AUTO, R.string.auto)
+}
+
+@Suppress("InlinedApi", "DEPRECATION")
+enum class WifiSecurity(val id: Int, val text: Int) {
+ Open(WifiConfiguration.SECURITY_TYPE_OPEN, R.string.wifi_security_open),
+ Psk(WifiConfiguration.SECURITY_TYPE_PSK, R.string.wifi_security_psk)
+}
+
+@Suppress("DEPRECATION")
+enum class WifiStatus(val id: Int, val text: Int) {
+ Current(WifiConfiguration.Status.CURRENT, R.string.current),
+ Enabled(WifiConfiguration.Status.ENABLED, R.string.enabled),
+ Disabled(WifiConfiguration.Status.DISABLED, R.string.disabled)
+}
+
+class IpConf(val address: String, val gateway: String, val dns: List)
+
+class ProxyConf(val host: String, val port: Int, val exclude: List)
+
+enum class IpMode(val text: Int) {
+ Dhcp(R.string.wifi_mode_dhcp), Static(R.string.static_str)
+}
+
+enum class ProxyMode(val text: Int) {
+ None(R.string.none), Http(R.string.http)
+}
+
+class SsidPolicy(
+ val type: SsidPolicyType = SsidPolicyType.None,
+ val list: List = emptyList()
+)
+
+@Suppress("InlinedApi")
+enum class SsidPolicyType(val id: Int, val text: Int) {
+ None(-1, R.string.none),
+ Whitelist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_ALLOWLIST, R.string.whitelist),
+ Blacklist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_DENYLIST, R.string.blacklist)
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiScreen.kt
new file mode 100644
index 0000000..43b711c
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiScreen.kt
@@ -0,0 +1,782 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.Manifest
+import android.app.admin.DevicePolicyManager
+import android.os.Build.VERSION
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.filled.Add
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.LocationOn
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+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.FilledTonalButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.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.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.FunctionItem
+import com.bintianqi.owndroid.ui.ListItem
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.showOperationResultToast
+import com.bintianqi.owndroid.utils.writeClipBoard
+import com.bintianqi.owndroid.utils.yesOrNo
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import kotlinx.coroutines.launch
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WifiScreen(
+ vm: WifiViewModel, navigate: (Destination) -> Unit, navigateUp: () -> Unit
+) {
+ val coroutine = rememberCoroutineScope()
+ val pagerState = rememberPagerState { 3 }
+ var tabIndex by rememberSaveable { mutableIntStateOf(0) }
+ tabIndex = pagerState.currentPage
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.wifi)) },
+ navigationIcon = { NavIcon(navigateUp) },
+ colors = TopAppBarDefaults.topAppBarColors(
+ MaterialTheme.colorScheme.surfaceContainer
+ )
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ PrimaryTabRow(tabIndex) {
+ Tab(
+ tabIndex == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } },
+ text = { Text(stringResource(R.string.overview)) }
+ )
+ Tab(
+ tabIndex == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } },
+ text = { Text(stringResource(R.string.saved_networks)) }
+ )
+ Tab(
+ tabIndex == 2, { coroutine.launch { pagerState.animateScrollToPage(2) } },
+ text = { Text(stringResource(R.string.add_network)) }
+ )
+ }
+ HorizontalPager(state = pagerState, verticalAlignment = Alignment.Top) { page ->
+ @Suppress("NewApi")
+ when (page) {
+ 0 -> WifiOverviewScreen(vm, navigate)
+ 1 -> SavedNetworks(vm) {
+ navigate(Destination.UpdateNetwork(it))
+ }
+ 2 -> AddNetworkScreenContent(vm) {
+ coroutine.launch { pagerState.animateScrollToPage(1) }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun WifiOverviewScreen(
+ vm: WifiViewModel, navigate: (Destination) -> Unit
+) {
+ val context = LocalContext.current
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ var macDialog by rememberSaveable { mutableStateOf(false) }
+ Column(Modifier.fillMaxSize()) {
+ Spacer(Modifier.height(10.dp))
+ Row(
+ Modifier.fillMaxWidth(),
+ Arrangement.Center,
+ ) {
+ Button({ vm.setWifiEnabled(true) }) {
+ Text(stringResource(R.string.enable))
+ }
+ Spacer(Modifier.width(8.dp))
+ Button({ vm.setWifiEnabled(false) }) {
+ Text(stringResource(R.string.disable))
+ }
+ }
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ Arrangement.Center
+ ) {
+ Button({ vm.disconnect() }) {
+ Text(stringResource(R.string.disconnect))
+ }
+ Spacer(Modifier.width(8.dp))
+ Button({ vm.reconnect() }) {
+ Text(stringResource(R.string.reconnect))
+ }
+ }
+ if (VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) {
+ FunctionItem(R.string.wifi_mac_address) { macDialog = true }
+ }
+ if (VERSION.SDK_INT >= 33 && (privilege.device || privilege.org)) {
+ FunctionItem(R.string.min_wifi_security_level) {
+ navigate(Destination.WifiSecurityLevel)
+ }
+ FunctionItem(R.string.wifi_ssid_policy) {
+ navigate(Destination.WifiSsidPolicy)
+ }
+ }
+ }
+ if (macDialog && VERSION.SDK_INT >= 24) {
+ val mac by vm.macState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getMac()
+ }
+ AlertDialog(
+ title = { Text(stringResource(R.string.wifi_mac_address)) },
+ text = {
+ OutlinedTextField(
+ mac ?: stringResource(R.string.none), {},
+ Modifier.fillMaxWidth(),
+ readOnly = true,
+ textStyle = MaterialTheme.typography.bodyLarge,
+ trailingIcon = {
+ if (mac != null) IconButton({ writeClipBoard(context, mac!!) }) {
+ Icon(painterResource(R.drawable.content_copy_fill0), null)
+ }
+ }
+ )
+ },
+ onDismissRequest = { macDialog = false },
+ confirmButton = {
+ TextButton({ macDialog = false }) {
+ Text(stringResource(R.string.confirm))
+ }
+ }
+ )
+ }
+}
+
+@Suppress("DEPRECATION")
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+private fun SavedNetworks(
+ vm: WifiViewModel, editNetwork: (Int) -> Unit
+) {
+ var dialog by rememberSaveable { mutableIntStateOf(-1) }
+ val list by vm.configuredNetworksState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getConfiguredNetworks()
+ }
+ val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ if (it) vm.getConfiguredNetworks()
+ }
+ LazyColumn {
+ item {
+ if (!locationPermission.status.isGranted) Row(
+ Modifier
+ .padding(10.dp)
+ .fillMaxWidth()
+ .padding(12.dp)
+ .clip(RoundedCornerShape(15))
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .clickable { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) },
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.LocationOn, null,
+ Modifier.padding(start = 8.dp, end = 4.dp),
+ MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ stringResource(R.string.request_location_permission_description),
+ Modifier.padding(8.dp),
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+ itemsIndexed(list) { index, network ->
+ Row(
+ Modifier.fillMaxWidth().padding(12.dp, 4.dp),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Text(network.ssid)
+ IconButton({ dialog = index }) {
+ Icon(painterResource(R.drawable.more_horiz_fill0), null)
+ }
+ }
+ }
+ }
+ if (dialog != -1) AlertDialog(
+ text = {
+ val network = list[dialog]
+ Column {
+ Text(stringResource(R.string.network_id) + ": " + network.id.toString())
+ Spacer(Modifier.height(4.dp))
+ Text("SSID", style = MaterialTheme.typography.titleMedium)
+ SelectionContainer {
+ Text(network.ssid)
+ }
+ Spacer(Modifier.height(4.dp))
+ if (network.bssid.isNotEmpty()) {
+ Text("BSSID", style = MaterialTheme.typography.titleMedium)
+ SelectionContainer {
+ Text(network.bssid)
+ }
+ Spacer(Modifier.height(4.dp))
+ }
+ Text(stringResource(R.string.status), style = MaterialTheme.typography.titleMedium)
+ SelectionContainer {
+ Text(stringResource(network.status.text))
+ }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp), Arrangement.SpaceBetween
+ ) {
+ FilledTonalButton({
+ if (network.status == WifiStatus.Disabled) {
+ vm.enableNetwork(network.id)
+ } else {
+ vm.disableNetwork(network.id)
+ }
+ dialog = -1
+ }) {
+ if (network.status == WifiStatus.Disabled) {
+ Text(stringResource(R.string.enable))
+ } else {
+ Text(stringResource(R.string.disable))
+ }
+ }
+ Row {
+ FilledTonalIconButton({
+ editNetwork(dialog)
+ dialog = -1
+ }) {
+ Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
+ }
+ FilledTonalIconButton({
+ vm.removeNetwork(network.id)
+ dialog = -1
+ }) {
+ Icon(Icons.Outlined.Delete, stringResource(R.string.delete))
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton({ dialog = -1 }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = { dialog = -1 }
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun UpdateNetworkScreen(
+ vm: WifiViewModel, onNavigateUp: () -> Unit
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ { Text(stringResource(R.string.update_network)) },
+ navigationIcon = { NavIcon(onNavigateUp) }
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ Column(
+ Modifier.fillMaxSize().padding(paddingValues)
+ ) {
+ AddNetworkScreenContent(vm, onNavigateUp)
+ }
+ }
+}
+
+@Composable
+fun UnchangedMenuItem(onClick: () -> Unit) {
+ DropdownMenuItem({ Text(stringResource(R.string.unchanged)) }, onClick)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AddNetworkScreenContent(
+ vm: WifiViewModel, onNavigateUp: () -> Unit
+) {
+ val wifiInfo = vm.selectedWifiInfo
+ val updating = wifiInfo != null
+ val context = LocalContext.current
+ var menu by remember { mutableStateOf(AddNetworkMenu.None) }
+ var status by rememberSaveable { mutableStateOf(WifiStatus.Enabled) }
+ var ssid by rememberSaveable { mutableStateOf(wifiInfo?.ssid ?: "") }
+ var hiddenSsid by rememberSaveable { mutableStateOf(false) }
+ var security by rememberSaveable { mutableStateOf(WifiSecurity.Open) }
+ var password by rememberSaveable { mutableStateOf("") }
+ var macRandomization by rememberSaveable {
+ mutableStateOf(WifiMacRandomization.None)
+ }
+ var ipMode by rememberSaveable { mutableStateOf(IpMode.Dhcp) }
+ var ipAddress by rememberSaveable { mutableStateOf("") }
+ var gatewayAddress by rememberSaveable { mutableStateOf("") }
+ var dnsServers by rememberSaveable { mutableStateOf("") }
+ var proxyMode by rememberSaveable { mutableStateOf(ProxyMode.None) }
+ var httpProxyHost by rememberSaveable { mutableStateOf("") }
+ var httpProxyPort by rememberSaveable { mutableStateOf("") }
+ var httpProxyExclList by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ if (updating) {
+ hiddenSsid = null
+ security = null
+ macRandomization = null
+ ipMode = null
+ proxyMode = null
+ status = wifiInfo.status
+ ssid = wifiInfo.ssid
+ }
+ }
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(horizontal = HorizontalPadding)
+ ) {
+ Spacer(Modifier.height(4.dp))
+ ExposedDropdownMenuBox(
+ menu == AddNetworkMenu.Status,
+ { menu = if (it) AddNetworkMenu.Status else AddNetworkMenu.None },
+ Modifier.padding(bottom = 8.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(status.text), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text(stringResource(R.string.status)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Status)
+ },
+ )
+ ExposedDropdownMenu(menu == AddNetworkMenu.Status, { menu = AddNetworkMenu.None }) {
+ WifiStatus.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ status = it
+ menu = AddNetworkMenu.None
+ }
+ )
+ }
+ }
+ }
+ OutlinedTextField(
+ ssid, { ssid = it },
+ Modifier.fillMaxWidth().padding(bottom = 8.dp),
+ label = { Text("SSID") }
+ )
+ ExposedDropdownMenuBox(
+ menu == AddNetworkMenu.HiddenSSID,
+ { menu = if (it) AddNetworkMenu.HiddenSSID else AddNetworkMenu.None },
+ Modifier.padding(bottom = 8.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(hiddenSsid?.yesOrNo ?: R.string.unchanged), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text(stringResource(R.string.hidden_ssid)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.HiddenSSID)
+ }
+ )
+ DropdownMenu(menu == AddNetworkMenu.HiddenSSID, { menu = AddNetworkMenu.None }) {
+ if (updating) UnchangedMenuItem {
+ hiddenSsid = null
+ menu = AddNetworkMenu.None
+ }
+ DropdownMenuItem(
+ { Text(stringResource(R.string.yes)) },
+ {
+ hiddenSsid = true
+ menu = AddNetworkMenu.None
+ }
+ )
+ DropdownMenuItem(
+ { Text(stringResource(R.string.no)) },
+ {
+ hiddenSsid = false
+ menu = AddNetworkMenu.None
+ }
+ )
+ }
+ }
+ ExposedDropdownMenuBox(
+ menu == AddNetworkMenu.Security,
+ { menu = if (it) AddNetworkMenu.Security else AddNetworkMenu.None },
+ Modifier.padding(bottom = 8.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(security?.text ?: R.string.unchanged), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true, label = { Text(stringResource(R.string.security)) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Security) }
+ )
+ ExposedDropdownMenu(menu == AddNetworkMenu.Security, { menu = AddNetworkMenu.None }) {
+ if (updating) UnchangedMenuItem { security = null }
+ WifiSecurity.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ security = it
+ menu = AddNetworkMenu.None
+ }
+ )
+ }
+ }
+ }
+ AnimatedVisibility(security == WifiSecurity.Psk) {
+ OutlinedTextField(
+ password, { password = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ label = { Text(stringResource(R.string.password)) }
+ )
+ }
+ if (VERSION.SDK_INT >= 33) {
+ ExposedDropdownMenuBox(
+ menu == AddNetworkMenu.MacRandomization,
+ { menu = if (it) AddNetworkMenu.MacRandomization else AddNetworkMenu.None },
+ Modifier.padding(bottom = 8.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(macRandomization?.text ?: R.string.unchanged), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text(stringResource(R.string.mac_randomization)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(
+ menu == AddNetworkMenu.MacRandomization
+ )
+ }
+ )
+ ExposedDropdownMenu(
+ menu == AddNetworkMenu.MacRandomization, { menu = AddNetworkMenu.None }
+ ) {
+ if (updating) UnchangedMenuItem { macRandomization = null }
+ WifiMacRandomization.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ macRandomization = it
+ menu = AddNetworkMenu.MacRandomization
+ }
+ )
+ }
+ }
+ }
+ }
+ if (VERSION.SDK_INT >= 33) {
+ ExposedDropdownMenuBox(
+ menu == AddNetworkMenu.Ip,
+ { menu = if (it) AddNetworkMenu.Ip else AddNetworkMenu.None },
+ Modifier.padding(bottom = 8.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(ipMode?.text ?: R.string.unchanged), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true, label = { Text(stringResource(R.string.ip_settings)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Ip)
+ }
+ )
+ ExposedDropdownMenu(menu == AddNetworkMenu.Ip, { menu = AddNetworkMenu.None }) {
+ if (updating) UnchangedMenuItem { ipMode = null }
+ IpMode.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ ipMode = it
+ menu = AddNetworkMenu.None
+ }
+ )
+ }
+ }
+ }
+ AnimatedVisibility(ipMode == IpMode.Static) {
+ Column {
+ OutlinedTextField(
+ ipAddress, { ipAddress = it },
+ Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ label = { Text(stringResource(R.string.ip_address)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
+ )
+ OutlinedTextField(
+ gatewayAddress, { gatewayAddress = it },
+ Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ label = { Text(stringResource(R.string.gateway_address)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
+ )
+ OutlinedTextField(
+ dnsServers, { dnsServers = it },
+ Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ label = { Text(stringResource(R.string.dns_servers)) },
+ minLines = 2,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ }
+ }
+ }
+ if (VERSION.SDK_INT >= 26) {
+ ExposedDropdownMenuBox(
+ menu == AddNetworkMenu.Proxy,
+ { menu = if (it) AddNetworkMenu.Proxy else AddNetworkMenu.None },
+ Modifier.padding(bottom = 8.dp)
+ ) {
+ OutlinedTextField(
+ stringResource(proxyMode?.text ?: R.string.unchanged), {},
+ Modifier
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ readOnly = true,
+ label = { Text(stringResource(R.string.proxy)) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Proxy)
+ }
+ )
+ ExposedDropdownMenu(menu == AddNetworkMenu.Proxy, { menu = AddNetworkMenu.None }) {
+ if (updating) UnchangedMenuItem { proxyMode = null }
+ ProxyMode.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ proxyMode = it
+ menu = AddNetworkMenu.None
+ }
+ )
+ }
+ }
+ }
+ AnimatedVisibility(proxyMode == ProxyMode.Http) {
+ Column {
+ OutlinedTextField(
+ httpProxyHost, { httpProxyHost = it },
+ Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ label = { Text(stringResource(R.string.host)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ httpProxyPort, { httpProxyPort = it },
+ Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ label = { Text(stringResource(R.string.port)) },
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Next, keyboardType = KeyboardType.Number
+ )
+ )
+ OutlinedTextField(
+ httpProxyExclList, { httpProxyExclList = it },
+ Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ label = { Text(stringResource(R.string.excluded_hosts)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ minLines = 2
+ )
+ }
+ }
+ }
+ Button(
+ onClick = {
+ val proxyConf = if (proxyMode == ProxyMode.Http) {
+ ProxyConf(
+ httpProxyHost, httpProxyPort.toInt(),
+ httpProxyExclList.lines().filter { it.isNotBlank() }
+ )
+ } else null
+ val ipConf = if (ipMode == IpMode.Static) {
+ IpConf(ipAddress, gatewayAddress, dnsServers.lines().filter { it.isNotBlank() })
+ } else null
+ val result = vm.setWifi(
+ WifiInfo(
+ -1, ssid, hiddenSsid, "", macRandomization, status, security, password,
+ ipMode, ipConf, proxyMode, proxyConf
+ )
+ )
+ context.showOperationResultToast(result)
+ if (result) onNavigateUp()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ enabled = (proxyMode != ProxyMode.Http ||
+ (httpProxyPort.toIntOrNull() != null && httpProxyHost.isNotBlank()))
+ ) {
+ Text(stringResource(if (updating) R.string.update else R.string.add))
+ }
+ Spacer(Modifier.height(BottomPadding))
+ }
+}
+
+@RequiresApi(33)
+@Composable
+fun WifiSecurityLevelScreen(
+ vm: WifiViewModel, onNavigateUp: () -> Unit
+) {
+ val level by vm.minWifiSecurityLevelState.collectAsState()
+ MyScaffold(R.string.min_wifi_security_level, onNavigateUp, 0.dp) {
+ FullWidthRadioButtonItem(
+ R.string.wifi_security_open, level == DevicePolicyManager.WIFI_SECURITY_OPEN
+ ) {
+ vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_OPEN)
+ }
+ FullWidthRadioButtonItem(
+ "WEP, WPA(2)-PSK", level == DevicePolicyManager.WIFI_SECURITY_PERSONAL
+ ) {
+ vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_PERSONAL)
+ }
+ FullWidthRadioButtonItem(
+ "WPA-EAP", level == DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_EAP
+ ) {
+ vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_EAP)
+ }
+ FullWidthRadioButtonItem(
+ "WPA3-192bit", level == DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_192
+ ) {
+ vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_192)
+ }
+ Spacer(Modifier.height(12.dp))
+ Notes(R.string.info_minimum_wifi_security_level, HorizontalPadding)
+ }
+}
+
+@RequiresApi(33)
+@Composable
+fun WifiSsidPolicyScreen(
+ vm: WifiViewModel, onNavigateUp: () -> Unit
+) {
+ MyScaffold(R.string.wifi_ssid_policy, onNavigateUp, 0.dp) {
+ var type by rememberSaveable { mutableStateOf(SsidPolicyType.None) }
+ val list = rememberSaveable { mutableStateListOf() }
+ LaunchedEffect(Unit) {
+ vm.getSsidPolicy().let {
+ type = it.type
+ list.addAll(it.list)
+ }
+ }
+ SsidPolicyType.entries.forEach {
+ FullWidthRadioButtonItem(it.text, type == it) { type = it }
+ }
+ AnimatedVisibility(type != SsidPolicyType.None) {
+ var inputSsid by remember { mutableStateOf("") }
+ Column(Modifier.padding(horizontal = HorizontalPadding)) {
+ Column(Modifier.animateContentSize()) {
+ for(i in list) {
+ ListItem(i) { list -= i }
+ }
+ }
+ Spacer(Modifier.padding(vertical = 5.dp))
+ OutlinedTextField(
+ inputSsid, { inputSsid = it },
+ Modifier.fillMaxWidth(),
+ label = { Text("SSID") },
+ trailingIcon = {
+ IconButton(
+ onClick = {
+ list += inputSsid
+ inputSsid = ""
+ },
+ enabled = inputSsid.isNotEmpty()
+ ) {
+ Icon(Icons.Default.Add, stringResource(R.string.add))
+ }
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ }
+ }
+ Button(
+ {
+ vm.setSsidPolicy(SsidPolicy(type, list))
+ },
+ Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp),
+ type == SsidPolicyType.None || list.isNotEmpty()
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiViewModel.kt
new file mode 100644
index 0000000..0cfac89
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiViewModel.kt
@@ -0,0 +1,160 @@
+package com.bintianqi.owndroid.feature.network
+
+import android.app.admin.WifiSsidPolicy
+import android.content.Context
+import android.net.IpConfiguration
+import android.net.LinkAddress
+import android.net.ProxyInfo
+import android.net.StaticIpConfiguration
+import android.net.wifi.WifiConfiguration
+import android.net.wifi.WifiManager
+import android.net.wifi.WifiSsid
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.net.InetAddress
+import kotlin.reflect.jvm.jvmErasure
+
+class WifiViewModel(
+ val application: MyApplication, val privilegeHelper: PrivilegeHelper,
+ val toastChannel: ToastChannel, val privilegeState: StateFlow
+) : ViewModel() {
+ val wm = application.getSystemService(Context.WIFI_SERVICE) as WifiManager
+
+ fun setWifiEnabled(enabled: Boolean) {
+ toastChannel.sendStatus(wm.setWifiEnabled(enabled))
+ }
+
+ fun disconnect() {
+ toastChannel.sendStatus(wm.disconnect())
+ }
+
+ fun reconnect() {
+ toastChannel.sendStatus(wm.reconnect())
+ }
+
+ val macState = MutableStateFlow(null)
+
+ @RequiresApi(24)
+ fun getMac() {
+ macState.value = privilegeHelper.dpm.getWifiMacAddress(privilegeHelper.dar)
+ }
+
+ val configuredNetworksState = MutableStateFlow(emptyList())
+
+ @Suppress("MissingPermission")
+ fun getConfiguredNetworks() {
+ configuredNetworksState.value = wm.configuredNetworks.distinctBy { it.networkId }.map { conf ->
+ WifiInfo(
+ conf.networkId, conf.SSID.removeSurrounding("\""), null, conf.BSSID ?: "", null,
+ WifiStatus.entries.find { it.id == conf.status }!!, null, "", null, null, null, null
+ )
+ }
+ }
+
+ fun enableNetwork(id: Int) {
+ toastChannel.sendStatus(wm.enableNetwork(id, false))
+ getConfiguredNetworks()
+ }
+
+ fun disableNetwork(id: Int) {
+ toastChannel.sendStatus(wm.disableNetwork(id))
+ getConfiguredNetworks()
+ }
+
+ fun removeNetwork(id: Int) {
+ toastChannel.sendStatus(wm.removeNetwork(id))
+ getConfiguredNetworks()
+ }
+
+ var selectedWifiInfo: WifiInfo? = null
+
+ fun setWifi(info: WifiInfo): Boolean {
+ val conf = WifiConfiguration()
+ conf.SSID = "\"" + info.ssid + "\""
+ info.hiddenSsid?.let { conf.hiddenSSID = it }
+ if (VERSION.SDK_INT >= 30) info.security?.let { conf.setSecurityParams(it.id) }
+ if (info.security == WifiSecurity.Psk) conf.preSharedKey = info.password
+ if (VERSION.SDK_INT >= 33) info.macRandomization?.let {
+ conf.macRandomizationSetting = it.id
+ }
+ if (VERSION.SDK_INT >= 33 && info.ipMode != null) {
+ val ipConf = if (info.ipMode == IpMode.Static && info.ipConf != null) {
+ val constructor = LinkAddress::class.constructors.find {
+ it.parameters.size == 1 && it.parameters[0].type.jvmErasure == String::class
+ }
+ val address = constructor!!.call(info.ipConf.address)
+ val staticIpConf = StaticIpConfiguration.Builder()
+ .setIpAddress(address)
+ .setGateway(InetAddress.getByName(info.ipConf.gateway))
+ .setDnsServers(info.ipConf.dns.map { InetAddress.getByName(it) })
+ .build()
+ IpConfiguration.Builder().setStaticIpConfiguration(staticIpConf).build()
+ } else null
+ conf.setIpConfiguration(ipConf)
+ }
+ if (VERSION.SDK_INT >= 26 && info.proxyMode != null) {
+ val proxy = if (info.proxyMode == ProxyMode.Http) {
+ info.proxyConf?.let {
+ ProxyInfo.buildDirectProxy(it.host, it.port, it.exclude)
+ }
+ } else null
+ conf.httpProxy = proxy
+ }
+ val result = if (info.id != -1) {
+ conf.networkId = info.id
+ wm.updateNetwork(conf)
+ } else {
+ wm.addNetwork(conf)
+ }
+ if (result != -1) {
+ when (info.status) {
+ WifiStatus.Current -> wm.enableNetwork(result, true)
+ WifiStatus.Enabled -> wm.enableNetwork(result, false)
+ WifiStatus.Disabled -> wm.disableNetwork(result)
+ }
+ getConfiguredNetworks()
+ }
+ return result != -1
+ }
+
+ val minWifiSecurityLevelState = MutableStateFlow(0)
+
+ @RequiresApi(33)
+ fun getMinimumWifiSecurityLevel() {
+ minWifiSecurityLevelState.value = privilegeHelper.dpm.minimumRequiredWifiSecurityLevel
+ }
+
+ @RequiresApi(33)
+ fun setMinimumWifiSecurityLevel(level: Int) {
+ privilegeHelper.dpm.minimumRequiredWifiSecurityLevel = level
+ getMinimumWifiSecurityLevel()
+ }
+
+ @RequiresApi(33)
+ fun getSsidPolicy(): SsidPolicy {
+ val policy = privilegeHelper.dpm.wifiSsidPolicy
+ return SsidPolicy(
+ SsidPolicyType.entries.find { it.id == policy?.policyType } ?: SsidPolicyType.None,
+ policy?.ssids?.map { it.bytes.decodeToString() } ?: emptyList()
+ )
+ }
+
+ @RequiresApi(33)
+ fun setSsidPolicy(policy: SsidPolicy) {
+ val newPolicy = if (policy.type != SsidPolicyType.None) {
+ WifiSsidPolicy(
+ policy.type.id,
+ policy.list.map { WifiSsid.fromBytes(it.encodeToByteArray()) }.toSet()
+ )
+ } else null
+ privilegeHelper.dpm.wifiSsidPolicy = newPolicy
+ toastChannel.sendStatus(true)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordModel.kt
new file mode 100644
index 0000000..6d8f494
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordModel.kt
@@ -0,0 +1,66 @@
+package com.bintianqi.owndroid.feature.password
+
+import android.app.admin.DevicePolicyManager
+import android.os.Build.VERSION
+import com.bintianqi.owndroid.R
+
+class PasswordInfo(
+ val complexity: Int = 0,
+ val complexitySufficient: Boolean = false,
+ val unified: Boolean = false
+)
+
+class RpTokenState(val set: Boolean = false, val active: Boolean = false)
+
+
+class KeyguardDisabledFeature(val id: Int, val text: Int, val requiresApi: Int = 0)
+
+@Suppress("InlinedApi")
+val keyguardDisabledFeatures = listOf(
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_WIDGETS_ALL, R.string.disable_keyguard_features_widgets
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_SECURE_CAMERA,
+ R.string.disable_keyguard_features_camera
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_SECURE_NOTIFICATIONS,
+ R.string.disable_keyguard_features_notification
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS,
+ R.string.disable_keyguard_features_unredacted_notification
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_TRUST_AGENTS,
+ R.string.disable_keyguard_features_trust_agents
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_FINGERPRINT,
+ R.string.disable_keyguard_features_fingerprint
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_FACE, R.string.disable_keyguard_features_face, 28
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_IRIS, R.string.disable_keyguard_features_iris, 28
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_BIOMETRICS,
+ R.string.disable_keyguard_features_biometrics, 28
+ ),
+ KeyguardDisabledFeature(
+ DevicePolicyManager.KEYGUARD_DISABLE_SHORTCUTS_ALL,
+ R.string.disable_keyguard_features_shortcuts, 34
+ )
+).filter { VERSION.SDK_INT >= it.requiresApi }
+
+enum class KeyguardDisableMode(val text: Int) {
+ None(R.string.enable_all), Custom(R.string.custom), All(R.string.disable_all)
+}
+
+data class KeyguardDisableConfig(
+ val mode: KeyguardDisableMode = KeyguardDisableMode.None,
+ val flags: Int = 0
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordScreen.kt
new file mode 100644
index 0000000..ccd6e27
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordScreen.kt
@@ -0,0 +1,487 @@
+package com.bintianqi.owndroid.feature.password
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.admin.DevicePolicyManager
+import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC
+import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC
+import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK
+import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC
+import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX
+import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_SOMETHING
+import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED
+import android.app.admin.DevicePolicyManager.RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT
+import android.app.admin.DevicePolicyManager.RESET_PASSWORD_REQUIRE_ENTRY
+import android.content.Context
+import android.os.Build.VERSION
+import android.os.UserManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.CheckBoxItem
+import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.FunctionItem
+import com.bintianqi.owndroid.ui.InfoItem
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.RadioButtonItem
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.generateBase64Key
+import com.bintianqi.owndroid.utils.yesOrNo
+
+@SuppressLint("NewApi")
+@Composable
+fun PasswordScreen(
+ vm: PasswordViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit
+) {
+ val context = LocalContext.current
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ var dialog by rememberSaveable { mutableIntStateOf(0) }
+ MyScaffold(R.string.password_and_keyguard, onNavigateUp, 0.dp) {
+ FunctionItem(R.string.password_info, icon = R.drawable.info_fill0) {
+ onNavigate(Destination.PasswordInfo)
+ }
+ if (vm.getDisplayDangerousFeatures()) {
+ if (VERSION.SDK_INT >= 26) {
+ FunctionItem(R.string.reset_password_token, icon = R.drawable.key_vertical_fill0) {
+ onNavigate(Destination.ResetPasswordToken)
+ }
+ }
+ FunctionItem(R.string.reset_password, icon = R.drawable.lock_reset_fill0) {
+ onNavigate(Destination.ResetPassword)
+ }
+ }
+ if (VERSION.SDK_INT >= 31) {
+ FunctionItem(R.string.required_password_complexity, icon = R.drawable.password_fill0) {
+ onNavigate(Destination.RequiredPasswordComplexity)
+ }
+ }
+ FunctionItem(
+ R.string.disable_keyguard_features, icon = R.drawable.screen_lock_portrait_fill0
+ ) {
+ onNavigate(Destination.KeyguardDisabledFeatures)
+ }
+ 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 }
+ if (vm.getDisplayDangerousFeatures()) {
+ 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 }
+ }
+ FunctionItem(R.string.pwd_history, icon = R.drawable.history_fill0) { dialog = 5 }
+ if (VERSION.SDK_INT < 31) {
+ FunctionItem(R.string.required_password_quality, icon = R.drawable.password_fill0) {
+ onNavigate(Destination.RequiredPasswordQuality)
+ }
+ }
+ }
+ if (dialog != 0) {
+ val input by when (dialog) {
+ 1 -> vm.maxTimeToLockState
+ 2 -> vm.strongAutoTimeoutState
+ 3 -> vm.expirationTimeoutState
+ 4 -> vm.maxFailedForWipeState
+ else -> vm.historyLengthState
+ }.collectAsState()
+ LaunchedEffect(Unit) {
+ when (dialog) {
+ 1 -> vm.getMaxTimeToLock()
+ 2 -> vm.getStrongAuthTimeout()
+ 3 -> vm.getExpirationTimeout()
+ 4 -> vm.getMaxFailedForWipe()
+ 5 -> vm.getHistoryLength()
+ }
+ }
+ AlertDialog(
+ title = {
+ Text(
+ stringResource(
+ when (dialog) {
+ 1 -> R.string.max_time_to_lock
+ 2 -> R.string.required_strong_auth_timeout
+ 3 -> R.string.pwd_expiration_timeout
+ 4 -> R.string.max_pwd_fail
+ 5 -> R.string.pwd_history
+ else -> R.string.password
+ }
+ )
+ )
+ },
+ text = {
+ val um = context.getSystemService(Context.USER_SERVICE) as UserManager
+ Column {
+ OutlinedTextField(
+ input, {
+ when (dialog) {
+ 1 -> vm.setMaxTimeToLock(it)
+ 2 -> vm.setStrongAuthTimeout(it)
+ 3 -> vm.setExpirationTimeout(it)
+ 4 -> vm.setMaxFailedForWipe(it)
+ 5 -> vm.setHistoryLength(it)
+ }
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ label = {
+ Text(
+ stringResource(
+ when (dialog) {
+ 1, 2, 3 -> R.string.time_unit_ms
+ 4 -> R.string.max_pwd_fail_textfield
+ 5 -> R.string.length
+ else -> R.string.password
+ }
+ )
+ )
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ ),
+ textStyle = typography.bodyLarge
+ )
+ Text(
+ stringResource(
+ when (dialog) {
+ 1 -> R.string.info_screen_timeout
+ 2 -> R.string.info_required_strong_auth_timeout
+ 3 -> R.string.info_password_expiration_timeout
+ 4 -> if (um.isSystemUser) R.string.info_max_failed_password_system_user else R.string.info_max_failed_password_other_user
+ 5 -> R.string.info_password_history_length
+ else -> R.string.password
+ }
+ )
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ {
+ when (dialog) {
+ 1 -> vm.applyMaxTimeToLock()
+ 2 -> vm.applyStrongAuthTimeout()
+ 3 -> vm.applyExpirationTimeout()
+ 4 -> vm.applyMaxFiledForWipe()
+ 5 -> vm.applyHistoryLength()
+ }
+ dialog = 0
+ },
+ enabled = input.toLongOrNull() != null
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ },
+ dismissButton = {
+ TextButton({ dialog = 0 }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = {
+ dialog = 0
+ }
+ )
+ }
+}
+
+fun getComplexityText(complexity: Int): Int {
+ return when (complexity) {
+ DevicePolicyManager.PASSWORD_COMPLEXITY_NONE -> R.string.none
+ DevicePolicyManager.PASSWORD_COMPLEXITY_LOW -> R.string.low
+ DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM -> R.string.medium
+ DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH -> R.string.high
+ else -> R.string.unknown
+ }
+}
+
+@Composable
+fun PasswordInfoScreen(
+ vm: PasswordViewModel, onNavigateUp: () -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ val info by vm.passwordInfoState.collectAsState()
+ var dialog by rememberSaveable { mutableIntStateOf(0) } // 0:none, 1:password complexity
+ LaunchedEffect(Unit) {
+ vm.getPasswordInfo()
+ }
+ MyScaffold(R.string.password_info, onNavigateUp, 0.dp) {
+ if (VERSION.SDK_INT >= 31) {
+ InfoItem(
+ R.string.current_password_complexity, getComplexityText(info.complexity), true
+ ) { dialog = 1 }
+ }
+ InfoItem(R.string.password_sufficient, info.complexitySufficient.yesOrNo)
+ if (VERSION.SDK_INT >= 28 && privilege.work) {
+ InfoItem(R.string.unified_password, info.unified.yesOrNo)
+ }
+ }
+ if (dialog != 0) AlertDialog(
+ text = { Text(stringResource(R.string.info_password_complexity)) },
+ confirmButton = {
+ TextButton({ dialog = 0 }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+}
+
+@RequiresApi(26)
+@Composable
+fun ResetPasswordTokenScreen(
+ vm: PasswordViewModel, onNavigateUp: () -> Unit
+) {
+ var token by rememberSaveable { mutableStateOf("") }
+ val tokenSize = token.encodeToByteArray().size
+ val state by vm.rpTokenState.collectAsState()
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == Activity.RESULT_OK) vm.getRpTokenState()
+ }
+ MyScaffold(R.string.reset_password_token, onNavigateUp) {
+ OutlinedTextField(
+ token, { token = it }, Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.token)) },
+ supportingText = { Text("${tokenSize}/32 bytes") },
+ trailingIcon = {
+ IconButton({ token = generateBase64Key(24) }) {
+ Icon(painterResource(R.drawable.casino_fill0), null)
+ }
+ }
+ )
+ Button(
+ {
+ vm.setRpToken(token)
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ tokenSize >= 32
+ ) {
+ Text(stringResource(R.string.set))
+ }
+ if (state.set && !state.active) Button(
+ {
+ val intent = vm.createActivateRpTokenIntent()
+ if (intent != null) {
+ launcher.launch(intent)
+ }
+ },
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.activate))
+ }
+ if (state.set) Button(
+ vm::clearRpToken,
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.clear))
+ }
+ Spacer(Modifier.padding(vertical = 5.dp))
+ Notes(R.string.activate_token_not_required_when_no_password)
+ }
+}
+
+@Composable
+fun ResetPasswordScreen(vm: PasswordViewModel, onNavigateUp: () -> Unit) {
+ var password by rememberSaveable { mutableStateOf("") }
+ var token by rememberSaveable { mutableStateOf("") }
+ var flags by rememberSaveable { mutableIntStateOf(0) }
+ var confirmPassword by rememberSaveable { mutableStateOf("") }
+ MyScaffold(R.string.reset_password, onNavigateUp) {
+ if (VERSION.SDK_INT >= 26) {
+ OutlinedTextField(
+ token, { token = it }, Modifier
+ .fillMaxWidth()
+ .padding(bottom = 5.dp),
+ label = { Text(stringResource(R.string.token)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
+ )
+ }
+ OutlinedTextField(
+ password, { password = it },
+ Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.password)) },
+ isError = password.length in 1..3,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password, imeAction = ImeAction.Next
+ ),
+ visualTransformation = PasswordVisualTransformation()
+ )
+ OutlinedTextField(
+ confirmPassword, { confirmPassword = it },
+ Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.confirm_password)) },
+ isError = confirmPassword != password,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
+ ),
+ visualTransformation = PasswordVisualTransformation()
+ )
+ Spacer(Modifier.padding(vertical = 5.dp))
+ 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
+ ) { flags = flags xor RESET_PASSWORD_REQUIRE_ENTRY }
+ Spacer(Modifier.padding(vertical = 5.dp))
+ Button(
+ {
+ vm.resetPassword(password, token, flags)
+ },
+ Modifier.fillMaxWidth(),
+ password == confirmPassword,
+ colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError)
+ ) {
+ Text(stringResource(R.string.reset_password))
+ }
+ Notes(R.string.info_reset_password)
+ }
+}
+
+@RequiresApi(31)
+@Composable
+fun RequiredPasswordComplexityScreen(
+ vm: PasswordViewModel, onNavigateUp: () -> Unit
+) {
+ val complexity by vm.requiredComplexityState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getRequiredComplexity()
+ }
+ MyScaffold(R.string.required_password_complexity, onNavigateUp, 0.dp) {
+ listOf(
+ DevicePolicyManager.PASSWORD_COMPLEXITY_NONE to R.string.none,
+ DevicePolicyManager.PASSWORD_COMPLEXITY_LOW to R.string.low,
+ DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM to R.string.medium,
+ DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH to R.string.high
+ ).forEach {
+ FullWidthRadioButtonItem(it.second, complexity == it.first) {
+ vm.setRequiredComplexity(it.first)
+ }
+ }
+ Button(
+ vm::applyRequiredComplexity,
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 8.dp)
+ ) {
+ Text(text = stringResource(R.string.apply))
+ }
+ Notes(R.string.info_password_complexity, HorizontalPadding)
+ }
+}
+
+
+@Composable
+fun KeyguardDisabledFeaturesScreen(
+ vm: PasswordViewModel, onNavigateUp: () -> Unit
+) {
+ val config by vm.keyguardDisableState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getKeyguardDisableConfig()
+ }
+ MyScaffold(R.string.disable_keyguard_features, onNavigateUp, 0.dp) {
+ KeyguardDisableMode.entries.forEach {
+ FullWidthRadioButtonItem(it.text, config.mode == it) {
+ vm.setKeyguardDisableConfig(config.copy(mode = it))
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ AnimatedVisibility(config.mode == KeyguardDisableMode.Custom) {
+ Column {
+ keyguardDisabledFeatures.forEach {
+ FullWidthCheckBoxItem(it.text, config.flags and it.id == it.id) { _ ->
+ vm.setKeyguardDisableConfig(config.copy(flags = config.flags xor it.id))
+ }
+ }
+ }
+ }
+ Button(
+ vm::applyKeyguardDisableConfig,
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 8.dp)
+ ) {
+ Text(text = stringResource(R.string.apply))
+ }
+ }
+}
+
+@Composable
+fun RequiredPasswordQualityScreen(
+ vm: PasswordViewModel,
+ onNavigateUp: () -> Unit
+) {
+ val quality by vm.qualityState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getQuality()
+ }
+ MyScaffold(R.string.required_password_quality, onNavigateUp) {
+ mapOf(
+ PASSWORD_QUALITY_UNSPECIFIED to R.string.password_quality_unspecified,
+ PASSWORD_QUALITY_SOMETHING to R.string.password_quality_something,
+ PASSWORD_QUALITY_ALPHABETIC to R.string.password_quality_alphabetic,
+ PASSWORD_QUALITY_NUMERIC to R.string.password_quality_numeric,
+ PASSWORD_QUALITY_ALPHANUMERIC to R.string.password_quality_alphanumeric,
+ PASSWORD_QUALITY_BIOMETRIC_WEAK to R.string.password_quality_biometrics_weak,
+ PASSWORD_QUALITY_NUMERIC_COMPLEX to R.string.password_quality_numeric_complex
+ ).forEach {
+ RadioButtonItem(it.value, quality == it.key) { vm.setQuality(it.key) }
+ }
+ Spacer(Modifier.padding(vertical = 5.dp))
+ Button(
+ vm::applyQuality,
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordViewModel.kt
new file mode 100644
index 0000000..b7f56b0
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordViewModel.kt
@@ -0,0 +1,219 @@
+package com.bintianqi.owndroid.feature.password
+
+import android.app.KeyguardManager
+import android.app.admin.DevicePolicyManager
+import android.content.Intent
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class PasswordViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val settingsRepo: SettingsRepository,
+ val privilegeState: StateFlow, val toastChannel: ToastChannel
+) : ViewModel() {
+ fun getDisplayDangerousFeatures() = settingsRepo.data.displayDangerousFeatures
+
+ val passwordInfoState = MutableStateFlow(PasswordInfo())
+
+ fun getPasswordInfo() = ph.safeDpmCall {
+ val privilege = privilegeState.value
+ passwordInfoState.value = PasswordInfo(
+ complexity = if (VERSION.SDK_INT >= 31) dpm.passwordComplexity else 0,
+ complexitySufficient = dpm.isActivePasswordSufficient,
+ unified =
+ if (VERSION.SDK_INT >= 28 && privilege.work) dpm.isUsingUnifiedPassword(dar)
+ else false
+ )
+ }
+
+ // Reset password token
+ val rpTokenState = MutableStateFlow(RpTokenState())
+
+ @RequiresApi(26)
+ fun getRpTokenState() {
+ rpTokenState.value = try {
+ var active = false
+ ph.safeDpmCall {
+ active = dpm.isResetPasswordTokenActive(dar)
+ }
+ RpTokenState(true, active)
+ } catch (_: IllegalStateException) {
+ RpTokenState(false, false)
+ }
+ }
+
+ @RequiresApi(26)
+ fun setRpToken(token: String) {
+ try {
+ ph.safeDpmCall {
+ val result = dpm.setResetPasswordToken(dar, token.encodeToByteArray())
+ toastChannel.sendStatus(result)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ toastChannel.sendStatus(false)
+ }
+ getRpTokenState()
+ }
+
+ @RequiresApi(26)
+ fun clearRpToken() = ph.safeDpmCall {
+ val result = dpm.clearResetPasswordToken(dar)
+ toastChannel.sendStatus(result)
+ getRpTokenState()
+ }
+
+ @RequiresApi(26)
+ fun createActivateRpTokenIntent(): Intent? {
+ val km = application.getSystemService(KeyguardManager::class.java)
+ val title = application.getString(R.string.activate_reset_password_token)
+ return km.createConfirmDeviceCredentialIntent(title, "")
+ }
+
+ fun resetPassword(password: String, token: String, flags: Int) = ph.safeDpmCall {
+ val result = if (VERSION.SDK_INT >= 26) {
+ dpm.resetPasswordWithToken(dar, password, token.encodeToByteArray(), flags)
+ } else {
+ dpm.resetPassword(password, flags)
+ }
+ toastChannel.sendStatus(result)
+ }
+
+ val requiredComplexityState = MutableStateFlow(0)
+
+ @RequiresApi(31)
+ fun getRequiredComplexity() = ph.safeDpmCall {
+ requiredComplexityState.value = dpm.requiredPasswordComplexity
+ }
+
+ fun setRequiredComplexity(complexity: Int) = ph.safeDpmCall {
+ requiredComplexityState.value = complexity
+ }
+
+ @RequiresApi(31)
+ fun applyRequiredComplexity() = ph.safeDpmCall {
+ dpm.requiredPasswordComplexity = requiredComplexityState.value
+ toastChannel.sendStatus(true)
+ }
+
+ val keyguardDisableState = MutableStateFlow(KeyguardDisableConfig())
+
+ fun getKeyguardDisableConfig() = ph.safeDpmCall {
+ val flags = dpm.getKeyguardDisabledFeatures(dar)
+ val mode = when (flags) {
+ DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -> KeyguardDisableMode.None
+ DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -> KeyguardDisableMode.All
+ else -> KeyguardDisableMode.Custom
+ }
+ keyguardDisableState.value = KeyguardDisableConfig(mode, flags)
+ }
+
+ fun setKeyguardDisableConfig(config: KeyguardDisableConfig) {
+ keyguardDisableState.value = config
+ }
+
+ fun applyKeyguardDisableConfig() = ph.safeDpmCall {
+ val flags = when (keyguardDisableState.value.mode) {
+ KeyguardDisableMode.None -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE
+ KeyguardDisableMode.All -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL
+ else -> keyguardDisableState.value.flags
+ }
+ dpm.setKeyguardDisabledFeatures(dar, flags)
+ toastChannel.sendStatus(true)
+ }
+
+ val maxTimeToLockState = MutableStateFlow("")
+
+ fun getMaxTimeToLock() = ph.safeDpmCall {
+ maxTimeToLockState.value = dpm.getMaximumTimeToLock(dar).toString()
+ }
+
+ fun setMaxTimeToLock(time: String) {
+ maxTimeToLockState.value = time
+ }
+
+ fun applyMaxTimeToLock() = ph.safeDpmCall {
+ dpm.setMaximumTimeToLock(dar, maxTimeToLockState.value.toLong())
+ }
+
+ val strongAutoTimeoutState = MutableStateFlow("")
+
+ @RequiresApi(26)
+ fun getStrongAuthTimeout() = ph.safeDpmCall {
+ strongAutoTimeoutState.value = dpm.getRequiredStrongAuthTimeout(dar).toString()
+ }
+
+ fun setStrongAuthTimeout(time: String) {
+ strongAutoTimeoutState.value = time
+ }
+
+ @RequiresApi(26)
+ fun applyStrongAuthTimeout() = ph.safeDpmCall {
+ dpm.setRequiredStrongAuthTimeout(dar, strongAutoTimeoutState.value.toLong())
+ }
+
+ val expirationTimeoutState = MutableStateFlow("")
+
+ fun getExpirationTimeout() = ph.safeDpmCall {
+ expirationTimeoutState.value = dpm.getPasswordExpirationTimeout(dar).toString()
+ }
+
+ fun setExpirationTimeout(time: String) {
+ expirationTimeoutState.value = time
+ }
+
+ fun applyExpirationTimeout() = ph.safeDpmCall {
+ dpm.setPasswordExpirationTimeout(dar, expirationTimeoutState.value.toLong())
+ }
+
+ val maxFailedForWipeState = MutableStateFlow("")
+
+ fun getMaxFailedForWipe() = ph.safeDpmCall {
+ maxFailedForWipeState.value = dpm.getMaximumFailedPasswordsForWipe(dar).toString()
+ }
+
+ fun setMaxFailedForWipe(times: String) {
+ maxFailedForWipeState.value = times
+ }
+
+ fun applyMaxFiledForWipe() = ph.safeDpmCall {
+ dpm.setMaximumFailedPasswordsForWipe(dar, maxFailedForWipeState.value.toInt())
+ }
+
+ val historyLengthState = MutableStateFlow("")
+
+ fun getHistoryLength() = ph.safeDpmCall {
+ historyLengthState.value = dpm.getPasswordHistoryLength(dar).toString()
+ }
+
+ fun setHistoryLength(length: String) {
+ historyLengthState.value = length
+ }
+
+ fun applyHistoryLength() = ph.safeDpmCall {
+ dpm.setPasswordHistoryLength(dar, historyLengthState.value.toInt())
+ }
+
+ val qualityState = MutableStateFlow(0)
+
+ fun getQuality() = ph.safeDpmCall {
+ qualityState.value = dpm.getPasswordQuality(dar)
+ }
+
+ fun setQuality(level: Int) {
+ qualityState.value = level
+ }
+
+ fun applyQuality() = ph.safeDpmCall {
+ dpm.setPasswordQuality(dar, qualityState.value)
+ toastChannel.sendStatus(true)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsModel.kt
new file mode 100644
index 0000000..6c98c35
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsModel.kt
@@ -0,0 +1,25 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import android.app.admin.DevicePolicyManager
+import android.os.Build.VERSION
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.utils.AppInfo
+
+class DelegatedScope(val id: String, val string: Int, val requiresApi: Int = 26)
+
+@Suppress("InlinedApi")
+val delegatedScopesList = listOf(
+ DelegatedScope(DevicePolicyManager.DELEGATION_APP_RESTRICTIONS, R.string.manage_application_restrictions),
+ DelegatedScope(DevicePolicyManager.DELEGATION_BLOCK_UNINSTALL, R.string.block_uninstall),
+ DelegatedScope(DevicePolicyManager.DELEGATION_CERT_INSTALL, R.string.manage_certificates),
+ DelegatedScope(DevicePolicyManager.DELEGATION_CERT_SELECTION, R.string.select_keychain_certificates, 29),
+ DelegatedScope(DevicePolicyManager.DELEGATION_ENABLE_SYSTEM_APP, R.string.enable_system_app),
+ DelegatedScope(DevicePolicyManager.DELEGATION_INSTALL_EXISTING_PACKAGE, R.string.install_existing_packages, 28),
+ DelegatedScope(DevicePolicyManager.DELEGATION_KEEP_UNINSTALLED_PACKAGES, R.string.manage_uninstalled_packages, 28),
+ DelegatedScope(DevicePolicyManager.DELEGATION_NETWORK_LOGGING, R.string.network_logging, 29),
+ DelegatedScope(DevicePolicyManager.DELEGATION_PACKAGE_ACCESS, R.string.change_package_state),
+ DelegatedScope(DevicePolicyManager.DELEGATION_PERMISSION_GRANT, R.string.grant_permissions),
+ DelegatedScope(DevicePolicyManager.DELEGATION_SECURITY_LOGGING, R.string.security_logging, 31)
+).filter { VERSION.SDK_INT >= it.requiresApi }
+
+class DelegatedAdmin(val app: AppInfo, val scopes: List)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsScreen.kt
new file mode 100644
index 0000000..29bc3d0
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsScreen.kt
@@ -0,0 +1,187 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.MyLazyScaffold
+import com.bintianqi.owndroid.ui.MySmallTitleScaffold
+import com.bintianqi.owndroid.ui.PackageNameTextField
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+import kotlinx.coroutines.channels.Channel
+
+@RequiresApi(26)
+@Composable
+fun DelegatedAdminsScreen(
+ vm: DelegatedAdminsViewModel,
+ onNavigateUp: () -> Unit, onNavigate: (Destination.DelegatedAdminDetails) -> Unit
+) {
+ val admins by vm.delegatedAdminsState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) { vm.getDelegatedAdmins() }
+ MyLazyScaffold(R.string.delegated_admins, onNavigateUp) {
+ itemsIndexed(admins, { _, it -> it.app.name }) { index, admin ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 6.dp)
+ .animateItem(),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ rememberDrawablePainter(admin.app.icon), null,
+ Modifier
+ .padding(start = 12.dp, end = 18.dp)
+ .size(40.dp)
+ )
+ Column {
+ Text(admin.app.label)
+ Text(admin.app.name, Modifier.alpha(0.8F), style = typography.bodyMedium)
+ }
+ }
+ IconButton({
+ vm.selectedDelegatedAdminIndex = index
+ onNavigate(Destination.DelegatedAdminDetails)
+ }) {
+ Icon(Icons.Outlined.Edit, null)
+ }
+ }
+ }
+ item {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ vm.selectedDelegatedAdminIndex = -1
+ onNavigate(Destination.DelegatedAdminDetails)
+ }
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Add, null, Modifier.padding(end = 12.dp))
+ Text(stringResource(R.string.add_delegated_admin), style = typography.titleMedium)
+ }
+ }
+ }
+}
+
+@RequiresApi(26)
+@Composable
+fun AddDelegatedAdminScreen(
+ vm: DelegatedAdminsViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit,
+ onNavigateUp: () -> Unit
+) {
+ val adminsList by vm.delegatedAdminsState.collectAsState()
+ val origin = adminsList.getOrNull(vm.selectedDelegatedAdminIndex)
+ val updateMode = origin != null
+ var input by rememberSaveable { mutableStateOf(origin?.app?.name ?: "") }
+ val scopes = rememberSaveable {
+ mutableStateListOf(*(origin?.scopes?.toTypedArray() ?: emptyArray()))
+ }
+ LaunchedEffect(Unit) {
+ input = chosenPackage.receive()
+ }
+ MySmallTitleScaffold(
+ if (updateMode) R.string.place_holder else R.string.add_delegated_admin, onNavigateUp, 0.dp
+ ) {
+ if (updateMode) {
+ OutlinedTextField(
+ input, {},
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 8.dp),
+ readOnly = true, label = { Text(stringResource(R.string.package_name)) }
+ )
+ } else {
+ PackageNameTextField(
+ input, onChoosePackage,
+ Modifier.padding(HorizontalPadding, 8.dp)
+ ) { input = it }
+ }
+ delegatedScopesList.forEach { scope ->
+ val checked = scope.id in scopes
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable { if (!checked) scopes += scope.id else scopes -= scope.id }
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked, { if (it) scopes += scope.id else scopes -= scope.id },
+ Modifier.padding(horizontal = 4.dp)
+ )
+ Column {
+ Text(stringResource(scope.string))
+ Text(
+ scope.id, style = typography.bodyMedium,
+ color = colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ Button(
+ {
+ vm.setDelegatedAdmin(input, scopes)
+ onNavigateUp()
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, vertical = 4.dp),
+ input.isNotBlank()
+ ) {
+ Text(stringResource(if (updateMode) R.string.update else R.string.add))
+ }
+ if (updateMode) Button(
+ {
+ vm.setDelegatedAdmin(input, emptyList())
+ onNavigateUp()
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding),
+ colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError)
+ ) {
+ Text(stringResource(R.string.delete))
+ }
+ Spacer(Modifier.height(BottomPadding))
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsViewModel.kt
new file mode 100644
index 0000000..9e1a077
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsViewModel.kt
@@ -0,0 +1,39 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.getAppInfo
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class DelegatedAdminsViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper
+) : ViewModel() {
+ val delegatedAdminsState = MutableStateFlow(emptyList())
+
+ @RequiresApi(26)
+ fun getDelegatedAdmins() = ph.safeDpmCall {
+ val pm = application.packageManager
+ val list = mutableListOf()
+ delegatedScopesList.forEach { scope ->
+ dpm.getDelegatePackages(dar, scope.id)?.forEach { pkg ->
+ val index = list.indexOfFirst { it.app.name == pkg }
+ if (index == -1) {
+ list += DelegatedAdmin(getAppInfo(pm, pkg), listOf(scope.id))
+ } else {
+ list[index] = DelegatedAdmin(list[index].app, list[index].scopes + scope.id)
+ }
+ }
+ }
+ delegatedAdminsState.value = list
+ }
+
+ var selectedDelegatedAdminIndex = -1
+
+ @RequiresApi(26)
+ fun setDelegatedAdmin(name: String, scopes: List) = ph.safeDpmCall {
+ dpm.setDelegatedScopes(dar, name, scopes)
+ getDelegatedAdmins()
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerModel.kt
new file mode 100644
index 0000000..3254cfe
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerModel.kt
@@ -0,0 +1,7 @@
+package com.bintianqi.owndroid.feature.privilege
+
+data class DhizukuClientInfo(
+ val uid: Int,
+ val signature: String?,
+ val permissions: List = emptyList()
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerRepository.kt
new file mode 100644
index 0000000..434f331
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerRepository.kt
@@ -0,0 +1,53 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import com.bintianqi.owndroid.MyDbHelper
+
+class DhizukuServerRepository(val dbHelper: MyDbHelper) {
+
+ fun getDhizukuClients(): List {
+ val list = mutableListOf()
+ dbHelper.readableDatabase.rawQuery("SELECT * FROM dhizuku_clients", null).use { cursor ->
+ while (cursor.moveToNext()) {
+ list += DhizukuClientInfo(
+ cursor.getInt(0), cursor.getString(1),
+ cursor.getString(2).split(",").filter { it.isNotEmpty() }
+ )
+ }
+ }
+ return list
+ }
+
+ fun checkDhizukuClientPermission(uid: Int, signature: String?, permission: String): Boolean {
+ val cursor = if (signature == null) {
+ dbHelper.readableDatabase.rawQuery(
+ "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature IS NULL",
+ null
+ )
+ } else {
+ dbHelper.readableDatabase.rawQuery(
+ "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature = ?",
+ arrayOf(signature)
+ )
+ }
+ return cursor.use {
+ it.moveToNext() && permission in it.getString(0).split(",")
+ }
+ }
+
+ fun setDhizukuClient(info: DhizukuClientInfo) {
+ val cv = ContentValues()
+ cv.put("uid", info.uid)
+ cv.put("signature", info.signature)
+ cv.put("permissions", info.permissions.joinToString(","))
+ dbHelper.writableDatabase.insertWithOnConflict(
+ "dhizuku_clients", null, cv,
+ SQLiteDatabase.CONFLICT_REPLACE
+ )
+ }
+
+ fun deleteDhizukuClient(info: DhizukuClientInfo) {
+ dbHelper.writableDatabase.delete("dhizuku_clients", "uid = ${info.uid}", null)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerScreen.kt
new file mode 100644
index 0000000..3317ef0
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerScreen.kt
@@ -0,0 +1,140 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.Card
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Text
+import androidx.compose.material3.TriStateCheckbox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.state.ToggleableState
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.DhizukuPermissions
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.MyLazyScaffold
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+
+
+@Composable
+fun DhizukuServerSettingsScreen(
+ vm: DhizukuServerViewModel, onNavigateUp: () -> Unit
+) {
+ val enabled by vm.serverEnabledState.collectAsState()
+ val clients by vm.clientsState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getEnabled()
+ vm.getClients()
+ }
+ MyLazyScaffold(R.string.dhizuku_server, onNavigateUp) {
+ item {
+ SwitchItem(R.string.enable, enabled, vm::setEnabled)
+ HorizontalDivider(Modifier.padding(vertical = 8.dp))
+ }
+ if (enabled) items(clients) { (client, app) ->
+ Card(
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 8.dp)
+ ) {
+ DhizukuClientCardContent(client, app, vm::updateClient)
+ }
+ }
+ }
+}
+
+@Composable
+private fun DhizukuClientCardContent(
+ client: DhizukuClientInfo, app: AppInfo, update: (DhizukuClientInfo) -> Unit
+) {
+ var expand by remember { mutableStateOf(false) }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(8.dp, 8.dp, 0.dp, 8.dp),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ rememberDrawablePainter(app.icon), null,
+ Modifier
+ .padding(end = 16.dp)
+ .size(45.dp)
+ )
+ Column {
+ Text(app.label, style = typography.titleMedium)
+ Text(app.name, Modifier.alpha(0.7F), style = typography.bodyMedium)
+ }
+ }
+ val ts = when (DhizukuPermissions.filter { it !in client.permissions }.size) {
+ 0 -> ToggleableState.On
+ DhizukuPermissions.size -> ToggleableState.Off
+ else -> ToggleableState.Indeterminate
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ TriStateCheckbox(ts, {
+ if (ts == ToggleableState.Off) {
+ update(client.copy(permissions = DhizukuPermissions))
+ } else {
+ update(client.copy(permissions = emptyList()))
+ }
+ })
+ val degrees by animateFloatAsState(if (expand) 180F else 0F)
+ IconButton({ expand = !expand }) {
+ Icon(Icons.Default.ArrowDropDown, null, Modifier.rotate(degrees))
+ }
+ }
+ }
+ AnimatedVisibility(expand, Modifier.padding(8.dp, 0.dp, 8.dp, 8.dp)) {
+ Column {
+ mapOf(
+ "remote_transact" to "Remote transact",
+ "remote_process" to "Remote process",
+ "user_service" to "User service",
+ "delegated_scopes" to "Delegated scopes",
+ "other" to "Other"
+ ).forEach { (k, v) ->
+ Row(
+ Modifier.fillMaxWidth(), Arrangement.SpaceBetween,
+ Alignment.CenterVertically
+ ) {
+ Text(v)
+ Checkbox(k in client.permissions, {
+ update(
+ client.copy(
+ permissions = client.permissions.run {
+ if (it) plus(k) else minus(k)
+ }
+ ))
+ })
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerViewModel.kt
new file mode 100644
index 0000000..d05a6c6
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerViewModel.kt
@@ -0,0 +1,54 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.getAppInfo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class DhizukuServerViewModel(
+ val application: MyApplication, val repo: DhizukuServerRepository,
+ val settingsRepo: SettingsRepository
+) : ViewModel() {
+ val serverEnabledState = MutableStateFlow(false)
+
+ fun getEnabled() {
+ serverEnabledState.value = settingsRepo.data.privilege.dhizukuServer
+ }
+
+ fun setEnabled(status: Boolean) {
+ settingsRepo.update { it.privilege.dhizukuServer = status }
+ serverEnabledState.value = status
+ }
+
+ val clientsState = MutableStateFlow(emptyList>())
+ fun getClients() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val pm = application.packageManager
+ clientsState.value = repo.getDhizukuClients().mapNotNull {
+ val packageName = pm.getNameForUid(it.uid)
+ if (packageName == null) {
+ repo.deleteDhizukuClient(it)
+ null
+ } else {
+ it to getAppInfo(pm, packageName)
+ }
+ }
+ }
+ }
+
+ fun updateClient(info: DhizukuClientInfo) {
+ repo.setDhizukuClient(info)
+ clientsState.update { list ->
+ val ml = list.toMutableList()
+ val index = ml.indexOfFirst { it.first.uid == info.uid }
+ ml[index] = info to ml[index].second
+ ml
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipModel.kt
new file mode 100644
index 0000000..0ac5bee
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipModel.kt
@@ -0,0 +1,6 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import android.content.ComponentName
+import com.bintianqi.owndroid.utils.AppInfo
+
+class DeviceAdmin(val app: AppInfo, val dar: ComponentName)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipScreen.kt
new file mode 100644
index 0000000..681b4a0
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipScreen.kt
@@ -0,0 +1,110 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.MyLazyScaffold
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+
+@RequiresApi(28)
+@Composable
+fun TransferOwnershipScreen(
+ vm: TransferOwnershipViewModel, onNavigateUp: () -> Unit, onTransferred: () -> Unit
+) {
+ val privilege by vm.ps.collectAsState()
+ var selectedIndex by rememberSaveable { mutableIntStateOf(-1) }
+ var dialog by rememberSaveable { mutableStateOf(false) }
+ val receivers by vm.deviceAdminReceivers.collectAsState()
+ LaunchedEffect(Unit) { vm.getDeviceAdminReceivers() }
+ MyLazyScaffold(R.string.transfer_ownership, onNavigateUp) {
+ itemsIndexed(receivers) { index, admin ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable { selectedIndex = index }
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(selectedIndex == index, { selectedIndex = index })
+ Image(rememberDrawablePainter(admin.app.icon), null, Modifier.size(40.dp))
+ Column(Modifier.padding(start = 8.dp)) {
+ Text(admin.app.label)
+ Text(admin.app.name, Modifier.alpha(0.7F), style = typography.bodyMedium)
+ }
+ }
+ }
+ item {
+ Button(
+ { dialog = true },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 10.dp),
+ receivers.getOrNull(selectedIndex) != null
+ ) {
+ Text(stringResource(R.string.transfer))
+ }
+ Notes(R.string.info_transfer_ownership, HorizontalPadding)
+ }
+ }
+ if (dialog) AlertDialog(
+ text = {
+ Text(
+ stringResource(
+ R.string.transfer_ownership_warning,
+ stringResource(
+ if (privilege.device) R.string.device_owner else R.string.profile_owner
+ ),
+ receivers[selectedIndex].app.name
+ )
+ )
+ },
+ confirmButton = {
+ TextButton(
+ {
+ vm.transferOwnership(receivers[selectedIndex].dar)
+ dialog = false
+ onTransferred()
+ },
+ colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error)
+ ) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ dismissButton = {
+ TextButton({ dialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = { dialog = false }
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipViewModel.kt
new file mode 100644
index 0000000..e754b02
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipViewModel.kt
@@ -0,0 +1,53 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import android.app.admin.DeviceAdminInfo
+import android.app.admin.DeviceAdminReceiver
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.getAppInfo
+import com.bintianqi.owndroid.utils.getPrivilegeStatus
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+class TransferOwnershipViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper,
+ val ps: MutableStateFlow
+) : ViewModel() {
+ val deviceAdminReceivers = MutableStateFlow(emptyList())
+
+ fun getDeviceAdminReceivers() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val pm = application.packageManager
+ deviceAdminReceivers.value = pm.queryBroadcastReceivers(
+ Intent(DeviceAdminReceiver.ACTION_DEVICE_ADMIN_ENABLED),
+ PackageManager.GET_META_DATA
+ ).mapNotNull {
+ try {
+ DeviceAdminInfo(application, it)
+ } catch (_: Exception) {
+ null
+ }
+ }.filter {
+ it.isVisible && it.packageName != "com.bintianqi.owndroid" &&
+ it.activityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 0
+ }.map {
+ DeviceAdmin(getAppInfo(pm, it.packageName), it.component)
+ }
+ }
+ }
+
+ @RequiresApi(28)
+ fun transferOwnership(component: ComponentName) = ph.safeDpmCall {
+ dpm.transferOwnership(dar, component, null)
+ ps.value = getPrivilegeStatus(ph)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesScreen.kt
new file mode 100644
index 0000000..7dfad67
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesScreen.kt
@@ -0,0 +1,377 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import android.os.Build.VERSION
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+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.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Warning
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.CircularProgressDialog
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.ACTIVATE_DEVICE_OWNER_COMMAND
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.activateOrgProfileCommand
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun WorkModesScreen(
+ vm: WorkingModesViewModel, params: Destination.WorkingModes, onNavigateUp: () -> Unit,
+ onActivate: () -> Unit, onDeactivate: () -> Unit, onNavigate: (Destination) -> Unit
+) {
+ val privilege by vm.ps.collectAsStateWithLifecycle()
+ // 0: none, 1: device owner, 2: circular progress indicator, 3: result, 4: deactivate
+ // 5: command, 6: org profile, 7: org profile command
+ var dialog by rememberSaveable { mutableIntStateOf(0) }
+ var operationSucceed by rememberSaveable { mutableStateOf(false) }
+ var resultText by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(privilege) {
+ if (!params.canNavigateUp && privilege.device) {
+ delay(1000)
+ if (dialog != 3) { // Activated by ADB command
+ operationSucceed = true
+ resultText = ""
+ dialog = 3
+ }
+ }
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ {
+ if (!params.canNavigateUp) {
+ Column {
+ Text(stringResource(R.string.app_name))
+ Text(
+ stringResource(R.string.choose_work_mode), Modifier.alpha(0.8F),
+ style = typography.bodyLarge
+ )
+ }
+ }
+ },
+ navigationIcon = {
+ if (params.canNavigateUp) NavIcon(onNavigateUp)
+ },
+ actions = {
+ var expanded by remember { mutableStateOf(false) }
+ if (privilege.device || privilege.profile) Box {
+ IconButton({ expanded = true }) {
+ Icon(Icons.Default.MoreVert, null)
+ }
+ DropdownMenu(expanded, { expanded = false }) {
+ DropdownMenuItem(
+ { Text(stringResource(R.string.deactivate)) },
+ {
+ expanded = false
+ dialog = 4
+ },
+ leadingIcon = { Icon(Icons.Default.Close, null) }
+ )
+ if (VERSION.SDK_INT >= 26) DropdownMenuItem(
+ { Text(stringResource(R.string.delegated_admins)) },
+ {
+ expanded = false
+ onNavigate(Destination.DelegatedAdmins)
+ },
+ leadingIcon = {
+ Icon(
+ painterResource(R.drawable.admin_panel_settings_fill0), null
+ )
+ }
+ )
+ if (!privilege.dhizuku && VERSION.SDK_INT >= 28) DropdownMenuItem(
+ { Text(stringResource(R.string.transfer_ownership)) },
+ {
+ expanded = false
+ onNavigate(Destination.TransferOwnership)
+ },
+ leadingIcon = {
+ Icon(
+ painterResource(R.drawable.swap_horiz_fill0), null
+ )
+ }
+ )
+ }
+ }
+ if (!params.canNavigateUp) IconButton({ onNavigate(Destination.Settings) }) {
+ Icon(Icons.Default.Settings, null)
+ }
+ }
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ fun handleResult(succeeded: Boolean, output: String?) {
+ operationSucceed = succeeded
+ resultText = output ?: ""
+ dialog = 3
+ }
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ if (!privilege.profile) {
+ WorkingModeItem(R.string.device_owner, privilege.device) {
+ if (!privilege.device || (VERSION.SDK_INT >= 28 && privilege.dhizuku)) {
+ dialog = 1
+ }
+ }
+ }
+ if (privilege.profile) WorkingModeItem(R.string.profile_owner, true) { }
+ if (privilege.dhizuku || !privilege.activated) {
+ WorkingModeItem(R.string.dhizuku, privilege.dhizuku) {
+ if (!privilege.dhizuku) {
+ dialog = 2
+ vm.activateDhizukuMode(::handleResult)
+ }
+ }
+ }
+ if (
+ privilege.work || (VERSION.SDK_INT < 24 || vm.isCreatingWorkProfileAllowed())
+ ) {
+ WorkingModeItem(R.string.work_profile, privilege.work) {
+ if (!privilege.work) onNavigate(Destination.CreateWorkProfile)
+ }
+ }
+ if (privilege.work) {
+ WorkingModeItem(R.string.org_owned_work_profile, privilege.org) {
+ if (!privilege.org) dialog = 6
+ }
+ }
+ if (privilege.activated && !privilege.dhizuku) Row(
+ Modifier
+ .padding(top = 20.dp)
+ .fillMaxWidth()
+ .clickable { onNavigate(Destination.DhizukuServerSettings) }
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painterResource(R.drawable.dhizuku_icon), null,
+ Modifier
+ .padding(8.dp)
+ .size(28.dp)
+ )
+ Text(stringResource(R.string.dhizuku_server), style = typography.titleLarge)
+ }
+
+ Column(Modifier.padding(HorizontalPadding, 20.dp)) {
+ Row(
+ Modifier.padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Warning, null, Modifier.padding(end = 4.dp),
+ colorScheme.error
+ )
+ Text(
+ stringResource(R.string.warning), color = colorScheme.error,
+ style = typography.labelLarge
+ )
+ }
+ Text(stringResource(R.string.owndroid_warning))
+ }
+ }
+ if (dialog == 1) AlertDialog(
+ title = { Text(stringResource(R.string.activate_method)) },
+ text = {
+ FlowRow(Modifier.fillMaxWidth()) {
+ if (!privilege.dhizuku) {
+ Button({ dialog = 5 }, Modifier.padding(end = 8.dp)) {
+ Text(stringResource(R.string.adb_command))
+ }
+ Button({
+ dialog = 2
+ vm.activateDoByShizuku(::handleResult)
+ }, Modifier.padding(end = 8.dp)) {
+ Text(stringResource(R.string.shizuku))
+ }
+ Button({
+ dialog = 2
+ vm.activateDoByRoot(::handleResult)
+ }, Modifier.padding(end = 8.dp)) {
+ Text("Root")
+ }
+ }
+ if (VERSION.SDK_INT >= 28 && privilege.dhizuku) Button({
+ dialog = 2
+ vm.activateDoByDhizuku(::handleResult)
+ }, Modifier.padding(end = 8.dp)) {
+ Text(stringResource(R.string.dhizuku))
+ }
+ }
+ },
+ confirmButton = {
+ TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+ if (dialog == 2) CircularProgressDialog { }
+ if (dialog == 3) AlertDialog(
+ title = {
+ Text(
+ stringResource(if (operationSucceed) R.string.succeeded else R.string.failed)
+ )
+ },
+ text = {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(resultText)
+ }
+ },
+ confirmButton = {
+ TextButton({
+ dialog = 0
+ if (operationSucceed && !params.canNavigateUp) onActivate()
+ }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = {}
+ )
+ if (dialog == 4) AlertDialog(
+ title = { Text(stringResource(R.string.deactivate)) },
+ text = { Text(stringResource(R.string.info_deactivate)) },
+ confirmButton = {
+ var time by remember { mutableIntStateOf(if (privilege.dhizuku) 0 else 3) }
+ if (!privilege.dhizuku) LaunchedEffect(Unit) {
+ for (i in (0..2).reversed()) {
+ delay(1000)
+ time = i
+ }
+ }
+ val timeText = if (time != 0) " (${time}s)" else ""
+ TextButton(
+ {
+ vm.deactivate()
+ dialog = 0
+ onDeactivate()
+ },
+ enabled = time == 0,
+ colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error)
+ ) {
+ Text(stringResource(R.string.confirm) + timeText)
+ }
+ },
+ dismissButton = {
+ TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+ if (dialog == 5) AlertDialog(
+ text = {
+ SelectionContainer {
+ Text(ACTIVATE_DEVICE_OWNER_COMMAND)
+ }
+ },
+ confirmButton = {
+ TextButton({ dialog = 0 }) { Text(stringResource(R.string.confirm)) }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+ if (dialog == 6) AlertDialog(
+ text = {
+ Column {
+ Button({
+ dialog = 2
+ vm.activateOrgProfileByShizuku { dialog = 0 }
+ }) {
+ Text(stringResource(R.string.shizuku))
+ }
+ Button({ dialog = 7 }) {
+ Text(stringResource(R.string.adb_command))
+ }
+ }
+ },
+ confirmButton = {
+ TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+ if (dialog == 7) AlertDialog(
+ text = {
+ SelectionContainer {
+ Text(activateOrgProfileCommand)
+ }
+ },
+ confirmButton = {
+ TextButton({ dialog = 0 }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+ }
+}
+
+@Composable
+private fun WorkingModeItem(text: Int, active: Boolean, onClick: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .background(if (active) colorScheme.primaryContainer else Color.Transparent)
+ .padding(HorizontalPadding, 10.dp),
+ Arrangement.SpaceBetween, Alignment.CenterVertically
+ ) {
+ Text(stringResource(text), style = typography.titleLarge)
+ Icon(
+ if (active) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight,
+ null,
+ tint = if (active) colorScheme.primary else colorScheme.onBackground
+ )
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesViewModel.kt
new file mode 100644
index 0000000..051af33
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesViewModel.kt
@@ -0,0 +1,155 @@
+package com.bintianqi.owndroid.feature.privilege
+
+import android.app.admin.DevicePolicyManager
+import android.content.pm.PackageManager
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.IUserService
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
+import com.bintianqi.owndroid.useShizuku
+import com.bintianqi.owndroid.utils.ACTIVATE_DEVICE_OWNER_COMMAND
+import com.bintianqi.owndroid.utils.MyAdminComponent
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import com.bintianqi.owndroid.utils.activateOrgProfileCommand
+import com.bintianqi.owndroid.utils.getPrivilegeStatus
+import com.bintianqi.owndroid.utils.handlePrivilegeChange
+import com.rosan.dhizuku.api.Dhizuku
+import com.rosan.dhizuku.api.DhizukuRequestPermissionListener
+import com.topjohnwu.superuser.Shell
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+class WorkingModesViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val sr: SettingsRepository,
+ val ps: MutableStateFlow, val toastChannel: ToastChannel
+) : ViewModel() {
+
+ @RequiresApi(24)
+ fun isCreatingWorkProfileAllowed(): Boolean {
+ return ph.myDpm.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE)
+ }
+
+ fun activateDoByShizuku(callback: (Boolean, String?) -> Unit) {
+ viewModelScope.launch(Dispatchers.IO) {
+ useShizuku(application) { service ->
+ if (service == null) {
+ callback(false, null)
+ return@useShizuku
+ }
+ try {
+ val result = IUserService.Stub.asInterface(service)
+ .execute(ACTIVATE_DEVICE_OWNER_COMMAND)
+ if (result == null) {
+ callback(false, null)
+ } else if (result.getInt("code", -1) != 0) {
+ callback(
+ false, result.getString("output") + "\n" + result.getString("error")
+ )
+ } else {
+ updateStatus()
+ callback(
+ true, result.getString("output") + "\n" + result.getString("error")
+ )
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ callback(false, null)
+ }
+ }
+ }
+ }
+
+ fun activateDoByRoot(callback: (Boolean, String?) -> Unit) {
+ Shell.getShell { shell ->
+ if (shell.isRoot) {
+ val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec()
+ val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n")
+ if (result.isSuccess) updateStatus()
+ callback(result.isSuccess, output)
+ } else {
+ callback(false, application.getString(R.string.permission_denied))
+ }
+ }
+ }
+
+ @RequiresApi(28)
+ fun activateDoByDhizuku(callback: (Boolean, String?) -> Unit) = ph.safeDpmCall {
+ dpm.transferOwnership(dar, MyAdminComponent, null)
+ sr.update { it.privilege.dhizuku = false }
+ ph.dhizuku = false
+ updateStatus()
+ callback(true, null)
+ }
+
+ fun activateDhizukuMode(callback: (Boolean, String?) -> Unit) {
+ fun onSucceed() {
+ sr.update { it.privilege.dhizuku = true }
+ ph.dhizuku = true
+ updateStatus()
+ callback(true, null)
+ }
+ if (Dhizuku.init(application)) {
+ if (Dhizuku.isPermissionGranted()) {
+ onSucceed()
+ } else {
+ Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() {
+ override fun onRequestPermission(grantResult: Int) {
+ if (grantResult == PackageManager.PERMISSION_GRANTED) onSucceed()
+ else callback(
+ false, application.getString(R.string.dhizuku_permission_not_granted)
+ )
+ }
+ })
+ }
+ } else {
+ callback(false, application.getString(R.string.failed_to_init_dhizuku))
+ }
+ }
+
+ fun activateOrgProfileByShizuku(callback: (Boolean) -> Unit) {
+ viewModelScope.launch(Dispatchers.IO) {
+ useShizuku(application) { service ->
+ if (service == null) {
+ callback(false)
+ toastChannel.sendStatus(false)
+ return@useShizuku
+ }
+ val result =
+ IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand)
+ val succeed = result?.getInt("code", -1) == 0
+ callback(succeed)
+ if (succeed) {
+ updateStatus()
+ } else {
+ toastChannel.sendStatus(false)
+ }
+ }
+ }
+ }
+
+ fun deactivate() {
+ if (ps.value.dhizuku) {
+ sr.update { it.privilege.dhizuku = false }
+ ph.dhizuku = false
+ } else {
+ if (ps.value.device) {
+ ph.myDpm.clearDeviceOwnerApp(application.packageName)
+ } else if (VERSION.SDK_INT >= 24) {
+ ph.myDpm.clearProfileOwner(MyAdminComponent)
+ }
+ }
+ updateStatus()
+ }
+
+ private fun updateStatus() {
+ ps.value = getPrivilegeStatus(ph)
+ handlePrivilegeChange(application, ps.value, ph, sr)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsModel.kt
new file mode 100644
index 0000000..6880c18
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsModel.kt
@@ -0,0 +1,51 @@
+package com.bintianqi.owndroid.feature.settings
+
+import androidx.annotation.Keep
+import com.bintianqi.owndroid.R
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MySettings(
+ val privilege: Privilege = Privilege(),
+ var theme: Theme = Theme(),
+ val appLock: AppLock = AppLock(),
+ val shortcut: Shortcut = Shortcut(),
+ val notifications: MutableList = mutableListOf(),
+ var displayDangerousFeatures: Boolean = false,
+ var applicationsListView: Boolean = true,
+ var apiKeyHash: String = "",
+) {
+ @Serializable
+ data class Privilege(
+ var dhizuku: Boolean = false,
+ var dhizukuServer: Boolean = false,
+ var managedProfileActivated: Boolean = false,
+ var defaultAffiliationIdSet: Boolean = false,
+ )
+
+ // Use `val` since it is used as UI state
+ @Serializable
+ data class Theme(
+ val materialYou: Boolean = true,
+ val dark: DarkMode = DarkMode.FollowSystem,
+ val black: Boolean = false,
+ )
+
+ @Keep
+ enum class DarkMode(val text: Int) {
+ FollowSystem(R.string.follow_system), On(R.string.on), Off(R.string.off)
+ }
+
+ @Serializable
+ data class AppLock(
+ var passwordHash: String = "",
+ var biometrics: Boolean = false,
+ var lockWhenLeaving: Boolean = false,
+ )
+
+ @Serializable
+ data class Shortcut(
+ var enabled: Boolean = true,
+ var key: String = "",
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsRepository.kt
new file mode 100644
index 0000000..42eb0d1
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsRepository.kt
@@ -0,0 +1,30 @@
+package com.bintianqi.owndroid.feature.settings
+
+import kotlinx.serialization.json.Json
+import java.io.File
+
+class SettingsRepository(val file: File) {
+ var data: MySettings
+
+ init {
+ if (file.exists()) {
+ data = readData()
+ } else {
+ data = MySettings()
+ write()
+ }
+ }
+
+ fun readData(): MySettings {
+ return Json.Default.decodeFromString(file.readText())
+ }
+
+ fun update(block: (MySettings) -> Unit) {
+ block(data)
+ write()
+ }
+
+ fun write() {
+ file.writeText(Json.encodeToString(data))
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/screen/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsScreen.kt
similarity index 62%
rename from app/src/main/java/com/bintianqi/owndroid/ui/screen/Settings.kt
rename to app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsScreen.kt
index 8ee2602..5c4619f 100644
--- a/app/src/main/java/com/bintianqi/owndroid/ui/screen/Settings.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsScreen.kt
@@ -1,4 +1,4 @@
-package com.bintianqi.owndroid.ui.screen
+package com.bintianqi.owndroid.feature.settings
import android.content.Context
import android.content.Intent
@@ -36,7 +36,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -55,23 +55,18 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.bintianqi.owndroid.BottomPadding
-import com.bintianqi.owndroid.MyNotificationChannel
-import com.bintianqi.owndroid.NotificationType
-import com.bintianqi.owndroid.Privilege
import com.bintianqi.owndroid.R
-import com.bintianqi.owndroid.ThemeSettings
-import com.bintianqi.owndroid.adaptiveInsets
-import com.bintianqi.owndroid.exportLogs
-import com.bintianqi.owndroid.generateBase64Key
-import com.bintianqi.owndroid.showOperationResultToast
import com.bintianqi.owndroid.ui.FunctionItem
import com.bintianqi.owndroid.ui.MyScaffold
import com.bintianqi.owndroid.ui.NavIcon
import com.bintianqi.owndroid.ui.Notes
import com.bintianqi.owndroid.ui.SwitchItem
import com.bintianqi.owndroid.ui.navigation.Destination
-import kotlinx.coroutines.flow.StateFlow
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.MyNotificationChannel
+import com.bintianqi.owndroid.utils.NotificationType
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.generateBase64Key
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -79,12 +74,14 @@ import kotlin.system.exitProcess
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SettingsScreen(onNavigate: (Destination) -> Unit, onNavigateUp: () -> Unit) {
- val context = LocalContext.current
- val privilege by Privilege.status.collectAsStateWithLifecycle()
- val exportLogsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) {
- if(it != null) exportLogs(context, it)
- }
+fun SettingsScreen(
+ vm: SettingsViewModel, onNavigate: (Destination) -> Unit, onNavigateUp: () -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ val exportLogsLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) {
+ if (it != null) vm.exportLogs(it)
+ }
val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var dropdown by remember { mutableStateOf(false) }
Scaffold(
@@ -104,8 +101,9 @@ fun SettingsScreen(onNavigate: (Destination) -> Unit, onNavigateUp: () -> Unit)
{ Text(stringResource(R.string.export_logs)) },
{
dropdown = false
- val time = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault())
- .format(Date(System.currentTimeMillis()))
+ val time =
+ SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault())
+ .format(Date(System.currentTimeMillis()))
exportLogsLauncher.launch("owndroid_log_$time")
},
leadingIcon = {
@@ -125,7 +123,7 @@ fun SettingsScreen(onNavigate: (Destination) -> Unit, onNavigateUp: () -> Unit)
contentWindowInsets = adaptiveInsets()
) { paddingValues ->
Column(
- modifier = Modifier
+ Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
@@ -159,145 +157,130 @@ fun SettingsScreen(onNavigate: (Destination) -> Unit, onNavigateUp: () -> Unit)
@Composable
fun SettingsOptionsScreen(
- getDisplayDangerousFeatures: () -> Boolean, getShortcutsEnabled: () -> Boolean,
- setDisplayDangerousFeatures: (Boolean) -> Unit, setShortcutsEnabled: (Boolean) -> Unit,
- onNavigateUp: () -> Unit
+ vm: SettingsViewModel, onNavigateUp: () -> Unit
) {
- var dangerousFeatures by remember { mutableStateOf(getDisplayDangerousFeatures()) }
- var shortcuts by remember { mutableStateOf(getShortcutsEnabled()) }
+ val dangerousFeatures by vm.dangerousFeaturesState.collectAsState()
+ val shortcuts by vm.shortcutsState.collectAsState()
MyScaffold(R.string.options, onNavigateUp, 0.dp) {
SwitchItem(
- R.string.show_dangerous_features, dangerousFeatures, {
- setDisplayDangerousFeatures(it)
- dangerousFeatures = it
- }, R.drawable.warning_fill0
+ R.string.show_dangerous_features, dangerousFeatures, vm::setDisplayDangerousFeatures,
+ R.drawable.warning_fill0
)
SwitchItem(
- R.string.shortcuts, shortcuts, {
- setShortcutsEnabled(it)
- shortcuts = it
- }, R.drawable.open_in_new
+ R.string.shortcuts, shortcuts, vm::setShortcutsEnabled, R.drawable.open_in_new
)
}
}
@Composable
fun AppearanceScreen(
- onNavigateUp: () -> Unit, currentTheme: StateFlow,
- setTheme: (ThemeSettings) -> Unit
+ vm: SettingsViewModel, onNavigateUp: () -> Unit
) {
+ val uiState by vm.themeState.collectAsState()
var darkThemeMenu by remember { mutableStateOf(false) }
- val theme by currentTheme.collectAsStateWithLifecycle()
- val darkThemeTextID = when(theme.darkTheme) {
- 1 -> R.string.on
- 0 -> R.string.off
- else -> R.string.follow_system
- }
MyScaffold(R.string.appearance, onNavigateUp, 0.dp) {
- if(VERSION.SDK_INT >= 31) {
- SwitchItem(
- R.string.material_you_color,
- state = theme.materialYou,
- onCheckedChange = { setTheme(theme.copy(materialYou = it)) }
- )
+ if (VERSION.SDK_INT >= 31) {
+ SwitchItem(R.string.material_you_color, uiState.materialYou, vm::setMaterialYou)
}
Box {
- FunctionItem(R.string.dark_theme, stringResource(darkThemeTextID)) { darkThemeMenu = true }
+ FunctionItem(R.string.dark_theme, stringResource(uiState.dark.text)) {
+ darkThemeMenu = true
+ }
DropdownMenu(
- expanded = darkThemeMenu, onDismissRequest = { darkThemeMenu = false },
+ darkThemeMenu, { darkThemeMenu = false },
offset = DpOffset(x = 25.dp, y = 0.dp)
) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.follow_system)) },
- onClick = {
- setTheme(theme.copy(darkTheme = -1))
- darkThemeMenu = false
- }
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.on)) },
- onClick = {
- setTheme(theme.copy(darkTheme = 1))
- darkThemeMenu = false
- }
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.off)) },
- onClick = {
- setTheme(theme.copy(darkTheme = 0))
- darkThemeMenu = false
- }
- )
+ MySettings.DarkMode.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.text)) },
+ {
+ vm.setDarkMode(it)
+ darkThemeMenu = false
+ }
+ )
+ }
}
}
- AnimatedVisibility(theme.darkTheme == 1 || (theme.darkTheme == -1 && isSystemInDarkTheme())) {
- SwitchItem(
- R.string.black_theme, state = theme.blackTheme,
- onCheckedChange = { setTheme(theme.copy(blackTheme = it)) }
- )
+ AnimatedVisibility(
+ uiState.dark == MySettings.DarkMode.On ||
+ (uiState.dark == MySettings.DarkMode.FollowSystem && isSystemInDarkTheme())
+ ) {
+ SwitchItem(R.string.black_theme, uiState.black, vm::setBlackTheme)
}
}
}
-data class AppLockConfig(
- /** null means no password, empty means password already set */
- val password: String?, val biometrics: Boolean, val whenLeaving: Boolean
-)
-
@Composable
fun AppLockSettingsScreen(
- config: AppLockConfig, setConfig: (AppLockConfig) -> Unit,
- onNavigateUp: () -> Unit
+ vm: SettingsViewModel, onNavigateUp: () -> Unit
) = MyScaffold(R.string.app_lock, onNavigateUp) {
+ val config = vm.getAppLockConfig()
var password by rememberSaveable { mutableStateOf("") }
var confirmPassword by rememberSaveable { mutableStateOf("") }
var allowBiometrics by rememberSaveable { mutableStateOf(config.biometrics) }
- var lockWhenLeaving by rememberSaveable { mutableStateOf(config.whenLeaving) }
- var alreadySet by rememberSaveable { mutableStateOf(config.password != null) }
+ var lockWhenLeaving by rememberSaveable { mutableStateOf(config.lockWhenLeaving) }
+ var alreadySet by rememberSaveable { mutableStateOf(config.passwordHash.isNotEmpty()) }
val isInputLegal = password.length !in 1..3 && (alreadySet || password.isNotBlank())
OutlinedTextField(
- password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ password, { password = it }, Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
label = { Text(stringResource(R.string.password)) },
- supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) },
+ supportingText = {
+ Text(
+ stringResource(
+ if (alreadySet) R.string.leave_empty_to_remain_unchanged
+ else R.string.minimum_length_4
+ )
+ )
+ },
visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next)
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password, imeAction = ImeAction.Next
+ )
)
OutlinedTextField(
confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.confirm_password)) },
visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done)
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
+ )
)
if (VERSION.SDK_INT >= 28) Row(
- Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp),
Arrangement.SpaceBetween, Alignment.CenterVertically
) {
Text(stringResource(R.string.allow_biometrics))
Switch(allowBiometrics, { allowBiometrics = it })
}
Row(
- Modifier.fillMaxWidth().padding(bottom = 6.dp),
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 6.dp),
Arrangement.SpaceBetween, Alignment.CenterVertically
) {
Text(stringResource(R.string.lock_when_leaving))
Switch(lockWhenLeaving, { lockWhenLeaving = it })
}
Button(
- onClick = {
- setConfig(AppLockConfig(password, allowBiometrics, lockWhenLeaving))
+ {
+ vm.setAppLockConfig(password, allowBiometrics, lockWhenLeaving)
onNavigateUp()
},
- modifier = Modifier.fillMaxWidth(),
- enabled = isInputLegal && confirmPassword == password
+ Modifier.fillMaxWidth(),
+ isInputLegal && confirmPassword == password
) {
- Text(stringResource(if(alreadySet) R.string.update else R.string.set))
+ Text(stringResource(if (alreadySet) R.string.update else R.string.set))
}
if (alreadySet) FilledTonalButton(
- onClick = {
- setConfig(AppLockConfig(null, false, false))
+ {
+ vm.disableAppLock()
onNavigateUp()
},
- modifier = Modifier.fillMaxWidth()
+ Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.disable))
}
@@ -305,19 +288,21 @@ fun AppLockSettingsScreen(
@Composable
fun ApiSettings(
- getEnabled: () -> Boolean, setKey: (String) -> Unit, onNavigateUp: () -> Unit
+ vm: SettingsViewModel, onNavigateUp: () -> Unit
) {
- val context = LocalContext.current
- var alreadyEnabled by remember { mutableStateOf(getEnabled()) }
+ var alreadyEnabled by rememberSaveable { mutableStateOf(vm.getApiEnabled()) }
MyScaffold(R.string.api, onNavigateUp) {
- var enabled by remember { mutableStateOf(alreadyEnabled) }
+ var enabled by rememberSaveable { mutableStateOf(alreadyEnabled) }
var key by rememberSaveable { mutableStateOf("") }
SwitchItem(R.string.enable, state = enabled, onCheckedChange = {
enabled = it
}, padding = false)
if (enabled) {
OutlinedTextField(
- key, { key = it }, Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ key, { key = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp),
label = { Text(stringResource(R.string.api_key)) },
trailingIcon = {
IconButton({ key = generateBase64Key(10) }) {
@@ -327,12 +312,13 @@ fun ApiSettings(
)
}
Button(
- onClick = {
- setKey(if (enabled) key else "")
+ {
+ vm.setApiKey(if (enabled) key else "")
alreadyEnabled = enabled
- context.showOperationResultToast(true)
},
- modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
enabled = !enabled || key.length !in 0..7
) {
Text(stringResource(R.string.apply))
@@ -343,30 +329,33 @@ fun ApiSettings(
@Composable
fun NotificationsScreen(
- enabledNotifications: StateFlow>, getState: () -> Unit,
- setNotification: (NotificationType, Boolean) -> Unit, onNavigateUp: () -> Unit
+ vm: SettingsViewModel, onNavigateUp: () -> Unit
) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) {
- val notifications by enabledNotifications.collectAsStateWithLifecycle()
- LaunchedEffect(Unit) {
- getState()
- }
+ val notifications by vm.enabledNotifications.collectAsState()
NotificationType.entries.filter {
it.channel == MyNotificationChannel.Events
}.forEach { type ->
- SwitchItem(type.text, type.id in notifications, { setNotification(type, it) })
+ SwitchItem(type.text, type.id in notifications, { vm.setNotificationEnabled(type, it) })
}
}
@Composable
fun AboutScreen(onNavigateUp: () -> Unit) {
val context = LocalContext.current
- val pkgInfo = context.packageManager.getPackageInfo(context.packageName,0)
+ val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val verCode = pkgInfo.versionCode
val verName = pkgInfo.versionName
MyScaffold(R.string.about, onNavigateUp, 0.dp) {
- Text(text = stringResource(R.string.app_name)+" v$verName ($verCode)", modifier = Modifier.padding(start = 16.dp))
+ Text(
+ stringResource(R.string.app_name) + " v$verName ($verCode)",
+ Modifier.padding(start = 16.dp)
+ )
Spacer(Modifier.padding(vertical = 5.dp))
- FunctionItem(R.string.project_homepage, "GitHub", R.drawable.open_in_new) { shareLink(context, "https://github.com/BinTianqi/OwnDroid") }
+ FunctionItem(R.string.project_homepage, "GitHub", R.drawable.open_in_new) {
+ shareLink(
+ context, "https://github.com/BinTianqi/OwnDroid"
+ )
+ }
}
}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..5ff6d3a
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsViewModel.kt
@@ -0,0 +1,97 @@
+package com.bintianqi.owndroid.feature.settings
+
+import android.net.Uri
+import android.os.Build
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.NotificationType
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ShortcutUtils
+import com.bintianqi.owndroid.utils.ToastChannel
+import com.bintianqi.owndroid.utils.hash
+import com.bintianqi.owndroid.utils.plusOrMinus
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import java.util.concurrent.TimeUnit
+
+class SettingsViewModel(
+ val application: MyApplication, val settingsRepo: SettingsRepository,
+ val ph: PrivilegeHelper, val privilegeState: StateFlow,
+ val toastChannel: ToastChannel, val themeState: MutableStateFlow
+) : ViewModel() {
+ fun exportLogs(uri: Uri) {
+ application.contentResolver.openOutputStream(uri)?.use { output ->
+ val proc = Runtime.getRuntime().exec("logcat -d")
+ proc.inputStream.copyTo(output)
+ if (Build.VERSION.SDK_INT >= 26) proc.waitFor(2L, TimeUnit.SECONDS)
+ else proc.waitFor()
+ toastChannel.sendStatus(proc.exitValue() == 0)
+ }
+ }
+
+ fun setMaterialYou(enabled: Boolean) {
+ themeState.update { it.copy(materialYou = enabled) }
+ settingsRepo.update { it.theme = it.theme.copy(materialYou = enabled) }
+ }
+
+ fun setDarkMode(mode: MySettings.DarkMode) {
+ themeState.update { it.copy(dark = mode) }
+ settingsRepo.update { it.theme = it.theme.copy(dark = mode) }
+ }
+
+ fun setBlackTheme(enabled: Boolean) {
+ themeState.update { it.copy(black = enabled) }
+ settingsRepo.update { it.theme = it.theme.copy(black = enabled) }
+ }
+
+ val dangerousFeaturesState = MutableStateFlow(settingsRepo.data.displayDangerousFeatures)
+ val shortcutsState = MutableStateFlow(settingsRepo.data.shortcut.enabled)
+
+ fun setDisplayDangerousFeatures(state: Boolean) {
+ settingsRepo.update { it.displayDangerousFeatures = state }
+ dangerousFeaturesState.value = state
+ }
+
+ fun setShortcutsEnabled(enabled: Boolean) {
+ settingsRepo.update { it.shortcut.enabled = enabled }
+ ShortcutUtils.setAllShortcuts(application, settingsRepo, ph, enabled)
+ shortcutsState.value = enabled
+ }
+
+ fun getAppLockConfig() = settingsRepo.data.appLock
+
+ fun setAppLockConfig(password: String, biometrics: Boolean, lockWhenLeaving: Boolean) {
+ settingsRepo.update {
+ if (password.isNotEmpty()) it.appLock.passwordHash = password.hash()
+ it.appLock.biometrics = biometrics
+ it.appLock.lockWhenLeaving = lockWhenLeaving
+ }
+ }
+
+ fun disableAppLock() {
+ settingsRepo.update {
+ it.appLock.passwordHash = ""
+ }
+ }
+
+ fun getApiEnabled() = settingsRepo.data.apiKeyHash.isNotEmpty()
+ fun setApiKey(key: String) {
+ settingsRepo.update {
+ it.apiKeyHash = if (key.isEmpty()) "" else key.hash()
+ }
+ toastChannel.sendStatus(true)
+ }
+
+ val enabledNotifications = MutableStateFlow(emptyList())
+ fun setNotificationEnabled(type: NotificationType, enabled: Boolean) {
+ settingsRepo.update {
+ it.notifications.clear()
+ it.notifications.addAll(enabledNotifications.value)
+ }
+ enabledNotifications.update {
+ it.plusOrMinus(enabled, type.id)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertModel.kt
new file mode 100644
index 0000000..d937aca
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertModel.kt
@@ -0,0 +1,11 @@
+package com.bintianqi.owndroid.feature.system
+
+class CaCertInfo(
+ val hash: String,
+ val serialNumber: String,
+ val issuer: String,
+ val subject: String,
+ val issuedTime: Long,
+ val expiresTime: Long,
+ val bytes: ByteArray
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertScreen.kt
new file mode 100644
index 0000000..db5d723
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertScreen.kt
@@ -0,0 +1,220 @@
+package com.bintianqi.owndroid.feature.system
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.formatDate
+import com.bintianqi.owndroid.utils.popToast
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class)
+@Composable
+fun CaCertScreen(
+ vm: CaCertViewModel, onNavigateUp: () -> Unit
+) {
+ val context = LocalContext.current
+ // 0:none, 1:install, 2:info, 3:uninstall all
+ var dialog by rememberSaveable { mutableIntStateOf(0) }
+ val caCerts by vm.installedCertsState.collectAsState()
+ val selectedCert by vm.selectedCert.collectAsState()
+ val getCertLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocument()
+ ) { uri ->
+ if (uri != null) {
+ vm.parseCert(uri)
+ dialog = 1
+ }
+ }
+ val exportCertLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument()
+ ) { uri ->
+ if (uri != null) vm.exportCert(uri)
+ }
+ LaunchedEffect(Unit) {
+ vm.getCaCerts()
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.ca_cert)) },
+ navigationIcon = { NavIcon(onNavigateUp) },
+ actions = {
+ IconButton({ dialog = 3 }) {
+ Icon(Icons.Outlined.Delete, stringResource(R.string.delete))
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton({
+ context.popToast(R.string.select_ca_cert)
+ getCertLauncher.launch(arrayOf("*/*"))
+ }) {
+ Icon(Icons.Default.Add, stringResource(R.string.install))
+ }
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ LazyColumn(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ items(caCerts, { it.hash }) { cert ->
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ vm.selectCert(cert)
+ dialog = 2
+ }
+ .animateItem()
+ .padding(vertical = 10.dp, horizontal = 8.dp)
+ ) {
+ Text(cert.hash.substring(0..7))
+ }
+ HorizontalDivider()
+ }
+ item {
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+ }
+ if (selectedCert != null && (dialog == 1 || dialog == 2)) {
+ val cert = selectedCert!!
+ AlertDialog(
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ Text("Serial number", style = typography.labelLarge)
+ SelectionContainer { Text(cert.serialNumber) }
+ Text("Subject", style = typography.labelLarge)
+ SelectionContainer { Text(cert.subject) }
+ Text("Issuer", style = typography.labelLarge)
+ SelectionContainer { Text(cert.issuer) }
+ Text("Issued on", style = typography.labelLarge)
+ SelectionContainer { Text(formatDate(cert.issuedTime)) }
+ Text("Expires on", style = typography.labelLarge)
+ SelectionContainer { Text(formatDate(cert.expiresTime)) }
+ Text("SHA-256 fingerprint", style = typography.labelLarge)
+ SelectionContainer { Text(cert.hash) }
+ if (dialog == 2) Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp), Arrangement.SpaceBetween
+ ) {
+ TextButton(
+ {
+ vm.uninstallCert()
+ dialog = 0
+ },
+ Modifier.fillMaxWidth(0.49F)
+ ) {
+ Text(stringResource(R.string.uninstall))
+ }
+ FilledTonalButton(
+ {
+ exportCertLauncher.launch(cert.hash.substring(0..7) + ".0")
+ },
+ Modifier.fillMaxWidth(0.96F)
+ ) {
+ Text(stringResource(R.string.export))
+ }
+ }
+ }
+ },
+ confirmButton = {
+ if (dialog == 1) {
+ TextButton({
+ vm.installCert()
+ dialog = 0
+ }) {
+ Text(stringResource(R.string.install))
+ }
+ } else {
+ TextButton({
+ dialog = 0
+ }) {
+ Text(stringResource(R.string.confirm))
+ }
+ }
+ },
+ dismissButton = {
+ if (dialog == 1) {
+ TextButton({
+ dialog = 0
+ }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+ }
+ if (dialog == 3) {
+ AlertDialog(
+ text = {
+ Text(stringResource(R.string.uninstall_all_user_ca_cert))
+ },
+ confirmButton = {
+ TextButton({
+ vm.uninstallAll()
+ dialog = 0
+ }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ dismissButton = {
+ TextButton({
+ dialog = 0
+ }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertViewModel.kt
new file mode 100644
index 0000000..82a7752
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertViewModel.kt
@@ -0,0 +1,72 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import java.security.MessageDigest
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+
+class CaCertViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val toastChannel: ToastChannel
+) : ViewModel() {
+ val installedCertsState = MutableStateFlow(emptyList())
+
+ val selectedCert = MutableStateFlow(null)
+
+ fun getCaCerts() = ph.safeDpmCall {
+ installedCertsState.value = dpm.getInstalledCaCerts(dar).mapNotNull { parseCert(it) }
+ }
+
+ fun selectCert(cert: CaCertInfo) {
+ selectedCert.value = cert
+ }
+
+ fun parseCert(uri: Uri) {
+ try {
+ application.contentResolver.openInputStream(uri)?.use {
+ selectedCert.value = parseCert(it.readBytes())
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ toastChannel.sendStatus(false)
+ }
+ }
+
+ private fun parseCert(bytes: ByteArray): CaCertInfo {
+ val hash = MessageDigest.getInstance("SHA-256").digest(bytes).toHexString()
+ val factory = CertificateFactory.getInstance("X.509")
+ val cert = factory.generateCertificate(bytes.inputStream()) as X509Certificate
+ return CaCertInfo(
+ hash, cert.serialNumber.toString(16),
+ cert.issuerX500Principal.name, cert.subjectX500Principal.name,
+ cert.notBefore.time, cert.notAfter.time, bytes
+ )
+ }
+
+ fun installCert() = ph.safeDpmCall {
+ val result = dpm.installCaCert(dar, selectedCert.value!!.bytes)
+ if (result) getCaCerts()
+ toastChannel.sendStatus(result)
+ }
+
+ fun uninstallCert() = ph.safeDpmCall {
+ dpm.uninstallCaCert(dar, selectedCert.value!!.bytes)
+ getCaCerts()
+ }
+
+ fun uninstallAll() = ph.safeDpmCall {
+ dpm.uninstallAllUserCaCerts(dar)
+ installedCertsState.value = emptyList()
+ }
+
+ fun exportCert(uri: Uri) {
+ application.contentResolver.openOutputStream(uri)?.use {
+ it.write(selectedCert.value!!.bytes)
+ }
+ toastChannel.sendStatus(true)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorModel.kt
new file mode 100644
index 0000000..f8ea961
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorModel.kt
@@ -0,0 +1,19 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.os.HardwarePropertiesManager
+import androidx.annotation.RequiresApi
+import com.bintianqi.owndroid.R
+
+class HardwareProperties(
+ val temperatures: Map> = emptyMap(),
+ val cpuUsages: List> = emptyList(),
+ val fanSpeeds: List = emptyList()
+)
+
+@RequiresApi(24)
+val temperatureTypes = mapOf(
+ HardwarePropertiesManager.DEVICE_TEMPERATURE_CPU to R.string.cpu_temp,
+ HardwarePropertiesManager.DEVICE_TEMPERATURE_GPU to R.string.gpu_temp,
+ HardwarePropertiesManager.DEVICE_TEMPERATURE_BATTERY to R.string.battery_temp,
+ HardwarePropertiesManager.DEVICE_TEMPERATURE_SKIN to R.string.skin_temp
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorScreen.kt
new file mode 100644
index 0000000..936df92
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorScreen.kt
@@ -0,0 +1,111 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.os.HardwarePropertiesManager
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.MyScaffold
+import kotlin.math.roundToLong
+
+@RequiresApi(24)
+@Composable
+fun HardwareMonitorScreen(
+ vm: HardwareMonitorViewModel, onNavigateUp: () -> Unit
+) {
+ val properties by vm.propertiesState.collectAsState()
+ var refreshInterval by rememberSaveable { mutableFloatStateOf(1F) }
+ val refreshIntervalMs = (refreshInterval * 1000).roundToLong()
+ LaunchedEffect(Unit) {
+ vm.startHardwareMonitor()
+ }
+ MyScaffold(R.string.hardware_monitor, onNavigateUp) {
+ Text(
+ stringResource(R.string.refresh_interval), Modifier.padding(top = 8.dp, bottom = 4.dp),
+ style = typography.titleLarge
+ )
+ Slider(refreshInterval, {
+ refreshInterval = it
+ vm.setRefreshInterval(it)
+ }, valueRange = 0.5F..2F, steps = 14)
+ Text("${refreshIntervalMs}ms")
+ Spacer(Modifier.padding(vertical = 10.dp))
+ properties.temperatures.forEach { tempMapItem ->
+ Text(
+ stringResource(temperatureTypes[tempMapItem.key]!!), style = typography.titleLarge,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ if (tempMapItem.value.isEmpty()) {
+ Text(stringResource(R.string.unsupported))
+ } else {
+ tempMapItem.value.forEachIndexed { index, temp ->
+ Row(modifier = Modifier.padding(vertical = 4.dp)) {
+ Text(
+ index.toString(), style = typography.titleMedium,
+ modifier = Modifier.padding(start = 8.dp, end = 12.dp)
+ )
+ Text(
+ if (temp == HardwarePropertiesManager.UNDEFINED_TEMPERATURE) stringResource(
+ R.string.undefined
+ ) else temp.toString()
+ )
+ }
+ }
+ }
+ Spacer(Modifier.padding(vertical = 10.dp))
+ }
+ Text(
+ stringResource(R.string.cpu_usages), style = typography.titleLarge,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ if (properties.cpuUsages.isEmpty()) {
+ Text(stringResource(R.string.unsupported))
+ } else {
+ properties.cpuUsages.forEachIndexed { index, usage ->
+ Row(modifier = Modifier.padding(vertical = 4.dp)) {
+ Text(
+ index.toString(), style = typography.titleMedium,
+ modifier = Modifier.padding(start = 8.dp, end = 12.dp)
+ )
+ Column {
+ Text(stringResource(R.string.active) + ": " + usage.first + "ms")
+ Text(stringResource(R.string.total) + ": " + usage.second + "ms")
+ }
+ }
+ }
+ }
+ Spacer(Modifier.padding(vertical = 10.dp))
+ Text(
+ stringResource(R.string.fan_speeds), style = typography.titleLarge,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ if (properties.fanSpeeds.isEmpty()) {
+ Text(stringResource(R.string.unsupported))
+ } else {
+ properties.fanSpeeds.forEachIndexed { index, speed ->
+ Row(modifier = Modifier.padding(vertical = 4.dp)) {
+ Text(
+ index.toString(), style = typography.titleMedium,
+ modifier = Modifier.padding(start = 8.dp, end = 12.dp)
+ )
+ Text("$speed RPM")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorViewModel.kt
new file mode 100644
index 0000000..18ae3a0
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorViewModel.kt
@@ -0,0 +1,53 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.os.HardwarePropertiesManager
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+class HardwareMonitorViewModel(val application: MyApplication) : ViewModel() {
+ val propertiesState = MutableStateFlow(HardwareProperties())
+
+ var refreshInterval = 1000L
+
+ fun setRefreshInterval(interval: Float) {
+ refreshInterval = (interval * 1000).toLong()
+ }
+
+ lateinit var job: Job
+
+ @RequiresApi(24)
+ fun startHardwareMonitor() {
+ job = viewModelScope.launch {
+ val hpm = application.getSystemService(HardwarePropertiesManager::class.java)
+ while (true) {
+ val properties = HardwareProperties(
+ temperatureTypes.map { (type, _) ->
+ type to hpm.getDeviceTemperatures(
+ type, HardwarePropertiesManager.TEMPERATURE_CURRENT
+ ).toList()
+ }.toMap(),
+ hpm.cpuUsages.map { it.active to it.total },
+ hpm.fanSpeeds.toList()
+ )
+ if (properties.cpuUsages.isEmpty() && properties.fanSpeeds.isEmpty() &&
+ properties.temperatures.isEmpty()
+ ) {
+ break
+ }
+ propertiesState.value = properties
+ delay(refreshInterval)
+ }
+ }
+ }
+
+ override fun onCleared() {
+ job.cancel()
+ super.onCleared()
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeScreen.kt
new file mode 100644
index 0000000..48508b5
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeScreen.kt
@@ -0,0 +1,261 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.app.admin.DevicePolicyManager
+import android.os.Build.VERSION
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.feature.applications.ApplicationItem
+import com.bintianqi.owndroid.ui.ErrorDialog
+import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.PackageNameTextField
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.isValidPackageName
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@RequiresApi(28)
+@Composable
+fun LockTaskModeScreen(
+ vm: LockTaskModeViewModel, chosenPackage: Channel, chooseSinglePackage: () -> Unit,
+ choosePackage: () -> Unit, onNavigateUp: () -> Unit
+) {
+ val coroutine = rememberCoroutineScope()
+ val pagerState = rememberPagerState { 3 }
+ val tabIndex = pagerState.targetPage
+ LaunchedEffect(Unit) {
+ vm.getPackages()
+ vm.getFeatures()
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ { Text(stringResource(R.string.lock_task_mode)) },
+ navigationIcon = { NavIcon(onNavigateUp) }
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ PrimaryTabRow(tabIndex) {
+ Tab(
+ tabIndex == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } },
+ text = { Text(stringResource(R.string.start)) }
+ )
+ Tab(
+ tabIndex == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } },
+ text = { Text(stringResource(R.string.applications)) }
+ )
+ Tab(
+ tabIndex == 2, { coroutine.launch { pagerState.animateScrollToPage(2) } },
+ text = { Text(stringResource(R.string.features)) }
+ )
+ }
+ HorizontalPager(pagerState, verticalAlignment = Alignment.Top) { page ->
+ if(page == 0) {
+ StartLockTaskMode(vm::startLockTaskMode, chosenPackage, chooseSinglePackage)
+ } else if (page == 1) {
+ LockTaskPackages(chosenPackage, choosePackage, vm.packagesState, vm::setPackage)
+ } else {
+ LockTaskFeatures(vm.featuresState, vm::setFeatures, vm::applyFeatures)
+ }
+ }
+ }
+ }
+}
+
+@RequiresApi(28)
+@Composable
+private fun StartLockTaskMode(
+ startLockTaskMode: (String, String, Boolean, Boolean) -> Unit,
+ chosenPackage: Channel, onChoosePackage: () -> Unit
+) {
+ val focusMgr = LocalFocusManager.current
+ 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()
+ .verticalScroll(rememberScrollState())
+ ) {
+ 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(4.dp, 4.dp, HorizontalPadding, 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(specifyActivity, {
+ specifyActivity = it
+ activity = ""
+ })
+ OutlinedTextField(
+ activity, { activity = it },
+ Modifier.fillMaxWidth(),
+ label = { Text("Activity") },
+ enabled = specifyActivity,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() })
+ )
+ }
+ Button(
+ {
+ startLockTaskMode(packageName, activity, clearTask, showNotification)
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding),
+ packageName.isNotBlank() && (!specifyActivity || activity.isNotBlank())
+ ) {
+ Text(stringResource(R.string.start))
+ }
+ Spacer(Modifier.height(5.dp))
+ Notes(R.string.info_start_lock_task_mode, HorizontalPadding)
+ }
+}
+
+@RequiresApi(26)
+@Composable
+private fun LockTaskPackages(
+ chosenPackage: Channel, onChoosePackage: () -> Unit,
+ packagesState: StateFlow>, setPackage: (String, Boolean) -> Unit
+) {
+ val packages by packagesState.collectAsStateWithLifecycle()
+ var packageName by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ packageName = chosenPackage.receive()
+ }
+ LazyColumn {
+ items(packages, { it.name }) {
+ ApplicationItem(it) { setPackage(it.name, false) }
+ }
+ item {
+ Column(
+ Modifier.padding(horizontal = HorizontalPadding)
+ ) {
+ PackageNameTextField(
+ packageName, onChoosePackage, Modifier.padding(vertical = 3.dp)
+ ) { packageName = it }
+ Button(
+ {
+ setPackage(packageName, true)
+ packageName = ""
+ },
+ Modifier.fillMaxWidth(),
+ packageName.isValidPackageName
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ Notes(R.string.info_lock_task_packages)
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+ }
+}
+
+@RequiresApi(28)
+@Composable
+private fun LockTaskFeatures(
+ featuresState: StateFlow, setFlag: (Int) -> Unit, apply: ((String?) -> Unit) -> Unit
+) {
+ val flags by featuresState.collectAsState()
+ var errorMessage by rememberSaveable { mutableStateOf(null) }
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Spacer(Modifier.padding(vertical = 5.dp))
+ listOf(
+ DevicePolicyManager.LOCK_TASK_FEATURE_SYSTEM_INFO to R.string.ltf_sys_info,
+ DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS to R.string.ltf_notifications,
+ DevicePolicyManager.LOCK_TASK_FEATURE_HOME to R.string.ltf_home,
+ DevicePolicyManager.LOCK_TASK_FEATURE_OVERVIEW to R.string.ltf_overview,
+ DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS to R.string.ltf_global_actions,
+ DevicePolicyManager.LOCK_TASK_FEATURE_KEYGUARD to R.string.ltf_keyguard
+ ).let {
+ if(VERSION.SDK_INT >= 30) it.plus(
+ DevicePolicyManager.LOCK_TASK_FEATURE_BLOCK_ACTIVITY_START_IN_TASK to
+ R.string.ltf_block_activity_start_in_task)
+ else it
+ }.forEach { (id, title) ->
+ FullWidthCheckBoxItem(title, flags and id != 0) { setFlag(flags xor id) }
+ }
+ Button(
+ {
+ apply { errorMessage = it }
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp)
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ Spacer(Modifier.height(BottomPadding))
+ }
+ ErrorDialog(errorMessage) { errorMessage = null }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeViewModel.kt
new file mode 100644
index 0000000..e6ed48d
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeViewModel.kt
@@ -0,0 +1,95 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.app.ActivityOptions
+import android.app.admin.DevicePolicyManager
+import android.content.ComponentName
+import android.content.Intent
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.LockTaskService
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.ToastChannel
+import com.bintianqi.owndroid.utils.getAppInfo
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class LockTaskModeViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val toastChannel: ToastChannel
+) : ViewModel() {
+ val packagesState = MutableStateFlow(emptyList())
+
+ @RequiresApi(26)
+ fun getPackages() = ph.safeDpmCall {
+ val pm = application.packageManager
+ packagesState.value = dpm.getLockTaskPackages(dar).map { getAppInfo(pm, it) }
+ }
+
+ @RequiresApi(26)
+ fun setPackage(name: String, status: Boolean) = ph.safeDpmCall {
+ dpm.setLockTaskPackages(
+ dar,
+ packagesState.value.map { it.name }
+ .run { if (status) plus(name) else minus(name) }
+ .toTypedArray()
+ )
+ getPackages()
+ }
+
+ @RequiresApi(28)
+ fun startLockTaskMode(
+ packageName: String, activity: String, clearTask: Boolean, showNotification: Boolean
+ ) = ph.safeDpmCall {
+ if (!dpm.isLockTaskPermitted(packageName)) {
+ val list = packagesState.value.map { it.name } + packageName
+ dpm.setLockTaskPackages(dar, list.toTypedArray())
+ getPackages()
+ }
+ 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 pm = application.packageManager
+ val intent = if (activity.isNotEmpty()) {
+ Intent().setComponent(ComponentName(packageName, activity))
+ } else pm.getLaunchIntentForPackage(packageName)
+ if (intent != null) {
+ 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))
+ }
+ } else {
+ toastChannel.sendStatus(false)
+ }
+ }
+
+ val featuresState = MutableStateFlow(0)
+
+ @RequiresApi(28)
+ fun getFeatures() = ph.safeDpmCall {
+ featuresState.value = dpm.getLockTaskFeatures(dar)
+ }
+
+ fun setFeatures(flags: Int) {
+ featuresState.value = flags
+ }
+
+ @RequiresApi(28)
+ fun applyFeatures(errorCallback: (String?) -> Unit) = ph.safeDpmCall {
+ try {
+ dpm.setLockTaskFeatures(dar, featuresState.value)
+ } catch (e: IllegalArgumentException) {
+ errorCallback(e.message)
+ }
+ toastChannel.sendStatus(true)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingModel.kt
new file mode 100644
index 0000000..941cba1
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingModel.kt
@@ -0,0 +1,182 @@
+package com.bintianqi.owndroid.feature.system
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonObject
+
+@Serializable
+class SecurityEvent(
+ val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: JsonObject?
+)
+
+@Serializable
+class SecurityEventWithData(
+ val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: SecurityEventData?
+)
+
+@Serializable
+sealed class SecurityEventData {
+ @Serializable
+ class AdbShellCmd(val command: String): SecurityEventData()
+ @Serializable
+ class AppProcessStart(
+ val name: String,
+ val time: Long,
+ val uid: Int,
+ val pid: Int,
+ val seinfo: String,
+ val hash: String
+ ): SecurityEventData()
+ @Serializable
+ class BackupServiceToggled(
+ val admin: String,
+ val user: Int,
+ val state: Int
+ ): SecurityEventData()
+ @Serializable
+ class BluetoothConnection(
+ val mac: String,
+ val successful: Int,
+ @SerialName("failure_reason") val failureReason: String
+ ): SecurityEventData()
+ @Serializable
+ class BluetoothDisconnection(
+ val mac: String,
+ val reason: String
+ ): SecurityEventData()
+ @Serializable
+ class CameraPolicySet(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val disabled: Int
+ ): SecurityEventData()
+ @Serializable
+ class CaInstalledRemoved(
+ val result: Int,
+ val subject: String,
+ val user: Int
+ ): SecurityEventData()
+ @Serializable
+ class CertValidationFailure(val reason: String): SecurityEventData()
+ @Serializable
+ class CryptoSelfTestCompleted(val result: Int): SecurityEventData()
+ @Serializable
+ class KeyguardDisabledFeaturesSet(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val mask: Int
+ ): SecurityEventData()
+ @Serializable
+ class KeyguardDismissAuthAttempt(
+ val result: Int,
+ val strength: Int
+ ): SecurityEventData()
+ @Serializable
+ class KeyGeneratedImportDestruction(
+ val result: Int,
+ val alias: String,
+ val uid: Int
+ ): SecurityEventData()
+ @Serializable
+ class KeyIntegrityViolation(
+ val alias: String,
+ val uid: Int
+ ): SecurityEventData()
+ @Serializable
+ class MaxPasswordAttemptsSet(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val value: Int
+ ): SecurityEventData()
+ @Serializable
+ class MaxScreenLockTimeoutSet(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val timeout: Long
+ ): SecurityEventData()
+ @Serializable
+ class MediaMountUnmount(
+ @SerialName("mount_point") val mountPoint: String,
+ val label: String
+ ): SecurityEventData()
+ @Serializable
+ class OsStartup(
+ @SerialName("verified_boot_state") val verifiedBootState: String,
+ @SerialName("dm_verity_mode") val dmVerityMode: String
+ ): SecurityEventData()
+ @Serializable
+ class PackageInstalledUninstalledUpdated(
+ val name: String,
+ val version: Long,
+ val user: Int
+ ): SecurityEventData()
+ @Serializable
+ class PasswordChanged(
+ val complexity: Int,
+ val user: Int
+ ): SecurityEventData()
+ @Serializable
+ class PasswordComplexityRequired(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val complexity: Int
+ ): SecurityEventData()
+ @Serializable
+ class PasswordComplexitySet(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val length: Int,
+ val quality: Int,
+ val letters: Int,
+ @SerialName("non_letters") val nonLetters: Int,
+ val digits: Int,
+ val uppercase: Int,
+ val lowercase: Int,
+ val symbols: Int
+ ): SecurityEventData()
+ @Serializable
+ class PasswordExpirationSet(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val expiration: Long
+ ): SecurityEventData()
+ @Serializable
+ class PasswordHistoryLengthSet(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ val length: Int
+ ): SecurityEventData()
+ @Serializable
+ class RemoteLock(
+ val admin: String,
+ @SerialName("admin_user") val adminUser: Int,
+ @SerialName("target_user") val targetUser: Int,
+ ): SecurityEventData()
+ @Serializable
+ class SyncRecvSendFile(val path: String): SecurityEventData()
+ @Serializable
+ class UserRestrictionAddedRemoved(
+ val admin: String,
+ val user: Int,
+ val restriction: String
+ ): SecurityEventData()
+ @Serializable
+ class WifiConnection(
+ val bssid: String,
+ val type: String,
+ @SerialName("failure_reason") val failureReason: String
+ ): SecurityEventData()
+ @Serializable
+ class WifiDisconnection(
+ val bssid: String,
+ val reason: String
+ ): SecurityEventData()
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingRepository.kt
new file mode 100644
index 0000000..ba62c9b
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingRepository.kt
@@ -0,0 +1,274 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.app.admin.SecurityLog
+import android.database.DatabaseUtils
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.core.database.getStringOrNull
+import com.bintianqi.owndroid.MyDbHelper
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.ClassDiscriminatorMode
+import kotlinx.serialization.json.Json
+import java.io.OutputStream
+
+class SecurityLoggingRepository(val dbHelper: MyDbHelper) {
+ fun getSecurityLogsCount(): Long {
+ return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "security_logs")
+ }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ @RequiresApi(24)
+ fun writeSecurityLogs(events: List) {
+ val db = dbHelper.writableDatabase
+ val json = Json {
+ classDiscriminatorMode = ClassDiscriminatorMode.NONE
+ }
+ val statement = db.compileStatement("INSERT INTO security_logs VALUES (?, ?, ?, ?, ?)")
+ db.beginTransaction()
+ events.forEach { event ->
+ try {
+ if (Build.VERSION.SDK_INT >= 28) {
+ statement.bindLong(1, event.id)
+ statement.bindLong(3, event.logLevel.toLong())
+ } else {
+ statement.bindNull(1)
+ statement.bindNull(3)
+ }
+ statement.bindLong(2, event.tag.toLong())
+ statement.bindLong(4, event.timeNanos / 1000000)
+ val dataObject = transformSecurityEventData(event.tag, event.data)
+ if (dataObject == null) {
+ statement.bindNull(5)
+ } else {
+ statement.bindString(5, json.encodeToString(dataObject))
+ }
+ statement.executeInsert()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ } finally {
+ statement.clearBindings()
+ }
+ }
+ db.setTransactionSuccessful()
+ db.endTransaction()
+ statement.close()
+ }
+
+ fun exportSecurityLogs(stream: OutputStream) {
+ var offset = 0
+ val json = Json {
+ explicitNulls = false
+ }
+ var addComma = false
+ val bw = stream.bufferedWriter()
+ bw.write("[")
+ while (true) {
+ dbHelper.readableDatabase.rawQuery(
+ "SELECT * FROM security_logs LIMIT ? OFFSET ?",
+ arrayOf(100.toString(), offset.toString())
+ ).use { cursor ->
+ if (cursor.count == 0) {
+ break
+ }
+ while (cursor.moveToNext()) {
+ if (addComma) bw.write(",")
+ addComma = true
+ val event = SecurityEvent(
+ cursor.getLong(0), cursor.getInt(1), cursor.getInt(2), cursor.getLong(3),
+ cursor.getStringOrNull(4)?.let { json.decodeFromString(it) }
+ )
+ bw.write(json.encodeToString(event))
+ }
+ offset += 100
+ }
+ }
+ bw.write("]")
+ bw.close()
+ }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ @RequiresApi(24)
+ fun exportPRSecurityLogs(logs: List, stream: OutputStream) {
+ val bw = stream.bufferedWriter()
+ bw.write("[")
+ val json = Json {
+ explicitNulls = false
+ classDiscriminatorMode = ClassDiscriminatorMode.NONE
+ }
+ var addComma = false
+ logs.forEach { log ->
+ try {
+ if (addComma) bw.write(",")
+ addComma = true
+ val event = SecurityEventWithData(
+ if (Build.VERSION.SDK_INT >= 28) log.id else null, log.tag,
+ if (Build.VERSION.SDK_INT >= 28) log.logLevel else null,
+ log.timeNanos / 1000000,
+ transformSecurityEventData(log.tag, log.data)
+ )
+ bw.write(json.encodeToString(event))
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ bw.write("]")
+ bw.close()
+ }
+
+ fun deleteSecurityLogs() {
+ dbHelper.writableDatabase.execSQL("DELETE FROM security_logs")
+ }
+
+ companion object {
+ fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? {
+ return when(tag) {
+ SecurityLog.TAG_ADB_SHELL_CMD -> SecurityEventData.AdbShellCmd(payload as String)
+ SecurityLog.TAG_ADB_SHELL_INTERACTIVE -> null
+ SecurityLog.TAG_APP_PROCESS_START -> {
+ val data = payload as Array<*>
+ SecurityEventData.AppProcessStart(
+ data[0] as String, data[1] as Long, data[2] as Int, data[3] as Int,
+ data[4] as String, data[5] as String
+ )
+ }
+ SecurityLog.TAG_BACKUP_SERVICE_TOGGLED -> {
+ val data = payload as Array<*>
+ SecurityEventData.BackupServiceToggled(
+ data[0] as String, data[1] as Int, data[2] as Int
+ )
+ }
+ SecurityLog.TAG_BLUETOOTH_CONNECTION -> {
+ val data = payload as Array<*>
+ SecurityEventData.BluetoothConnection(
+ data[0] as String, data[1] as Int, data[2] as String
+ )
+ }
+ SecurityLog.TAG_BLUETOOTH_DISCONNECTION -> {
+ val data = payload as Array<*>
+ SecurityEventData.BluetoothDisconnection(data[0] as String, data[1] as String)
+ }
+ SecurityLog.TAG_CAMERA_POLICY_SET -> {
+ val data = payload as Array<*>
+ SecurityEventData.CameraPolicySet(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
+ )
+ }
+ SecurityLog.TAG_CERT_AUTHORITY_INSTALLED, SecurityLog.TAG_CERT_AUTHORITY_REMOVED -> {
+ val data = payload as Array<*>
+ SecurityEventData.CaInstalledRemoved(
+ data[0] as Int, data[1] as String, data[2] as Int
+ )
+ }
+ SecurityLog.TAG_CERT_VALIDATION_FAILURE ->
+ SecurityEventData.CertValidationFailure(payload as String)
+ SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED ->
+ SecurityEventData.CryptoSelfTestCompleted(payload as Int)
+ SecurityLog.TAG_KEYGUARD_DISABLED_FEATURES_SET -> {
+ val data = payload as Array<*>
+ SecurityEventData.KeyguardDisabledFeaturesSet(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
+ )
+ }
+ SecurityLog.TAG_KEYGUARD_DISMISSED -> null
+ SecurityLog.TAG_KEYGUARD_DISMISS_AUTH_ATTEMPT -> {
+ val data = payload as Array<*>
+ SecurityEventData.KeyguardDismissAuthAttempt(data[0] as Int, data[1] as Int)
+ }
+ SecurityLog.TAG_KEYGUARD_SECURED -> null
+ SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT,
+ SecurityLog.TAG_KEY_DESTRUCTION -> {
+ val data = payload as Array<*>
+ SecurityEventData.KeyGeneratedImportDestruction(
+ data[0] as Int, data[1] as String, data[2] as Int
+ )
+ }
+ SecurityLog.TAG_LOGGING_STARTED, SecurityLog.TAG_LOGGING_STOPPED -> null
+ SecurityLog.TAG_LOG_BUFFER_SIZE_CRITICAL -> null
+ SecurityLog.TAG_MAX_PASSWORD_ATTEMPTS_SET -> {
+ val data = payload as Array<*>
+ SecurityEventData.MaxPasswordAttemptsSet(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
+ )
+ }
+ SecurityLog.TAG_MAX_SCREEN_LOCK_TIMEOUT_SET -> {
+ val data = payload as Array<*>
+ SecurityEventData.MaxScreenLockTimeoutSet(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long
+ )
+ }
+ SecurityLog.TAG_MEDIA_MOUNT, SecurityLog.TAG_MEDIA_UNMOUNT -> {
+ val data = payload as Array<*>
+ SecurityEventData.MediaMountUnmount(data[0] as String, data[1] as String)
+ }
+ SecurityLog.TAG_NFC_ENABLED, SecurityLog.TAG_NFC_DISABLED -> null
+ SecurityLog.TAG_OS_SHUTDOWN -> null
+ SecurityLog.TAG_OS_STARTUP -> {
+ val data = payload as Array<*>
+ SecurityEventData.OsStartup(data[0] as String, data[1] as String)
+ }
+ SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UPDATED,
+ SecurityLog.TAG_PACKAGE_UNINSTALLED -> {
+ val data = payload as Array<*>
+ SecurityEventData.PackageInstalledUninstalledUpdated(
+ data[0] as String, data[1] as Long, data[2] as Int
+ )
+ }
+ SecurityLog.TAG_PASSWORD_CHANGED -> {
+ val data = payload as Array<*>
+ SecurityEventData.PasswordChanged(data[0] as Int, data[1] as Int)
+ }
+ SecurityLog.TAG_PASSWORD_COMPLEXITY_REQUIRED -> {
+ val data = payload as Array<*>
+ SecurityEventData.PasswordComplexityRequired(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
+ )
+ }
+ SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> {
+ val data = payload as Array<*>
+ SecurityEventData.PasswordComplexitySet(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int,
+ data[4] as Int, data[5] as Int, data[6] as Int, data[7] as Int,
+ data[8] as Int, data[9] as Int, data[10] as Int
+ )
+ }
+ SecurityLog.TAG_PASSWORD_EXPIRATION_SET -> {
+ val data = payload as Array<*>
+ SecurityEventData.PasswordExpirationSet(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long
+ )
+ }
+ SecurityLog.TAG_PASSWORD_HISTORY_LENGTH_SET -> {
+ val data = payload as Array<*>
+ SecurityEventData.PasswordHistoryLengthSet(
+ data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int
+ )
+ }
+ SecurityLog.TAG_REMOTE_LOCK -> {
+ val data = payload as Array<*>
+ SecurityEventData.RemoteLock(data[0] as String, data[1] as Int, data[2] as Int)
+ }
+ SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE ->
+ SecurityEventData.SyncRecvSendFile(payload as String)
+ SecurityLog.TAG_USER_RESTRICTION_ADDED,
+ SecurityLog.TAG_USER_RESTRICTION_REMOVED -> {
+ val data = payload as Array<*>
+ SecurityEventData.UserRestrictionAddedRemoved(
+ data[0] as String, data[1] as Int, data[2] as String
+ )
+ }
+ SecurityLog.TAG_WIFI_CONNECTION -> {
+ val data = payload as Array<*>
+ SecurityEventData.WifiConnection(
+ data[0] as String, data[1] as String, data[2] as String
+ )
+ }
+ SecurityLog.TAG_WIFI_DISCONNECTION -> {
+ val data = payload as Array<*>
+ SecurityEventData.WifiDisconnection(data[0] as String, data[1] as String)
+ }
+ SecurityLog.TAG_WIPE_FAILURE -> null
+ else -> null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingScreen.kt
new file mode 100644
index 0000000..c8e6e69
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingScreen.kt
@@ -0,0 +1,116 @@
+package com.bintianqi.owndroid.feature.system
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.CircularProgressDialog
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@RequiresApi(24)
+@Composable
+fun SecurityLoggingScreen(
+ vm: SecurityLoggingViewModel, onNavigateUp: () -> Unit
+) {
+ val enabled by vm.enabledState.collectAsState()
+ val logsCount by vm.countState.collectAsState()
+ val exporting by vm.exportingState.collectAsState()
+ var dialog by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ vm.getEnabled()
+ vm.getCount()
+ }
+ val exportLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("application/json")
+ ) {
+ if (it != null) vm.exportLogs(it)
+ }
+ val exportPRLogsLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("application/json")
+ ) {
+ if (it != null) vm.exportPreRebootSecurityLogs(it)
+ }
+ MyScaffold(R.string.security_logging, onNavigateUp, 0.dp) {
+ SwitchItem(R.string.enable, enabled, vm::setEnabled)
+ Text(
+ stringResource(R.string.n_logs_in_total, logsCount),
+ Modifier.padding(HorizontalPadding)
+ )
+ Button(
+ {
+ val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
+ exportLauncher.launch("security_logs_$date")
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding),
+ logsCount > 0
+ ) {
+ Text(stringResource(R.string.export_logs))
+ }
+ if (logsCount > 0) FilledTonalButton(
+ { dialog = true },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 4.dp)
+ ) {
+ Text(stringResource(R.string.delete_logs))
+ }
+ Notes(R.string.info_security_log, HorizontalPadding)
+ Button(
+ {
+ vm.getPreRebootSecurityLogs {
+ val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
+ exportPRLogsLauncher.launch("pre_reboot_security_logs_$date")
+ }
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 15.dp)
+ ) {
+ Text(stringResource(R.string.pre_reboot_security_logs))
+ }
+ Notes(R.string.info_pre_reboot_security_log, HorizontalPadding)
+ }
+ if (exporting) CircularProgressDialog {}
+ if (dialog) AlertDialog(
+ text = { Text(stringResource(R.string.delete_logs)) },
+ confirmButton = {
+ TextButton({
+ vm.deleteLogs()
+ dialog = false
+ }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ dismissButton = {
+ TextButton({ dialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = { dialog = false }
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingViewModel.kt
new file mode 100644
index 0000000..5d8e508
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingViewModel.kt
@@ -0,0 +1,85 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.app.admin.SecurityLog
+import android.net.Uri
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlin.collections.isNotEmpty
+
+class SecurityLoggingViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val repo: SecurityLoggingRepository,
+ val toastChannel: ToastChannel
+) : ViewModel() {
+ val enabledState = MutableStateFlow(false)
+
+ @RequiresApi(24)
+ fun getEnabled() = ph.safeDpmCall {
+ enabledState.value = dpm.isSecurityLoggingEnabled(dar)
+ }
+
+ @RequiresApi(24)
+ fun setEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.setSecurityLoggingEnabled(dar, enabled)
+ enabledState.value = enabled
+ }
+
+ val countState = MutableStateFlow(0L)
+
+ fun getCount() {
+ countState.value = repo.getSecurityLogsCount()
+ }
+
+ val exportingState = MutableStateFlow(false)
+
+ fun exportLogs(uri: Uri) {
+ viewModelScope.launch(Dispatchers.IO) {
+ exportingState.value = true
+ application.contentResolver.openOutputStream(uri)?.use {
+ repo.exportSecurityLogs(it)
+ }
+ exportingState.value = false
+ toastChannel.sendStatus(true)
+ }
+ }
+
+ fun deleteLogs() {
+ repo.deleteSecurityLogs()
+ countState.value = 0
+ }
+
+ var preRebootSecurityLogs = emptyList()
+
+ @RequiresApi(24)
+ fun getPreRebootSecurityLogs(callback: () -> Unit) {
+ if (preRebootSecurityLogs.isNotEmpty()) callback()
+ val result = try {
+ val logs = ph.myDpm.retrievePreRebootSecurityLogs(ph.myDar)
+ if (!logs.isNullOrEmpty()) {
+ preRebootSecurityLogs = logs
+ true
+ } else false
+ } catch (_: SecurityException) {
+ false
+ }
+ if (!result) toastChannel.sendStatus(false)
+ }
+
+ @RequiresApi(24)
+ fun exportPreRebootSecurityLogs(uri: Uri) {
+ viewModelScope.launch(Dispatchers.IO) {
+ exportingState.value = true
+ application.contentResolver.openOutputStream(uri)!!.use {
+ repo.exportPRSecurityLogs(preRebootSecurityLogs, it)
+ }
+ exportingState.value = false
+ toastChannel.sendStatus(true)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemModel.kt
new file mode 100644
index 0000000..9594413
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemModel.kt
@@ -0,0 +1,17 @@
+package com.bintianqi.owndroid.feature.system
+
+data class FrpPolicyInfo(
+ val supported: Boolean = false,
+ val usePolicy: Boolean = false,
+ val enabled: Boolean = false,
+ val accounts: List = emptyList()
+)
+
+class DeviceInfo(
+ val financed: Boolean = false,
+ val dpmrh: String? = null,
+ val storageEncryptionStatus: Int = 0,
+ val deviceIdAttestationSupported: Boolean = false,
+ val uniqueDeviceAttestationSupported: Boolean = false,
+ val activeAdmins: List = emptyList()
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsModel.kt
new file mode 100644
index 0000000..345a787
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsModel.kt
@@ -0,0 +1,36 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.provider.Settings
+import com.bintianqi.owndroid.R
+
+data class SystemOptionsStatus(
+ val cameraDisabled: Boolean = false,
+ val screenCaptureDisabled: Boolean = false,
+ val statusBarDisabled: Boolean = false,
+ val autoTimeEnabled: Boolean = true,
+ val autoTimeZoneEnabled: Boolean = true,
+ val autoTimeRequired: Boolean = true,
+ val masterVolumeMuted: Boolean = false,
+ val backupServiceEnabled: Boolean = false,
+ val btContactSharingDisabled: Boolean = false,
+ val commonCriteriaMode: Boolean = false,
+ val usbSignalEnabled: Boolean = true,
+ val canDisableUsbSignal: Boolean = true,
+ val stayOnWhilePluggedIn: Boolean = false
+)
+
+class GlobalSetting(val icon: Int, val name: Int, val setting: String) // also for secure settings
+
+val globalSettings = listOf(
+ GlobalSetting(R.drawable.cell_tower_fill0, R.string.data_roaming, Settings.Global.DATA_ROAMING),
+ GlobalSetting(R.drawable.adb_fill0, R.string.enable_adb, Settings.Global.ADB_ENABLED),
+ GlobalSetting(R.drawable.usb_fill0, R.string.enable_usb_mass_storage,
+ Settings.Global.USB_MASS_STORAGE_ENABLED),
+ GlobalSetting(R.drawable.wifi_password_fill0, R.string.lockdown_admin_configured_network,
+ Settings.Global.WIFI_DEVICE_OWNER_CONFIGS_LOCKDOWN)
+)
+
+val secureSettings = listOf(
+ GlobalSetting(R.drawable.light_off_fill0, R.string.skip_first_use_hints,
+ Settings.Secure.SKIP_FIRST_USE_HINTS)
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsScreen.kt
new file mode 100644
index 0000000..ab588db
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsScreen.kt
@@ -0,0 +1,161 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.os.Build.VERSION
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.utils.HorizontalPadding
+
+@Composable
+fun SystemOptionsScreen(vm: SystemOptionsViewModel, onNavigateUp: () -> Unit) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ var dialog by rememberSaveable { mutableIntStateOf(0) }
+ val status by vm.optionsState.collectAsStateWithLifecycle()
+ val globalSettingsStatus = remember { mutableStateMapOf() }
+ val secureSettingsStatus = remember { mutableStateMapOf() }
+ LaunchedEffect(Unit) {
+ vm.getSystemOptionsStatus()
+ if (privilege.device) {
+ globalSettingsStatus.putAll(vm.getGlobalSettings())
+ secureSettingsStatus.putAll(vm.getSecureSettings())
+ }
+ }
+ MyScaffold(R.string.options, onNavigateUp, 0.dp) {
+ SwitchItem(
+ R.string.disable_cam, status.cameraDisabled, vm::setCameraDisabled,
+ R.drawable.no_photography_fill0
+ )
+ SwitchItem(
+ R.string.disable_screen_capture, status.screenCaptureDisabled,
+ vm::setScreenCaptureDisabled, R.drawable.screenshot_fill0
+ )
+ if (VERSION.SDK_INT >= 34 && privilege.run { device || (profile && affiliated) }) {
+ SwitchItem(
+ R.string.disable_status_bar, status.statusBarDisabled,
+ vm::setStatusBarDisabled, R.drawable.notifications_fill0
+ )
+ }
+ if (privilege.device || privilege.org) {
+ if (VERSION.SDK_INT >= 30) {
+ SwitchItem(
+ R.string.auto_time, status.autoTimeEnabled, vm::setAutoTimeEnabled,
+ R.drawable.schedule_fill0
+ )
+ SwitchItem(
+ R.string.auto_timezone, status.autoTimeZoneEnabled,
+ vm::setAutoTimeZoneEnabled, R.drawable.globe_fill0
+ )
+ } else {
+ SwitchItem(
+ R.string.require_auto_time, status.autoTimeRequired,
+ vm::setAutoTimeRequired, R.drawable.schedule_fill0
+ )
+ }
+ }
+ if (!privilege.work) SwitchItem(
+ R.string.master_mute,
+ status.masterVolumeMuted, vm::setMasterVolumeMuted, R.drawable.volume_off_fill0
+ )
+ if (VERSION.SDK_INT >= 26) {
+ SwitchItem(
+ R.string.backup_service, icon = R.drawable.backup_fill0,
+ state = status.backupServiceEnabled, onCheckedChange = vm::setBackupServiceEnabled,
+ onClickBlank = { dialog = 1 })
+ }
+ if (VERSION.SDK_INT >= 24 && privilege.work) {
+ SwitchItem(
+ R.string.disable_bt_contact_share, status.btContactSharingDisabled,
+ vm::setBtContactSharingDisabled, R.drawable.account_circle_fill0
+ )
+ }
+ if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) {
+ SwitchItem(
+ R.string.common_criteria_mode, icon = R.drawable.security_fill0,
+ state = status.commonCriteriaMode,
+ onCheckedChange = vm::setCommonCriteriaModeEnabled,
+ onClickBlank = { dialog = 2 })
+ }
+ if (VERSION.SDK_INT >= 31 && (privilege.device || privilege.org) && status.canDisableUsbSignal) {
+ SwitchItem(
+ R.string.enable_usb_signal, status.usbSignalEnabled,
+ vm::setUsbSignalEnabled, R.drawable.usb_fill0
+ )
+ }
+ SwitchItem(
+ R.string.stay_on_while_plugged_in, status.stayOnWhilePluggedIn,
+ vm::setStayOnWhilePluggedIn, R.drawable.mobile_phone_fill0
+ )
+ if (privilege.device && !privilege.dhizuku) {
+ globalSettings.forEach {
+ SwitchItem(it.name, globalSettingsStatus[it.setting] ?: false, { state ->
+ vm.setGlobalSetting(it.setting, state)
+ globalSettingsStatus[it.setting] = state
+ }, it.icon)
+ }
+ secureSettings.forEach {
+ SwitchItem(it.name, secureSettingsStatus[it.setting] ?: false, { state ->
+ vm.setSecureSetting(it.setting, state)
+ secureSettingsStatus[it.setting] = state
+ }, it.icon)
+ }
+ }
+ if (VERSION.SDK_INT < 34) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = HorizontalPadding),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(stringResource(R.string.status_bar), style = typography.titleMedium)
+ Button({
+ vm.setStatusBarDisabled(true)
+ }, Modifier.padding(horizontal = 4.dp)) {
+ Text(stringResource(R.string.disable))
+ }
+ Button({
+ vm.setStatusBarDisabled(false)
+ }) {
+ Text(stringResource(R.string.enable))
+ }
+ }
+ }
+ }
+ if (dialog != 0) AlertDialog(
+ text = {
+ Text(
+ stringResource(
+ when (dialog) {
+ 1 -> R.string.info_backup_service
+ 2 -> R.string.info_common_criteria_mode
+ else -> R.string.options
+ }
+ )
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { dialog = 0 }) { Text(stringResource(R.string.confirm)) }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsViewModel.kt
new file mode 100644
index 0000000..47e22eb
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsViewModel.kt
@@ -0,0 +1,155 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.os.Build.VERSION
+import android.provider.Settings
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
+import com.bintianqi.owndroid.utils.MyShortcut
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ShortcutUtils
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+class SystemOptionsViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val settingsRepo: SettingsRepository,
+ val privilegeState: StateFlow
+) : ViewModel() {
+ val optionsState = MutableStateFlow(SystemOptionsStatus())
+
+ fun getSystemOptionsStatus() = ph.safeDpmCall {
+ val privilege = privilegeState.value
+ optionsState.value = SystemOptionsStatus(
+ cameraDisabled = dpm.getCameraDisabled(null),
+ screenCaptureDisabled = dpm.getScreenCaptureDisabled(null),
+ statusBarDisabled = if (VERSION.SDK_INT >= 34 &&
+ privilege.run { device || (profile && affiliated) }
+ )
+ dpm.isStatusBarDisabled else false,
+ autoTimeEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org))
+ dpm.getAutoTimeEnabled(dar) else false,
+ 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 (privilege.work)
+ dpm.getBluetoothContactSharingDisabled(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,
+ stayOnWhilePluggedIn =
+ Settings.Global.getInt(
+ application.contentResolver, Settings.Global.STAY_ON_WHILE_PLUGGED_IN
+ ) != 0
+ )
+ }
+
+ fun setCameraDisabled(disabled: Boolean) = ph.safeDpmCall {
+ dpm.setCameraDisabled(dar, disabled)
+ ShortcutUtils.setShortcut(application, settingsRepo, MyShortcut.DisableCamera, !disabled)
+ optionsState.update { it.copy(cameraDisabled = dpm.getCameraDisabled(null)) }
+ }
+
+ fun setScreenCaptureDisabled(disabled: Boolean) = ph.safeDpmCall {
+ dpm.setScreenCaptureDisabled(dar, disabled)
+ optionsState.update {
+ it.copy(screenCaptureDisabled = dpm.getScreenCaptureDisabled(null))
+ }
+ }
+
+ fun setStatusBarDisabled(disabled: Boolean) = ph.safeDpmCall {
+ val result = dpm.setStatusBarDisabled(dar, disabled)
+ if (result) optionsState.update { it.copy(statusBarDisabled = disabled) }
+ }
+
+ @RequiresApi(30)
+ fun setAutoTimeEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.setAutoTimeEnabled(dar, enabled)
+ optionsState.update { it.copy(autoTimeEnabled = dpm.getAutoTimeEnabled(dar)) }
+ }
+
+ @RequiresApi(30)
+ fun setAutoTimeZoneEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.setAutoTimeZoneEnabled(dar, enabled)
+ optionsState.update {
+ it.copy(autoTimeZoneEnabled = dpm.getAutoTimeZoneEnabled(dar))
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ fun setAutoTimeRequired(required: Boolean) = ph.safeDpmCall {
+ dpm.setAutoTimeRequired(dar, required)
+ optionsState.update { it.copy(autoTimeRequired = dpm.autoTimeRequired) }
+ }
+
+ fun setMasterVolumeMuted(muted: Boolean) = ph.safeDpmCall {
+ dpm.setMasterVolumeMuted(dar, muted)
+ ShortcutUtils.setShortcut(application, settingsRepo, MyShortcut.Mute, !muted)
+ optionsState.update { it.copy(masterVolumeMuted = dpm.isMasterVolumeMuted(dar)) }
+ }
+
+ @RequiresApi(26)
+ fun setBackupServiceEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.setBackupServiceEnabled(dar, enabled)
+ optionsState.update {
+ it.copy(backupServiceEnabled = dpm.isBackupServiceEnabled(dar))
+ }
+ }
+
+ fun setBtContactSharingDisabled(disabled: Boolean) = ph.safeDpmCall {
+ dpm.setBluetoothContactSharingDisabled(dar, disabled)
+ optionsState.update {
+ it.copy(btContactSharingDisabled = dpm.getBluetoothContactSharingDisabled(dar))
+ }
+ }
+
+ @RequiresApi(30)
+ fun setCommonCriteriaModeEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.setCommonCriteriaModeEnabled(dar, enabled)
+ optionsState.update {
+ it.copy(commonCriteriaMode = dpm.isCommonCriteriaModeEnabled(dar))
+ }
+ }
+
+ @RequiresApi(31)
+ fun setUsbSignalEnabled(enabled: Boolean) = ph.safeDpmCall {
+ dpm.isUsbDataSignalingEnabled = enabled
+ optionsState.update { it.copy(usbSignalEnabled = dpm.isUsbDataSignalingEnabled) }
+ }
+
+ fun setStayOnWhilePluggedIn(status: Boolean) = ph.safeDpmCall {
+ dpm.setGlobalSetting(
+ dar, Settings.Global.STAY_ON_WHILE_PLUGGED_IN, if (status) "15" else "0"
+ )
+ optionsState.update { it.copy(stayOnWhilePluggedIn = status) }
+ }
+
+ fun getGlobalSettings(): Map {
+ return globalSettings.associate {
+ it.setting to (Settings.Global.getInt(application.contentResolver, it.setting, 0) == 1)
+ }
+ }
+
+ fun setGlobalSetting(name: String, status: Boolean) = ph.safeDpmCall {
+ dpm.setGlobalSetting(dar, name, if (status) "1" else "0")
+ }
+
+ fun getSecureSettings(): Map {
+ return secureSettings.associate {
+ it.setting to (Settings.Secure.getInt(application.contentResolver, it.setting, 0) == 1)
+ }
+ }
+
+ fun setSecureSetting(name: String, status: Boolean) = ph.safeDpmCall {
+ dpm.setSecureSetting(dar, name, if (status) "1" else "0")
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemScreen.kt
new file mode 100644
index 0000000..2d9d34c
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemScreen.kt
@@ -0,0 +1,830 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.app.admin.DevicePolicyManager
+import android.app.admin.DevicePolicyManager.WIPE_EUICC
+import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE
+import android.app.admin.DevicePolicyManager.WIPE_RESET_PROTECTION_DATA
+import android.app.admin.DevicePolicyManager.WIPE_SILENTLY
+import android.content.Context
+import android.os.Build.VERSION
+import android.os.UserManager
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.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.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.CheckBoxItem
+import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.FunctionItem
+import com.bintianqi.owndroid.ui.InfoItem
+import com.bintianqi.owndroid.ui.ListItem
+import com.bintianqi.owndroid.ui.MyLazyScaffold
+import com.bintianqi.owndroid.ui.MyScaffold
+import com.bintianqi.owndroid.ui.MySmallTitleScaffold
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.ui.SwitchItem
+import com.bintianqi.owndroid.ui.navigation.Destination
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.yesOrNo
+import com.google.accompanist.drawablepainter.rememberDrawablePainter
+import kotlinx.coroutines.delay
+
+@Composable
+fun SystemScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ // 1: reboot, 2: bug report, 3: org name, 4: org id, 5: enrollment specific id
+ var dialog by rememberSaveable { mutableIntStateOf(0) }
+ MyScaffold(R.string.system, onNavigateUp, 0.dp) {
+ FunctionItem(R.string.options, icon = R.drawable.tune_fill0) {
+ onNavigate(Destination.SystemOptions)
+ }
+ FunctionItem(R.string.keyguard, icon = R.drawable.screen_lock_portrait_fill0) {
+ onNavigate(Destination.Keyguard)
+ }
+ if (VERSION.SDK_INT >= 24 && privilege.device && !privilege.dhizuku)
+ FunctionItem(R.string.hardware_monitor, icon = R.drawable.memory_fill0) {
+ onNavigate(Destination.HardwareMonitor)
+ }
+ FunctionItem(R.string.default_input_method, icon = R.drawable.keyboard_fill0) {
+ onNavigate(Destination.DefaultInputMethod)
+ }
+ if (VERSION.SDK_INT >= 24 && privilege.device) {
+ FunctionItem(R.string.reboot, icon = R.drawable.restart_alt_fill0) { dialog = 1 }
+ }
+ if (VERSION.SDK_INT >= 24 && privilege.device && (VERSION.SDK_INT < 28 || privilege.affiliated)) {
+ FunctionItem(R.string.bug_report, icon = R.drawable.bug_report_fill0) { dialog = 2 }
+ }
+ if (VERSION.SDK_INT >= 28 && (privilege.device || privilege.org)) {
+ FunctionItem(R.string.time, icon = R.drawable.schedule_fill0) {
+ onNavigate(Destination.Time)
+ }
+ }
+ if (VERSION.SDK_INT >= 35 && (privilege.device || (privilege.profile && privilege.affiliated)))
+ FunctionItem(R.string.content_protection_policy, icon = R.drawable.search_fill0) {
+ onNavigate(Destination.ContentProtectionPolicy)
+ }
+ FunctionItem(R.string.permission_policy, icon = R.drawable.key_fill0) {
+ onNavigate(Destination.PermissionPolicy)
+ }
+ if (VERSION.SDK_INT >= 34 && privilege.device) {
+ FunctionItem(R.string.mte_policy, icon = R.drawable.memory_fill0) {
+ onNavigate(Destination.MtePolicy)
+ }
+ }
+ if (VERSION.SDK_INT >= 31) {
+ FunctionItem(R.string.nearby_streaming_policy, icon = R.drawable.share_fill0) {
+ onNavigate(Destination.NearbyStreamingPolicy)
+ }
+ }
+ if (VERSION.SDK_INT >= 28 && privilege.device) {
+ FunctionItem(R.string.lock_task_mode, icon = R.drawable.lock_fill0) {
+ onNavigate(Destination.LockTaskMode)
+ }
+ }
+ FunctionItem(R.string.ca_cert, icon = R.drawable.license_fill0) {
+ onNavigate(Destination.CaCert)
+ }
+ if (VERSION.SDK_INT >= 26 && !privilege.dhizuku && (privilege.device || privilege.org)) {
+ FunctionItem(R.string.security_logging, icon = R.drawable.description_fill0) {
+ onNavigate(Destination.SecurityLogging)
+ }
+ }
+ FunctionItem(R.string.device_info, icon = R.drawable.perm_device_information_fill0) {
+ onNavigate(Destination.DeviceInfo)
+ }
+ if (VERSION.SDK_INT >= 24 && (privilege.profile || (VERSION.SDK_INT >= 26 && privilege.device))) {
+ FunctionItem(R.string.org_name, icon = R.drawable.corporate_fare_fill0) { dialog = 3 }
+ }
+ if (VERSION.SDK_INT >= 31) {
+ FunctionItem(R.string.org_id, icon = R.drawable.corporate_fare_fill0) { dialog = 4 }
+ }
+ if (VERSION.SDK_INT >= 31) {
+ FunctionItem(
+ R.string.enrollment_specific_id, icon = R.drawable.id_card_fill0
+ ) { dialog = 5 }
+ }
+ if (VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) {
+ FunctionItem(R.string.lock_screen_info, icon = R.drawable.screen_lock_portrait_fill0) {
+ onNavigate(Destination.LockScreenInfo)
+ }
+ }
+ if (VERSION.SDK_INT >= 24) {
+ FunctionItem(R.string.support_messages, icon = R.drawable.chat_fill0) {
+ onNavigate(Destination.SupportMessage)
+ }
+ }
+ FunctionItem(R.string.disable_account_management, icon = R.drawable.account_circle_fill0) {
+ onNavigate(Destination.DisableAccountManagement)
+ }
+ if (privilege.device || privilege.org) {
+ FunctionItem(R.string.system_update_policy, icon = R.drawable.system_update_fill0) {
+ onNavigate(Destination.SystemUpdatePolicy)
+ }
+ }
+ if (VERSION.SDK_INT >= 29 && (privilege.device || privilege.org)) {
+ FunctionItem(R.string.install_system_update, icon = R.drawable.system_update_fill0) {
+ onNavigate(Destination.InstallSystemUpdate)
+ }
+ }
+ if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) {
+ FunctionItem(R.string.frp_policy, icon = R.drawable.device_reset_fill0) {
+ onNavigate(Destination.FrpPolicy)
+ }
+ }
+ if (vm.getDisplayDangerousFeatures() && !privilege.work) {
+ FunctionItem(R.string.wipe_data, icon = R.drawable.device_reset_fill0) {
+ onNavigate(Destination.WipeData)
+ }
+ }
+ }
+ if ((dialog == 1 || dialog == 2) && VERSION.SDK_INT >= 24) AlertDialog(
+ onDismissRequest = { dialog = 0 },
+ title = {
+ Text(stringResource(if (dialog == 1) R.string.reboot else R.string.bug_report))
+ },
+ text = {
+ Text(
+ stringResource(
+ if (dialog == 1) R.string.info_reboot else R.string.confirm_bug_report
+ )
+ )
+ },
+ dismissButton = {
+ TextButton(onClick = { dialog = 0 }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (dialog == 1) {
+ vm.reboot()
+ } else {
+ vm.requestBugReport()
+ }
+ dialog = 0
+ }
+ ) {
+ Text(stringResource(R.string.confirm))
+ }
+ }
+ )
+ if (dialog in 3..5) {
+ val input by when (dialog) {
+ 3 -> vm.orgNameState.collectAsState()
+ 4 -> vm.orgIdState.collectAsState()
+ else -> vm.enrollmentSpecificIdState.collectAsState()
+ }
+ AlertDialog(
+ text = {
+ LaunchedEffect(Unit) {
+ if (dialog == 5 && VERSION.SDK_INT >= 31) vm.getEnrollmentSpecificId()
+ if (dialog == 3 && VERSION.SDK_INT >= 24) vm.getOrgName()
+ }
+ Column {
+ OutlinedTextField(
+ input, {
+ when (dialog) {
+ 3 -> vm.setOrgName(it)
+ 4 -> vm.setOrgId(it)
+ }
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = if (dialog != 3) 8.dp else 0.dp),
+ readOnly = dialog == 5,
+ label = {
+ Text(
+ stringResource(
+ when (dialog) {
+ 3 -> R.string.org_name
+ 4 -> R.string.org_id
+ else -> R.string.enrollment_specific_id
+ }
+ )
+ )
+ },
+ supportingText = {
+ if (dialog == 4) Text(stringResource(R.string.length_6_to_64))
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ textStyle = typography.bodyLarge
+ )
+ if (dialog == 5) Text(stringResource(R.string.info_enrollment_specific_id))
+ if (dialog == 4) Text(stringResource(R.string.info_org_id))
+ }
+ },
+ onDismissRequest = { dialog = 0 },
+ dismissButton = {
+ if (dialog != 5) TextButton({ dialog = 0 }) {
+ Text(
+ stringResource(R.string.cancel)
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ {
+ if (dialog == 3 && VERSION.SDK_INT >= 24) vm.applyOrgName()
+ if (dialog == 4 && VERSION.SDK_INT >= 31) vm.applyOrgId()
+ dialog = 0
+ },
+ enabled = dialog != 4 || input.length in 6..64
+ ) {
+ Text(stringResource(R.string.confirm))
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun KeyguardScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ MyScaffold(R.string.keyguard, onNavigateUp) {
+ if (privilege.device ||
+ (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated)
+ ) {
+ Row(
+ Modifier.fillMaxWidth(), Arrangement.SpaceBetween,
+ ) {
+ Button(
+ onClick = { vm.setKeyguardDisabled(true) },
+ modifier = Modifier.fillMaxWidth(0.49F)
+ ) {
+ Text(stringResource(R.string.disable))
+ }
+ Button(
+ { vm.setKeyguardDisabled(false) },
+ Modifier.fillMaxWidth(0.96F)
+ ) {
+ Text(stringResource(R.string.enable))
+ }
+ }
+ Notes(R.string.info_disable_keyguard)
+ Spacer(Modifier.padding(vertical = 12.dp))
+ }
+ Text(text = stringResource(R.string.lock_now), style = typography.titleLarge)
+ Spacer(Modifier.padding(vertical = 2.dp))
+ var evictKey by rememberSaveable { mutableStateOf(false) }
+ if (VERSION.SDK_INT >= 26 && privilege.work) {
+ CheckBoxItem(R.string.evict_credential_encryption_key, evictKey) { evictKey = true }
+ Spacer(Modifier.height(5.dp))
+ Notes(R.string.info_evict_credential_encryption_key)
+ }
+ Button(
+ { vm.lockScreen(evictKey) },
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.lock_now))
+ }
+ }
+}
+
+@Composable
+fun DefaultInputMethodScreen(
+ vm: SystemViewModel, navigateUp: () -> Unit
+) {
+ val imList by vm.inputMethodList.collectAsStateWithLifecycle()
+ val selectedIm by vm.defaultInputMethodState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getInputMethods()
+ vm.getDefaultInputMethod()
+ }
+ MyLazyScaffold(R.string.default_input_method, navigateUp) {
+ items(imList) { (id, info) ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable { vm.setDefaultInputMethod(id) }
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(selectedIm == id, { vm.setDefaultInputMethod(id) })
+ Image(rememberDrawablePainter(info.icon), null, Modifier.size(40.dp))
+ Column(Modifier.padding(start = 8.dp)) {
+ Text(info.label)
+ Text(id, Modifier.alpha(0.7F), style = typography.bodyMedium)
+ }
+ }
+ }
+ item {
+ Spacer(Modifier.height(BottomPadding))
+ }
+ }
+}
+
+@RequiresApi(35)
+@Composable
+fun ContentProtectionPolicyScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val policy by vm.contentProtectionPolicyState.collectAsState()
+ MyScaffold(R.string.content_protection_policy, onNavigateUp, 0.dp) {
+ mapOf(
+ 0 to R.string.not_controlled_by_policy,
+ 1 to R.string.disabled,
+ 2 to R.string.enabled
+ ).forEach { (id, string) ->
+ FullWidthRadioButtonItem(string, policy == id) { vm.setContentProtectionPolicy(id) }
+ }
+ Notes(R.string.info_content_protection_policy, HorizontalPadding)
+ }
+}
+
+@Composable
+fun PermissionPolicyScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val policy by vm.permissionPolicyState.collectAsState()
+ MyScaffold(R.string.permission_policy, onNavigateUp, 0.dp) {
+ listOf(
+ 0 to R.string.default_stringres,
+ 1 to R.string.auto_grant,
+ 2 to R.string.auto_deny
+ ).forEach {
+ FullWidthRadioButtonItem(it.second, policy == it.first) {
+ vm.setPermissionPolicy(it.first)
+ }
+ }
+ Notes(R.string.info_permission_policy, HorizontalPadding)
+ }
+}
+
+@RequiresApi(34)
+@Composable
+fun MtePolicyScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val policy by vm.mtePolicyState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getMtePolicy()
+ }
+ MyScaffold(R.string.mte_policy, onNavigateUp, 0.dp) {
+ listOf(
+ 0 to R.string.default_stringres,
+ 1 to R.string.enabled,
+ 2 to R.string.disabled
+ ).forEach {
+ FullWidthRadioButtonItem(it.second, policy == it.first) { vm.setMtePolicy(it.first) }
+ }
+ Notes(R.string.info_mte_policy, HorizontalPadding)
+ }
+}
+
+@RequiresApi(31)
+@Composable
+fun NearbyStreamingPolicyScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val appPolicy by vm.nsAppPolicyState.collectAsState()
+ val notificationPolicy by vm.nsNotificationPolicyState.collectAsState()
+ MySmallTitleScaffold(R.string.nearby_streaming_policy, onNavigateUp, 0.dp) {
+ Text(
+ stringResource(R.string.nearby_app_streaming),
+ Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp),
+ style = typography.titleLarge
+ )
+ NearbyStreamingPolicyScreenContent(appPolicy, vm::setNsAppPolicy)
+ Notes(R.string.info_nearby_app_streaming_policy, HorizontalPadding)
+ Spacer(Modifier.height(20.dp))
+ Text(
+ stringResource(R.string.nearby_notification_streaming),
+ Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp),
+ style = typography.titleLarge
+ )
+ NearbyStreamingPolicyScreenContent(notificationPolicy, vm::setNsNotificationPolicy)
+ Notes(R.string.info_nearby_notification_streaming_policy, HorizontalPadding)
+ Spacer(Modifier.height(BottomPadding))
+ }
+}
+
+@Composable
+private fun NearbyStreamingPolicyScreenContent(policy: Int, setPolicy: (Int) -> Unit) {
+ listOf(
+ 0 to R.string.default_str,
+ 1 to R.string.disabled,
+ 2 to R.string.enabled,
+ 3 to R.string.enable_if_same_account
+ ).forEach {
+ FullWidthRadioButtonItem(it.second, policy == it.first) { setPolicy(it.first) }
+ }
+}
+
+@Composable
+fun DeviceInfoScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ val info by vm.deviceInfoState.collectAsState()
+ var dialog by rememberSaveable { mutableIntStateOf(0) }
+ LaunchedEffect(Unit) {
+ vm.getDeviceInfo()
+ }
+ MyScaffold(R.string.device_info, onNavigateUp, 0.dp) {
+ if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.org)) {
+ InfoItem(R.string.financed_device, info.financed.yesOrNo)
+ }
+ if (VERSION.SDK_INT >= 33) {
+ InfoItem(R.string.dpmrh, info.dpmrh ?: stringResource(R.string.none))
+ }
+ val encryptionStatus = when (info.storageEncryptionStatus) {
+ DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE -> R.string.es_inactive
+ DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE -> R.string.es_active
+ DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED -> R.string.es_unsupported
+ DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY -> R.string.es_active_default_key
+ DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER -> R.string.es_active_per_user
+ else -> R.string.unknown
+ }
+ InfoItem(R.string.encryption_status, encryptionStatus)
+ if (VERSION.SDK_INT >= 28) {
+ InfoItem(
+ R.string.support_device_id_attestation, info.deviceIdAttestationSupported.yesOrNo,
+ true
+ ) { dialog = 1 }
+ }
+ if (VERSION.SDK_INT >= 30) {
+ InfoItem(
+ R.string.support_unique_device_attestation,
+ info.uniqueDeviceAttestationSupported.yesOrNo, true
+ ) { dialog = 2 }
+ }
+ InfoItem(R.string.activated_device_admin, info.activeAdmins.joinToString("\n"))
+ }
+ if (dialog != 0) AlertDialog(
+ text = {
+ Text(
+ stringResource(
+ if (dialog == 1) R.string.info_device_id_attestation
+ else R.string.info_unique_device_attestation
+ )
+ )
+ },
+ confirmButton = {
+ TextButton({ dialog = 0 }) { Text(stringResource(R.string.confirm)) }
+ },
+ onDismissRequest = { dialog = 0 }
+ )
+}
+
+@RequiresApi(24)
+@Composable
+fun SupportMessageScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val shortMsg by vm.shortSupportMessageState.collectAsState()
+ val longMsg by vm.longSupportMessageState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getSupportMessages()
+ }
+ MyScaffold(R.string.support_messages, onNavigateUp) {
+ OutlinedTextField(
+ shortMsg, vm::setShortSupportMessage,
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 2.dp),
+ label = { Text(stringResource(R.string.short_support_msg)) },
+ minLines = 2
+ )
+ Notes(R.string.info_short_support_message)
+ Spacer(Modifier.padding(vertical = 8.dp))
+ OutlinedTextField(
+ longMsg, vm::setLongSupportMessage,
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 2.dp),
+ label = { Text(stringResource(R.string.long_support_msg)) },
+ minLines = 3
+ )
+ Notes(R.string.info_long_support_message)
+ Spacer(Modifier.padding(vertical = 8.dp))
+ Button(
+ vm::applySupportMessages,
+ Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ }
+}
+
+@Composable
+fun DisableAccountManagementScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val list by vm.mdAccountTypes.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) { vm.getMdAccountTypes() }
+ MyScaffold(R.string.disable_account_management, onNavigateUp) {
+ Column(Modifier.animateContentSize()) {
+ for (i in list) {
+ ListItem(i) {
+ vm.setMdAccountType(i, false)
+ }
+ }
+ }
+ var inputText by remember { mutableStateOf("") }
+ OutlinedTextField(
+ inputText, { inputText = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ label = { Text(stringResource(R.string.account_type)) },
+ trailingIcon = {
+ IconButton(
+ {
+ vm.setMdAccountType(inputText, true)
+ inputText = ""
+ },
+ enabled = inputText != ""
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.add)
+ )
+ }
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ Spacer(Modifier.padding(vertical = 10.dp))
+ Notes(R.string.info_disable_account_management)
+ }
+}
+
+@RequiresApi(30)
+@Composable
+fun FrpPolicyScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val policy by vm.frpPolicyState.collectAsState()
+ var inputAccount by rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ vm.getFrpPolicy()
+ }
+ MyScaffold(R.string.frp_policy, onNavigateUp, 0.dp) {
+ if (!policy.supported) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 8.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(colorScheme.primaryContainer)
+ ) {
+ Text(
+ stringResource(R.string.frp_not_supported), Modifier.padding(8.dp),
+ color = colorScheme.onPrimaryContainer
+ )
+ }
+ } else {
+ SwitchItem(
+ R.string.use_policy, policy.usePolicy,
+ { vm.setFrpPolicy(policy.copy(usePolicy = it)) }
+ )
+ }
+ if (policy.usePolicy) {
+ FullWidthCheckBoxItem(R.string.enable_frp, policy.enabled) {
+ vm.setFrpPolicy(policy.copy(enabled = it))
+ }
+ Column(Modifier.padding(horizontal = HorizontalPadding)) {
+ Text(stringResource(R.string.account_list_is))
+ Column(Modifier.animateContentSize()) {
+ if (policy.accounts.isEmpty()) Text(stringResource(R.string.none))
+ for (i in policy.accounts) {
+ ListItem(i) {
+ }
+ }
+ }
+ OutlinedTextField(
+ inputAccount, { inputAccount = it },
+ Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.account)) },
+ trailingIcon = {
+ IconButton(
+ {
+ vm.setFrpPolicy(
+ policy.copy(accounts = policy.accounts + inputAccount)
+ )
+ inputAccount = ""
+ },
+ enabled = inputAccount.isNotBlank()
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.add)
+ )
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
+ )
+ )
+ Button(
+ vm::applyFrpPolicy,
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ }
+ }
+ Notes(R.string.info_frp_policy, HorizontalPadding)
+ }
+}
+
+@Composable
+fun WipeDataScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val context = LocalContext.current
+ val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
+ val focusMgr = LocalFocusManager.current
+ var flag by rememberSaveable { mutableIntStateOf(0) }
+ var dialog by rememberSaveable { mutableIntStateOf(0) } // 0: none, 1: wipe data, 2: wipe device
+ var reason by rememberSaveable { mutableStateOf("") }
+ MyScaffold(R.string.wipe_data, onNavigateUp, 0.dp) {
+ FullWidthCheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) {
+ flag = flag xor WIPE_EXTERNAL_STORAGE
+ }
+ if (privilege.device) FullWidthCheckBoxItem(
+ R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0
+ ) {
+ flag = flag xor WIPE_RESET_PROTECTION_DATA
+ }
+ if (VERSION.SDK_INT >= 28) FullWidthCheckBoxItem(
+ R.string.wipe_euicc,
+ flag and WIPE_EUICC != 0
+ ) {
+ flag = flag xor WIPE_EUICC
+ }
+ if (VERSION.SDK_INT < 34 || !userManager.isSystemUser) {
+ if (VERSION.SDK_INT >= 29) CheckBoxItem(
+ R.string.wipe_silently, flag and WIPE_SILENTLY != 0
+ ) {
+ flag = flag xor WIPE_SILENTLY
+ reason = ""
+ }
+ AnimatedVisibility(flag and WIPE_SILENTLY != 0 && VERSION.SDK_INT >= 28) {
+ OutlinedTextField(
+ reason, { reason = it },
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ label = { Text(stringResource(R.string.reason)) }
+ )
+ }
+ Button(
+ {
+ focusMgr.clearFocus()
+ dialog = 1
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 5.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorScheme.error, contentColor = colorScheme.onError
+ )
+ ) {
+ Text("WipeData")
+ }
+ }
+ if (VERSION.SDK_INT >= 34 && privilege.device) {
+ Button(
+ {
+ focusMgr.clearFocus()
+ dialog = 2
+ },
+ Modifier
+ .fillMaxWidth()
+ .padding(HorizontalPadding, 5.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorScheme.error, contentColor = colorScheme.onError
+ )
+ ) {
+ Text("WipeDevice")
+ }
+ }
+ }
+ if (dialog != 0) {
+ AlertDialog(
+ title = {
+ Text(stringResource(R.string.warning), color = colorScheme.error)
+ },
+ text = {
+ Text(
+ stringResource(
+ if (userManager.isSystemUser) R.string.wipe_data_warning
+ else R.string.info_wipe_data_in_managed_user
+ ),
+ color = colorScheme.error
+ )
+ },
+ onDismissRequest = { dialog = 0 },
+ confirmButton = {
+ var timer by remember { mutableIntStateOf(5) }
+ LaunchedEffect(Unit) {
+ while (timer > 0) {
+ delay(1000)
+ timer -= 1
+ }
+ }
+ val timerText = if (timer > 0) "(${timer}s)" else ""
+ TextButton(
+ {
+ vm.wipeData(dialog == 2, flag, reason)
+ },
+ Modifier.animateContentSize(),
+ timer == 0,
+ colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error)
+ ) {
+ Text(stringResource(R.string.confirm) + timerText)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { dialog = 0 }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+}
+
+@RequiresApi(24)
+@Composable
+fun LockScreenInfoScreen(
+ vm: SystemViewModel, onNavigateUp: () -> Unit
+) {
+ val text by vm.lockScreenInfoState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.getLockScreenInfo()
+ }
+ MyScaffold(R.string.lock_screen_info, onNavigateUp) {
+ OutlinedTextField(
+ text, vm::setLockScreenInfo,
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ label = { Text(stringResource(R.string.lock_screen_info)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ Button(
+ vm::applyLockScreenInfo,
+ Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.apply))
+ }
+ Spacer(Modifier.padding(vertical = 10.dp))
+ Notes(R.string.info_lock_screen_info)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateModel.kt
new file mode 100644
index 0000000..7a2a573
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateModel.kt
@@ -0,0 +1,23 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.app.admin.SystemUpdatePolicy
+import com.bintianqi.owndroid.R
+
+data class SystemUpdatePolicyUiState(
+ val type: SystemUpdatePolicyType = SystemUpdatePolicyType.None,
+ val start: String = "",
+ val end: String = ""
+)
+
+enum class SystemUpdatePolicyType(val id: Int, val text: Int) {
+ None(-1, R.string.none),
+ Automatic(SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC, R.string.automatic),
+ Windowed(SystemUpdatePolicy.TYPE_INSTALL_WINDOWED, R.string.system_update_policy_windowed),
+ Postpone(SystemUpdatePolicy.TYPE_POSTPONE, R.string.system_update_policy_postpone)
+}
+
+class PendingSystemUpdateInfo(
+ val exists: Boolean = false,
+ val time: Long = 0,
+ val securityPatch: Boolean = false
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateScreen.kt
new file mode 100644
index 0000000..5b248c7
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateScreen.kt
@@ -0,0 +1,166 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.net.Uri
+import android.os.Build.VERSION
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.ErrorDialog
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.MySmallTitleScaffold
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.formatDate
+import com.bintianqi.owndroid.utils.yesOrNo
+
+@Composable
+fun SystemUpdateScreen(
+ vm: SystemUpdateViewModel, onNavigateUp: () -> Unit
+) {
+ val policy by vm.policyState.collectAsState()
+ val pendingUpdate by vm.pendingUpdateState.collectAsState()
+ var uri by remember { mutableStateOf(null) }
+ var installing by rememberSaveable { mutableStateOf(false) }
+ var errorMessage by rememberSaveable { mutableStateOf(null) }
+ val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
+ uri = it
+ }
+ LaunchedEffect(Unit) {
+ vm.getPolicy()
+ if (VERSION.SDK_INT >= 26) vm.getPendingUpdate()
+ }
+ MySmallTitleScaffold(R.string.system_update_policy, onNavigateUp, 0.dp) {
+ Text(
+ stringResource(R.string.system_update_policy),
+ Modifier.padding(start = HorizontalPadding, top = 8.dp),
+ style = MaterialTheme.typography.titleLarge
+ )
+ SystemUpdatePolicyType.entries.forEach {
+ FullWidthRadioButtonItem(it.text, policy.type == it) {
+ vm.setPolicy(policy.copy(type = it))
+ }
+ }
+ AnimatedVisibility(policy.type == SystemUpdatePolicyType.Windowed) {
+ Column(Modifier.padding(horizontal = HorizontalPadding)) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ Arrangement.SpaceBetween
+ ) {
+ OutlinedTextField(
+ policy.start, { vm.setPolicy(policy.copy(start = it)) },
+ Modifier.fillMaxWidth(0.49F),
+ label = { Text(stringResource(R.string.start_time)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Next
+ )
+ )
+ OutlinedTextField(
+ policy.end, { vm.setPolicy(policy.copy(end = it)) },
+ Modifier.fillMaxWidth(0.96F),
+ label = { Text(stringResource(R.string.end_time)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ )
+ )
+ }
+ Text(
+ stringResource(R.string.minutes_in_one_day),
+ color = colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ Button(
+ vm::applyPolicy,
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp, horizontal = HorizontalPadding),
+ policy.type != SystemUpdatePolicyType.Windowed ||
+ listOf(policy.start, policy.end).map { it.toIntOrNull() }
+ .all { it != null && it <= 1440 }
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ if (VERSION.SDK_INT >= 26) {
+ Column(Modifier.padding(HorizontalPadding)) {
+ if (pendingUpdate.exists) {
+ Text(
+ stringResource(
+ R.string.update_received_time, formatDate(pendingUpdate.time)
+ )
+ )
+ Text(
+ stringResource(
+ R.string.is_security_patch,
+ stringResource(pendingUpdate.securityPatch.yesOrNo)
+ )
+ )
+ } else {
+ Text(text = stringResource(R.string.no_system_update))
+ }
+ }
+ }
+ if (VERSION.SDK_INT >= 29) {
+ Text(
+ stringResource(R.string.install_system_update),
+ Modifier.padding(start = HorizontalPadding),
+ style = MaterialTheme.typography.titleLarge
+ )
+ Spacer(Modifier.height(10.dp))
+ Button(
+ {
+ getFileLauncher.launch("application/zip")
+ },
+ Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)
+ ) {
+ Text(stringResource(R.string.select_ota_package))
+ }
+ Button(
+ {
+ installing = true
+ vm.installUpdate(uri!!) { message ->
+ errorMessage = message
+ }
+ },
+ Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding),
+ uri != null && !installing
+ ) {
+ Text(stringResource(R.string.install_system_update))
+ }
+ Spacer(Modifier.padding(vertical = 10.dp))
+ Notes(R.string.auto_reboot_after_install_succeed, HorizontalPadding)
+ }
+ Spacer(Modifier.height(BottomPadding))
+ }
+ ErrorDialog(errorMessage) { errorMessage = null }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateViewModel.kt
new file mode 100644
index 0000000..11cd3f2
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateViewModel.kt
@@ -0,0 +1,75 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback
+import android.app.admin.SystemUpdateInfo
+import android.app.admin.SystemUpdatePolicy
+import android.net.Uri
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class SystemUpdateViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val toastChannel: ToastChannel
+) : ViewModel() {
+ val policyState = MutableStateFlow(SystemUpdatePolicyUiState())
+
+ fun getPolicy() = ph.safeDpmCall {
+ val policy = dpm.systemUpdatePolicy
+ if (policy != null) {
+ policyState.value = SystemUpdatePolicyUiState(
+ SystemUpdatePolicyType.entries.find { it.id == policy.policyType }!!,
+ policy.installWindowStart.toString(), policy.installWindowEnd.toString()
+ )
+ }
+ }
+
+ fun setPolicy(info: SystemUpdatePolicyUiState) {
+ policyState.value = info
+ }
+
+ fun applyPolicy() = ph.safeDpmCall {
+ val info = policyState.value
+ val policy = when (info.type) {
+ SystemUpdatePolicyType.Automatic -> SystemUpdatePolicy.createAutomaticInstallPolicy()
+ SystemUpdatePolicyType.Windowed ->
+ SystemUpdatePolicy.createWindowedInstallPolicy(info.start.toInt(), info.end.toInt())
+ SystemUpdatePolicyType.Postpone -> SystemUpdatePolicy.createPostponeInstallPolicy()
+ else -> null
+ }
+ dpm.setSystemUpdatePolicy(dar, policy)
+ toastChannel.sendStatus(true)
+ }
+
+ val pendingUpdateState = MutableStateFlow(PendingSystemUpdateInfo())
+
+ @RequiresApi(26)
+ fun getPendingUpdate() = ph.safeDpmCall {
+ val update = dpm.getPendingSystemUpdate(dar)
+ pendingUpdateState.value = PendingSystemUpdateInfo(
+ update != null, update?.receivedTime ?: 0,
+ update?.securityPatchState == SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE
+ )
+ }
+
+ @RequiresApi(29)
+ fun installUpdate(uri: Uri, callback: (String) -> Unit) = ph.safeDpmCall {
+ val callback = object : InstallSystemUpdateCallback() {
+ override fun onInstallUpdateError(errorCode: Int, errorMessage: String) {
+ super.onInstallUpdateError(errorCode, errorMessage)
+ val errDetail = when (errorCode) {
+ UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low
+ UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid
+ UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver
+ UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist
+ else -> R.string.unknown_error
+ }
+ callback(application.getString(errDetail) + "\n$errorMessage")
+ }
+ }
+ dpm.installSystemUpdate(dar, uri, application.mainExecutor, callback)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemViewModel.kt
new file mode 100644
index 0000000..ebc8d8f
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemViewModel.kt
@@ -0,0 +1,315 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.annotation.SuppressLint
+import android.app.admin.DevicePolicyManager
+import android.app.admin.FactoryResetProtectionPolicy
+import android.os.Build.VERSION
+import android.provider.Settings
+import android.view.inputmethod.InputMethodManager
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.MyApplication
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.feature.settings.SettingsRepository
+import com.bintianqi.owndroid.utils.AppInfo
+import com.bintianqi.owndroid.utils.PrivilegeStatus
+import com.bintianqi.owndroid.utils.ToastChannel
+import com.bintianqi.owndroid.utils.getAppInfo
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class SystemViewModel(
+ val application: MyApplication, val ph: PrivilegeHelper, val settingsRepo: SettingsRepository,
+ val privilegeState: StateFlow, val toastChannel: ToastChannel
+) : ViewModel() {
+ fun getDisplayDangerousFeatures() = settingsRepo.data.displayDangerousFeatures
+
+ @RequiresApi(24)
+ fun reboot() = ph.safeDpmCall {
+ dpm.reboot(dar)
+ }
+
+ @RequiresApi(24)
+ fun requestBugReport() = ph.safeDpmCall {
+ val result = try {
+ dpm.requestBugreport(dar)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ toastChannel.sendStatus(result)
+ }
+
+ val orgNameState = MutableStateFlow("")
+
+ @SuppressLint("PrivateApi")
+ @RequiresApi(24)
+ fun getOrgName() = ph.safeDpmCall {
+ orgNameState.value = try {
+ dpm.getOrganizationName(dar)?.toString() ?: ""
+ } catch (_: Exception) {
+ try {
+ val method = DevicePolicyManager::class.java.getDeclaredMethod(
+ "getDeviceOwnerOrganizationName"
+ )
+ method.isAccessible = true
+ (method.invoke(dpm) as CharSequence).toString()
+ } catch (_: Exception) {
+ ""
+ }
+ }
+ }
+
+ fun setOrgName(name: String) {
+ orgNameState.value = name
+ }
+
+ @RequiresApi(24)
+ fun applyOrgName() = ph.safeDpmCall {
+ dpm.setOrganizationName(dar, orgNameState.value)
+ }
+
+ val orgIdState = MutableStateFlow("")
+
+ fun setOrgId(id: String) {
+ orgIdState.value = id
+ }
+
+ @RequiresApi(31)
+ fun applyOrgId() = ph.safeDpmCall {
+ val result = try {
+ dpm.setOrganizationId(orgIdState.value)
+ true
+ } catch (_: IllegalStateException) {
+ false
+ }
+ toastChannel.sendStatus(result)
+ }
+
+ val enrollmentSpecificIdState = MutableStateFlow("")
+
+ @RequiresApi(31)
+ fun getEnrollmentSpecificId() = ph.safeDpmCall {
+ enrollmentSpecificIdState.value =
+ dpm.enrollmentSpecificId.ifEmpty { application.getString(R.string.none) }
+ }
+
+ val lockScreenInfoState = MutableStateFlow("")
+
+ @RequiresApi(24)
+ fun getLockScreenInfo() = ph.safeDpmCall {
+ lockScreenInfoState.value = dpm.deviceOwnerLockScreenInfo?.toString() ?: ""
+ }
+
+ fun setLockScreenInfo(text: String) {
+ lockScreenInfoState.value = text
+ }
+
+ @RequiresApi(24)
+ fun applyLockScreenInfo() = ph.safeDpmCall {
+ dpm.setDeviceOwnerLockScreenInfo(dar, lockScreenInfoState.value)
+ toastChannel.sendStatus(true)
+ }
+
+ fun setKeyguardDisabled(disabled: Boolean) = ph.safeDpmCall {
+ val result = dpm.setKeyguardDisabled(dar, disabled)
+ toastChannel.sendStatus(result)
+ }
+
+ fun lockScreen(evictKey: Boolean) = ph.safeDpmCall {
+ if (VERSION.SDK_INT >= 26 && privilegeState.value.work) {
+ dpm.lockNow(
+ if (evictKey) DevicePolicyManager.FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY else 0
+ )
+ } else {
+ dpm.lockNow()
+ }
+ }
+
+ val defaultInputMethodState = MutableStateFlow("")
+
+ val inputMethodList = MutableStateFlow(listOf>())
+
+ fun getDefaultInputMethod() = ph.safeDpmCall {
+ defaultInputMethodState.value = Settings.Secure.getString(
+ application.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD
+ )
+ }
+
+ fun getInputMethods() = ph.safeDpmCall {
+ val imm = application.getSystemService(InputMethodManager::class.java)
+ val pm = application.packageManager
+ inputMethodList.value = imm.inputMethodList.map {
+ it.id to getAppInfo(pm, it.packageName)
+ }
+ }
+
+ fun setDefaultInputMethod(id: String) = ph.safeDpmCall {
+ dpm.setSecureSetting(
+ dar, Settings.Secure.DEFAULT_INPUT_METHOD, id
+ )
+ getDefaultInputMethod()
+ }
+
+ val contentProtectionPolicyState = MutableStateFlow(0)
+
+ @RequiresApi(35)
+ fun getContentProtectionPolicy() = ph.safeDpmCall {
+ contentProtectionPolicyState.value = dpm.getContentProtectionPolicy(dar)
+ }
+
+ @RequiresApi(35)
+ fun setContentProtectionPolicy(policy: Int) = ph.safeDpmCall {
+ dpm.setContentProtectionPolicy(dar, policy)
+ getContentProtectionPolicy()
+ }
+
+ val permissionPolicyState = MutableStateFlow(0)
+
+ fun getPermissionPolicy() = ph.safeDpmCall {
+ permissionPolicyState.value = dpm.getPermissionPolicy(dar)
+ }
+
+ fun setPermissionPolicy(policy: Int) = ph.safeDpmCall {
+ dpm.setPermissionPolicy(dar, policy)
+ getPermissionPolicy()
+ }
+
+ val mtePolicyState = MutableStateFlow(0)
+
+ @RequiresApi(34)
+ fun getMtePolicy() = ph.safeDpmCall {
+ mtePolicyState.value = dpm.mtePolicy
+ }
+
+ @RequiresApi(34)
+ fun setMtePolicy(policy: Int) = ph.safeDpmCall {
+ try {
+ dpm.mtePolicy = policy
+ mtePolicyState.value = policy
+ } catch (_: UnsupportedOperationException) {
+ toastChannel.sendText(R.string.unsupported)
+ }
+ }
+
+ // Nearby streaming
+ val nsAppPolicyState = MutableStateFlow(0)
+
+ @RequiresApi(31)
+ fun getNsAppPolicy() = ph.safeDpmCall {
+ nsAppPolicyState.value = dpm.nearbyAppStreamingPolicy
+ }
+
+ @RequiresApi(31)
+ fun setNsAppPolicy(policy: Int) = ph.safeDpmCall {
+ dpm.nearbyAppStreamingPolicy = policy
+ getNsAppPolicy()
+ }
+
+ val nsNotificationPolicyState = MutableStateFlow(0)
+
+ @RequiresApi(31)
+ fun getNsNotificationPolicy() = ph.safeDpmCall {
+ nsNotificationPolicyState.value = dpm.nearbyNotificationStreamingPolicy
+ }
+
+ @RequiresApi(31)
+ fun setNsNotificationPolicy(policy: Int) = ph.safeDpmCall {
+ dpm.nearbyNotificationStreamingPolicy = policy
+ getNsNotificationPolicy()
+ }
+
+ // Management disabled account
+ val mdAccountTypes = MutableStateFlow(emptyList())
+
+ fun getMdAccountTypes() = ph.safeDpmCall {
+ mdAccountTypes.value = dpm.accountTypesWithManagementDisabled?.toList() ?: emptyList()
+ }
+
+ fun setMdAccountType(type: String, disabled: Boolean) = ph.safeDpmCall {
+ dpm.setAccountManagementDisabled(dar, type, disabled)
+ getMdAccountTypes()
+ }
+
+ val frpPolicyState = MutableStateFlow(FrpPolicyInfo())
+
+ @RequiresApi(30)
+ fun getFrpPolicy() = ph.safeDpmCall {
+ try {
+ val policy = dpm.getFactoryResetProtectionPolicy(dar)
+ frpPolicyState.value = FrpPolicyInfo(
+ true, policy != null, policy?.isFactoryResetProtectionEnabled ?: false,
+ policy?.factoryResetProtectionAccounts ?: emptyList()
+ )
+ } catch (_: UnsupportedOperationException) {
+ }
+ }
+
+ fun setFrpPolicy(info: FrpPolicyInfo) {
+ frpPolicyState.value = info
+ }
+
+ @RequiresApi(30)
+ fun applyFrpPolicy() = ph.safeDpmCall {
+ val info = frpPolicyState.value
+ val policy = if (info.usePolicy) {
+ FactoryResetProtectionPolicy.Builder()
+ .setFactoryResetProtectionEnabled(info.enabled)
+ .setFactoryResetProtectionAccounts(info.accounts)
+ .build()
+ } else null
+ dpm.setFactoryResetProtectionPolicy(dar, policy)
+ toastChannel.sendStatus(true)
+ }
+
+ fun wipeData(wipeDevice: Boolean, flags: Int, reason: String) = ph.safeDpmCall {
+ if (wipeDevice && VERSION.SDK_INT >= 34) {
+ dpm.wipeDevice(flags)
+ } else {
+ if (VERSION.SDK_INT >= 28 && reason.isNotEmpty()) {
+ dpm.wipeData(flags, reason)
+ } else {
+ dpm.wipeData(flags)
+ }
+ }
+ }
+
+ val deviceInfoState = MutableStateFlow(DeviceInfo())
+
+ fun getDeviceInfo() = ph.safeDpmCall {
+ val ps = privilegeState.value
+ deviceInfoState.value = DeviceInfo(
+ if (VERSION.SDK_INT >= 34 && (ps.device || ps.org)) dpm.isDeviceFinanced else false,
+ if (VERSION.SDK_INT >= 33) dpm.devicePolicyManagementRoleHolderPackage else "",
+ dpm.storageEncryptionStatus,
+ if (VERSION.SDK_INT >= 28) dpm.isDeviceIdAttestationSupported else false,
+ if (VERSION.SDK_INT >= 30) dpm.isUniqueDeviceAttestationSupported else false,
+ dpm.activeAdmins?.map { it.flattenToShortString() } ?: emptyList()
+ )
+ }
+
+ val shortSupportMessageState = MutableStateFlow("")
+ val longSupportMessageState = MutableStateFlow("")
+
+ @RequiresApi(24)
+ fun getSupportMessages() = ph.safeDpmCall {
+ shortSupportMessageState.value = dpm.getShortSupportMessage(dar)?.toString() ?: ""
+ longSupportMessageState.value = dpm.getLongSupportMessage(dar)?.toString() ?: ""
+ }
+
+ fun setShortSupportMessage(text: String) {
+ shortSupportMessageState.value = text
+ }
+
+ fun setLongSupportMessage(text: String) {
+ longSupportMessageState.value = text
+ }
+
+ @RequiresApi(24)
+ fun applySupportMessages() = ph.safeDpmCall {
+ dpm.setShortSupportMessage(dar, shortSupportMessageState.value.ifEmpty { null })
+ dpm.setLongSupportMessage(dar, longSupportMessageState.value.ifEmpty { null })
+ toastChannel.sendStatus(true)
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeScreen.kt
new file mode 100644
index 0000000..5941733
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeScreen.kt
@@ -0,0 +1,333 @@
+package com.bintianqi.owndroid.feature.system
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.List
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SecondaryTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TimePicker
+import androidx.compose.material3.TimePickerDialog
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.material3.rememberTimePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+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.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.bintianqi.owndroid.R
+import com.bintianqi.owndroid.ui.CheckBoxItem
+import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
+import com.bintianqi.owndroid.ui.NavIcon
+import com.bintianqi.owndroid.ui.Notes
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
+import com.bintianqi.owndroid.utils.clickableTextField
+import com.bintianqi.owndroid.utils.formatDate
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import java.util.TimeZone
+
+@OptIn(ExperimentalMaterial3Api::class)
+@RequiresApi(28)
+@Composable
+fun TimeScreen(vm: TimeViewModel, onNavigateUp: () -> Unit) {
+ val pagerState = rememberPagerState { if (Build.VERSION.SDK_INT >= 36) 2 else 1 }
+ val tab = pagerState.currentPage
+ val coroutine = rememberCoroutineScope()
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ { Text(stringResource(R.string.time)) },
+ navigationIcon = { NavIcon(onNavigateUp) }
+ )
+ },
+ contentWindowInsets = adaptiveInsets()
+ ) { paddingValues ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ if (Build.VERSION.SDK_INT >= 36) PrimaryTabRow(tab) {
+ Tab(
+ tab == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } },
+ text = { Text(stringResource(R.string.change_time)) }
+ )
+ Tab(
+ tab == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } },
+ text = { Text(stringResource(R.string.auto_time_policy)) }
+ )
+ }
+ HorizontalPager(
+ pagerState, Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Top
+ ) { page ->
+ if (page == 0) {
+ ChangeTimeScreen(vm)
+ } else if (Build.VERSION.SDK_INT >= 36) {
+ AutoTimePolicyScreen(vm)
+ }
+ }
+ }
+ }
+}
+
+@RequiresApi(28)
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ChangeTimeScreen(vm: TimeViewModel) {
+ val pagerState = rememberPagerState { 2 }
+ val coroutine = rememberCoroutineScope()
+ val tab = pagerState.targetPage
+ Column(Modifier.fillMaxSize()) {
+ SecondaryTabRow(tab) {
+ Tab(
+ tab == 0,
+ {
+ coroutine.launch {
+ pagerState.animateScrollToPage(0)
+ }
+ },
+ text = { Text(stringResource(R.string.time)) }
+ )
+ Tab(
+ tab == 1,
+ {
+ coroutine.launch {
+ pagerState.animateScrollToPage(1)
+ }
+ },
+ text = { Text(stringResource(R.string.timezone)) }
+ )
+ }
+ HorizontalPager(pagerState) { page ->
+ if (page == 0) {
+ ChangeTimeScreenContent(vm::setTime)
+ } else {
+ ChangeTimeZoneScreenContent(vm::setTimeZone)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ChangeTimeScreenContent(setTime: (Long, Boolean) -> Unit) {
+ var picker by rememberSaveable { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker
+ var useCurrentTz by rememberSaveable { mutableStateOf(true) }
+ val datePickerState = rememberDatePickerState()
+ val timePickerState = rememberTimePickerState(is24Hour = true)
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(top = 8.dp)
+ .padding(horizontal = HorizontalPadding)
+ .verticalScroll(rememberScrollState())
+ ) {
+ OutlinedTextField(
+ datePickerState.selectedDateMillis?.let { formatDate(it) } ?: "", {},
+ Modifier
+ .fillMaxWidth()
+ .clickableTextField { picker = 1 },
+ readOnly = true,
+ label = { Text(stringResource(R.string.date)) }
+ )
+ OutlinedTextField(
+ timePickerState.hour.toString().padStart(2, '0') + ":" +
+ timePickerState.minute.toString().padStart(2, '0'),
+ {},
+ Modifier
+ .fillMaxWidth()
+ .clickableTextField { picker = 2 }
+ .padding(vertical = 4.dp),
+ readOnly = true,
+ label = { Text(stringResource(R.string.time)) }
+ )
+ CheckBoxItem(R.string.use_current_timezone, useCurrentTz) {
+ useCurrentTz = it
+ }
+ Button(
+ {
+ val timeMillis = datePickerState.selectedDateMillis!! +
+ timePickerState.hour * 3600000 + timePickerState.minute * 60000
+ setTime(timeMillis, useCurrentTz)
+ },
+ Modifier.fillMaxWidth(),
+ datePickerState.selectedDateMillis != null
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ Spacer(Modifier.height(BottomPadding))
+ }
+ if (picker == 1) DatePickerDialog(
+ confirmButton = {
+ TextButton({ picker = 0 }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = { picker = 0 }
+ ) {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ DatePicker(datePickerState)
+ }
+ }
+ if (picker == 2) TimePickerDialog(
+ title = {},
+ confirmButton = {
+ TextButton({ picker = 0 }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ onDismissRequest = { picker = 0 }
+ ) {
+ TimePicker(timePickerState)
+ }
+}
+
+@Composable
+private fun ChangeTimeZoneScreenContent(setTimeZone: (String) -> Unit) {
+ var inputTimezone by rememberSaveable { mutableStateOf(TimeZone.getDefault().id) }
+ var dialog by rememberSaveable { mutableStateOf(false) }
+ val availableIds = TimeZone.getAvailableIDs()
+ val validInput = inputTimezone in availableIds
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(top = 8.dp)
+ .padding(horizontal = HorizontalPadding)
+ .verticalScroll(rememberScrollState())
+ ) {
+ OutlinedTextField(
+ inputTimezone, { inputTimezone = it },
+ Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.timezone_id)) },
+ isError = inputTimezone.isNotEmpty() && !validInput,
+ trailingIcon = {
+ IconButton({ dialog = true }) {
+ Icon(Icons.AutoMirrored.Default.List, null)
+ }
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii)
+ )
+ Spacer(Modifier.padding(vertical = 5.dp))
+ Button(
+ {
+ setTimeZone(inputTimezone)
+ },
+ Modifier.fillMaxWidth(),
+ inputTimezone.isNotEmpty() && validInput
+ ) {
+ Text(stringResource(R.string.apply))
+ }
+ Spacer(Modifier.padding(vertical = 10.dp))
+ Notes(R.string.disable_auto_time_zone_before_set)
+ }
+ if (dialog) AlertDialog(
+ text = {
+ LazyColumn {
+ items(availableIds) {
+ Text(
+ it,
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 1.dp)
+ .clickable {
+ inputTimezone = it
+ dialog = false
+ }
+ .padding(start = 6.dp, top = 10.dp, bottom = 10.dp)
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { dialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ onDismissRequest = { dialog = false }
+ )
+}
+
+@RequiresApi(36)
+@Composable
+private fun AutoTimePolicyScreen(vm: TimeViewModel) {
+ LaunchedEffect(Unit) {
+ vm.getAutoTimePolicy()
+ vm.getAutoTimeZonePolicy()
+ }
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ stringResource(R.string.auto_time_policy),
+ Modifier.padding(start = 8.dp, top = 8.dp),
+ style = MaterialTheme.typography.titleLarge
+ )
+ AutoTimePolicyScreenContent(vm.autoTimePolicyState, vm::setAutoTimePolicy)
+ Spacer(Modifier.height(10.dp))
+ Text(
+ stringResource(R.string.auto_timezone_policy),
+ Modifier.padding(start = 8.dp),
+ style = MaterialTheme.typography.titleLarge
+ )
+ AutoTimePolicyScreenContent(vm.autoTimeZonePolicyState, vm::setAutoTimeZonePolicy)
+ }
+}
+
+@Composable
+private fun AutoTimePolicyScreenContent(
+ policyState: StateFlow, setPolicy: (Int) -> Unit
+) {
+ val policy by policyState.collectAsState()
+ listOf(
+ 0 to R.string.not_controlled_by_policy,
+ 1 to R.string.disabled,
+ 2 to R.string.enable
+ ).forEach {
+ FullWidthRadioButtonItem(it.second, it.first == policy) {
+ setPolicy(it.first)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeViewModel.kt
new file mode 100644
index 0000000..b5176df
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeViewModel.kt
@@ -0,0 +1,54 @@
+package com.bintianqi.owndroid.feature.system
+
+import androidx.annotation.RequiresApi
+import androidx.lifecycle.ViewModel
+import com.bintianqi.owndroid.PrivilegeHelper
+import com.bintianqi.owndroid.utils.ToastChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+class TimeViewModel(
+ val ph: PrivilegeHelper, val toastChannel: ToastChannel
+) : ViewModel() {
+ @RequiresApi(28)
+ fun setTime(time: Long, useCurrentTz: Boolean) = ph.safeDpmCall {
+ val offset = if (useCurrentTz) {
+ ZonedDateTime.now(ZoneId.systemDefault()).offset.totalSeconds * 1000L
+ } else 0L
+ val result = dpm.setTime(dar, time - offset)
+ toastChannel.sendStatus(result)
+ }
+
+ @RequiresApi(28)
+ fun setTimeZone(tz: String) = ph.safeDpmCall {
+ val result = dpm.setTimeZone(dar, tz)
+ toastChannel.sendStatus(result)
+ }
+
+ val autoTimePolicyState = MutableStateFlow(0)
+
+ @RequiresApi(36)
+ fun getAutoTimePolicy() = ph.safeDpmCall {
+ autoTimePolicyState.value = dpm.autoTimePolicy
+ }
+
+ @RequiresApi(36)
+ fun setAutoTimePolicy(policy: Int) = ph.safeDpmCall {
+ dpm.autoTimePolicy = policy
+ getAutoTimePolicy()
+ }
+
+ val autoTimeZonePolicyState = MutableStateFlow(0)
+
+ @RequiresApi(36)
+ fun getAutoTimeZonePolicy() = ph.safeDpmCall {
+ autoTimeZonePolicyState.value = dpm.autoTimeZonePolicy
+ }
+
+ @RequiresApi(36)
+ fun setAutoTimeZonePolicy(policy: Int) = ph.safeDpmCall {
+ dpm.autoTimeZonePolicy = policy
+ getAutoTimeZonePolicy()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionModel.kt
new file mode 100644
index 0000000..6316f1a
--- /dev/null
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionModel.kt
@@ -0,0 +1,8 @@
+package com.bintianqi.owndroid.feature.user_restriction
+
+class Restriction(
+ val id: String,
+ val name: Int,
+ val icon: Int,
+ val requiresApi: Int = 0
+)
diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/screen/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionScreen.kt
similarity index 70%
rename from app/src/main/java/com/bintianqi/owndroid/ui/screen/UserRestriction.kt
rename to app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionScreen.kt
index f324210..97075cf 100644
--- a/app/src/main/java/com/bintianqi/owndroid/ui/screen/UserRestriction.kt
+++ b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionScreen.kt
@@ -1,4 +1,4 @@
-package com.bintianqi.owndroid.ui.screen
+package com.bintianqi.owndroid.feature.user_restriction
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
@@ -44,46 +44,32 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.bintianqi.owndroid.BottomPadding
-import com.bintianqi.owndroid.HorizontalPadding
-import com.bintianqi.owndroid.Privilege
import com.bintianqi.owndroid.R
-import com.bintianqi.owndroid.UserRestrictionCategory
-import com.bintianqi.owndroid.UserRestrictionsRepository
-import com.bintianqi.owndroid.adaptiveInsets
-import com.bintianqi.owndroid.popToast
-import com.bintianqi.owndroid.showOperationResultToast
import com.bintianqi.owndroid.ui.FunctionItem
import com.bintianqi.owndroid.ui.MyLazyScaffold
import com.bintianqi.owndroid.ui.NavIcon
import com.bintianqi.owndroid.ui.navigation.Destination
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class Restriction(
- val id: String,
- val name: Int,
- val icon: Int,
- val requiresApi: Int = 0
-)
+import com.bintianqi.owndroid.utils.BottomPadding
+import com.bintianqi.owndroid.utils.HorizontalPadding
+import com.bintianqi.owndroid.utils.adaptiveInsets
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(24)
@Composable
fun UserRestrictionScreen(
- getRestrictions: () -> Unit,onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit
+ vm: UserRestrictionViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit
) {
- val privilege by Privilege.status.collectAsStateWithLifecycle()
+ val privilege by vm.privilegeState.collectAsStateWithLifecycle()
val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
- LaunchedEffect(Unit) { getRestrictions() }
+ LaunchedEffect(Unit) {
+ vm.getRestrictions()
+ }
Scaffold(
Modifier.nestedScroll(sb.nestedScrollConnection),
topBar = {
@@ -101,19 +87,28 @@ fun UserRestrictionScreen(
contentWindowInsets = adaptiveInsets()
) { paddingValues ->
Column(
- modifier = Modifier
+ Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(bottom = 80.dp)
) {
Spacer(Modifier.padding(vertical = 2.dp))
- Text(text = stringResource(R.string.switch_to_disable_feature), modifier = Modifier.padding(start = 16.dp))
+ Text(
+ stringResource(R.string.switch_to_disable_feature),
+ Modifier.padding(start = 16.dp)
+ )
if (privilege.profile && !privilege.work) {
- Text(text = stringResource(R.string.profile_owner_is_restricted), modifier = Modifier.padding(start = 16.dp))
+ Text(
+ stringResource(R.string.profile_owner_is_restricted),
+ Modifier.padding(start = 16.dp)
+ )
}
- if(privilege.work) {
- Text(text = stringResource(R.string.some_features_invalid_in_work_profile), modifier = Modifier.padding(start = 16.dp))
+ if (privilege.work) {
+ Text(
+ stringResource(R.string.some_features_invalid_in_work_profile),
+ Modifier.padding(start = 16.dp)
+ )
}
Spacer(Modifier.padding(vertical = 2.dp))
UserRestrictionCategory.entries.forEach {
@@ -121,17 +116,6 @@ fun UserRestrictionScreen(
onNavigate(Destination.UserRestrictionOptions(it.name))
}
}
- Row(
- Modifier
- .padding(HorizontalPadding, 10.dp)
- .fillMaxWidth()
- .background(colorScheme.primaryContainer, RoundedCornerShape(8.dp))
- .padding(8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(Icons.Outlined.Info, null, Modifier.padding(end = 8.dp), colorScheme.onPrimaryContainer)
- Text(stringResource(R.string.user_restriction_tip), color = colorScheme.onPrimaryContainer)
- }
}
}
}
@@ -139,12 +123,10 @@ fun UserRestrictionScreen(
@RequiresApi(24)
@Composable
fun UserRestrictionOptionsScreen(
- args: Destination.UserRestrictionOptions, userRestrictions: StateFlow