diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a48c02..4e2288c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + ref: 'master' - name: Set up JDK 21 uses: actions/setup-java@v4 diff --git a/Readme-en.md b/Readme-en.md deleted file mode 100644 index 6265fe8..0000000 --- a/Readme-en.md +++ /dev/null @@ -1,139 +0,0 @@ -[日本語](Readme-ja.md) | [简体中文](Readme.md) - -# OwnDroid - -Use Android's DevicePolicyManager API to manage your device. - -## Download - -- [IzzyOnDroid F-Droid Repository](https://apt.izzysoft.de/fdroid/index/apk/com.bintianqi.owndroid) -- [Releases on GitHub](https://github.com/BinTianqi/OwnDroid/releases) - -> [!NOTE] -> ColorOS users should download testkey version from releases on GitHub - -## Features - -- System: disable camera, disable screenshot, master volume mute, disable USB signal, lock task mode, wipe data... -- Network: add/modify/delete Wi-Fi, network stats, network logging... -- Applications: suspend/hide app, block app uninstallation, grant/revoke permissions, clear app storage, install/uninstall app... -- User restriction: disable SMS, disable outgoing call, disable bluetooth, disable NFC, disable USB file transfer, disable app installing/uninstalling... -- Users: user information, create/start/switch/stop/delete user... -- Password and keyguard: reset password, set screen timeout... - -## Working modes - -- Device owner (recommended) - - Activating methods: - - Shizuku - - Dhizuku - - Root - - ADB shell command `dpm set-device-owner com.bintianqi.owndroid/.Receiver` -- [Dhizuku](https://github.com/iamr0s/Dhizuku) -- Work profile - -## FAQ - -### Already some accounts on the device - -```text -java.lang.IllegalStateException: Not allowed to set the device owner because there are already some accounts on the device -``` - -Solutions: -- Freeze apps who hold those accounts. -- Delete these accounts. - -### Already several users on the device - -```text -java.lang.IllegalStateException: Not allowed to set the device owner because there are already several users on the device -``` - -Solutions: -- Delete secondary users. - -> [!NOTE] -> Some systems have features such as app cloning and children space, which are usually users. - -### MIUI & HyperOS - -```text -java.lang.SecurityException: Neither user 2000 nor current process has android.permission.MANAGE_DEVICE_ADMINS. -``` - -Solutions: -- Enable `USB debugging (Security setting)` in developer options. -- Or execute activating command in root shell. - -### ColorOS - -```text -java.lang.IllegalStateException: Unexpected @ProvisioningPreCondition -``` - -Solution: Use OwnDroid testkey version - -### Samsung - -```text -user limit reached -``` - -Samsung restricts Android's multiple users feature. There is currently no solution. - -## API - -| ID | Extras | Minimum Android version | -|--------------------------|------------------------|:-----------------------:| -| `HIDE` | `package` | | -| `UNHIDE` | `package` | | -| `SUSPEND` | `package` | 7 | -| `UNSUSPEND` | `package` | 7 | -| `ADD_USER_RESTRICTION` | `restriction` | | -| `CLEAR_USER_RESTRICTION` | `restriction` | | -| `SET_PERMISSION_DEFAULT` | `package` `permission` | 6 | -| `SET_PERMISSION_GRANTED` | `package` `permission` | 6 | -| `SET_PERMISSION_DENIED` | `package` `permission` | 6 | -| `LOCK` | | | -| `REBOOT` | | 7 | - -[Available user restrictions](https://developer.android.com/reference/android/os/UserManager#constants_1) - -```shell -# An example of hiding app in ADB shell -am broadcast -a com.bintianqi.owndroid.action.HIDE -n com.bintianqi.owndroid/.ApiReceiver --es key abcdefg --es package com.example.app -``` - -```kotlin -// An example of hiding app in Kotlin -val intent = Intent("com.bintianqi.owndroid.action.HIDE") - .setComponent(ComponentName("com.bintianqi.owndroid", "com.bintianqi.owndroid.ApiReceiver")) - .putExtra("key", "abcdefg") - .putExtra("package", "com.example.app") -context.sendBroadcast(intent) -``` - -## Build - -You can use Gradle in command line to build OwnDroid. -```shell -# Use testkey for signing (default) -./gradlew build -# Use your custom .jks key for signing -./gradlew build -PStoreFile="/path/to/your/jks/file" -PStorePassword="YOUR_KEYSTORE_PASSWORD" -PKeyPassword="YOUR_KEY_PASSWORD" -PKeyAlias="YOUR_KEY_ALIAS" -``` -(Use `./gradlew.bat` instead on Windows) - -## License - -[License.md](LICENSE.md) - -> Copyright (C) 2024 BinTianqi -> -> This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. -> -> This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -> -> You should have received a copy of the GNU General Public License along with this program. If not, see . diff --git a/Readme-ja.md b/Readme-ja.md index b72641b..4a8c3d5 100644 --- a/Readme-ja.md +++ b/Readme-ja.md @@ -1,4 +1,4 @@ -[English](Readme-en.md) | [简体中文](Readme.md) +[English](Readme.md) | [简体中文](Readme-zh_CN.md) > [!important] > The Japanese readme need update diff --git a/Readme-zh_CN.md b/Readme-zh_CN.md new file mode 100644 index 0000000..01e7af0 --- /dev/null +++ b/Readme-zh_CN.md @@ -0,0 +1,151 @@ +[English](Readme.md) | [日本語](Readme-ja.md) + +# OwnDroid + +使用安卓的设备策略管理器API管理你的设备。 + +## 下载 + +- [IzzyOnDroid F-Droid Repository](https://apt.izzysoft.de/fdroid/index/apk/com.bintianqi.owndroid) +- [Releases on GitHub](https://github.com/BinTianqi/OwnDroid/releases) + +> [!NOTE] +> ColorOS用户应在GitHub上的releases下载testkey版本 + +## 功能 + +- 系统:禁用摄像头、禁止截屏、全局静音、禁用USB信号、锁定任务模式、清除数据... +- 网络:添加/修改/删除 Wi-Fi、网络统计、网络日志... +- 应用:挂起/隐藏应用、阻止应用卸载、授予/撤销权限、清除应用存储、安装/卸载应用... +- 用户限制:禁止发送短信、禁止拨出电话、禁用蓝牙、禁用NFC、禁用USB文件传输、禁止安装/卸载应用... +- 用户:用户信息、创建/启动/切换/停止/删除用户... +- 密码与锁屏:重置密码、设置屏幕超时... + +## 工作模式 + +- Device owner(推荐) + + 激活方式: + - Shizuku + - Dhizuku + - Root + - ADB shell命令 `dpm set-device-owner com.bintianqi.owndroid/.Receiver` +- [Dhizuku](https://github.com/iamr0s/Dhizuku) +- 工作资料 + +## FAQ + +### 设备上有账号 + +```text +java.lang.IllegalStateException: Not allowed to set the device owner because there are already some accounts on the device +``` + +解决办法: +- 冻结持有这些账号的app。 +- 删除这些账号。 + +### 设备上有多个用户 + +```text +java.lang.IllegalStateException: Not allowed to set the device owner because there are already several users on the device +``` + +解决办法: +- 删除次级用户。 + +> [!NOTE] +> 一些系统有应用克隆、儿童空间等功能,它们通常是用户。 + +#### Device owner 已存在 + +```text +java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.owndroid/.Receiver), but device owner (xxx) is already set. +``` + +一个设备只能存在一个device owner,请先停用已存在的device owner。 + +### MIUI & HyperOS + +```text +java.lang.SecurityException: Neither user 2000 nor current process has android.permission.MANAGE_DEVICE_ADMINS. +``` + +解决办法: 在开发者设置中打开`USB调试(安全设置)`,或在root命令行中执行激活命令。 + +### ColorOS + +```text +java.lang.IllegalStateException: Unexpected @ProvisioningPreCondition +``` + +解决办法:使用 OwnDroid testkey 版本 + +### 三星 + +```text +user limit reached +``` + +三星限制了多用户功能,暂无解决办法。 + +## API + +OwnDroid提供了一个基于Intent的API。你需要在设置中设置密钥并启用API。括号中的数字是最小的安卓版本。 + +- HIDE(package: String) +- UNHIDE(package: String) +- SUSPEND(package: String) (7) +- UNSUSPEND(package: String) (7) +- ADD_USER_RESTRICTION(restriction: Boolean) +- CLEAR_USER_RESTRICTION(restriction: Boolean) +- SET_PERMISSION_DEFAULT(package: String, permission: String) (6) +- SET_PERMISSION_GRANTED(package: String, permission: String) (6) +- SET_PERMISSION_DENIED(package: String, permission: String) (6) +- SET_SCREEN_CAPTURE_DISABLED() +- SET_SCREEN_CAPTURE_ENABLED() +- SET_CAMERA_DISABLED() +- SET_CAMERA_ENABLED() +- SET_USB_DISABLED() (12) +- SET_USB_ENABLED() (12) +- LOCK() +- REBOOT() (7) + +```shell +# 一个在ADB shell中隐藏app的示例 +am broadcast -a com.bintianqi.owndroid.action.HIDE -n com.bintianqi.owndroid/.ApiReceiver --es key abcdefg --es package com.example.app +``` + +```kotlin +// 一个在Kotlin中隐藏app的示例 +val intent = Intent("com.bintianqi.owndroid.action.HIDE") + .setComponent(ComponentName("com.bintianqi.owndroid", "com.bintianqi.owndroid.ApiReceiver")) + .putExtra("key", "abcdefg") + .putExtra("package", "com.example.app") +context.sendBroadcast(intent) +``` + +[可用的用户限制](https://developer.android.google.cn/reference/android/os/UserManager#constants_1) + +## 构建 + +你可以在命令行中使用Gradle以构建OwnDroid +```shell +# 使用testkey签名(默认) +./gradlew build +# 使用你的jks密钥签名 +./gradlew build -PStoreFile="/path/to/your/jks/file" -PStorePassword="YOUR_KEYSTORE_PASSWORD" -PKeyPassword="YOUR_KEY_PASSWORD" -PKeyAlias="YOUR_KEY_ALIAS" +``` +(在Windows系统中应使用`./gradlew.bat`) + +## 许可证 + +[License.md](LICENSE.md) + +> Copyright (C) 2024 BinTianqi +> +> This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +> +> This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +> +> You should have received a copy of the GNU General Public License along with this program. If not, see . diff --git a/Readme.md b/Readme.md index d3309b0..2a2b9a0 100644 --- a/Readme.md +++ b/Readme.md @@ -1,61 +1,69 @@ -[English](Readme-en.md) | [日本語](Readme-ja.md) +[简体中文](Readme-zh_CN.md) | [日本語](Readme-ja.md) # OwnDroid -使用安卓的设备策略管理器API管理你的设备。 +Use Android's DevicePolicyManager API to manage your device. -## 下载 +## Download - [IzzyOnDroid F-Droid Repository](https://apt.izzysoft.de/fdroid/index/apk/com.bintianqi.owndroid) - [Releases on GitHub](https://github.com/BinTianqi/OwnDroid/releases) > [!NOTE] -> ColorOS用户应在GitHub上的releases下载testkey版本 +> ColorOS users should download testkey version from releases on GitHub -## 功能 +## Features -- 系统:禁用摄像头、禁止截屏、全局静音、禁用USB信号、锁定任务模式、清除数据... -- 网络:添加/修改/删除 Wi-Fi、网络统计、网络日志... -- 应用:挂起/隐藏应用、阻止应用卸载、授予/撤销权限、清除应用存储、安装/卸载应用... -- 用户限制:禁止发送短信、禁止拨出电话、禁用蓝牙、禁用NFC、禁用USB文件传输、禁止安装/卸载应用... -- 用户:用户信息、创建/启动/切换/停止/删除用户... -- 密码与锁屏:重置密码、设置屏幕超时... +- System: disable camera, disable screenshot, master volume mute, disable USB signal, lock task mode, wipe data... +- Network: add/modify/delete Wi-Fi, network stats, network logging... +- Applications: suspend/hide app, block app uninstallation, grant/revoke permissions, clear app storage, install/uninstall app... +- User restriction: disable SMS, disable outgoing call, disable bluetooth, disable NFC, disable USB file transfer, disable app installing/uninstalling... +- Users: user information, create/start/switch/stop/delete user... +- Password and keyguard: reset password, set screen timeout... -## 工作模式 +## Working modes -- Device owner(推荐) +- Device owner (recommended) - 激活方式: + Activating methods: - Shizuku - Dhizuku - Root - - ADB shell命令 `dpm set-device-owner com.bintianqi.owndroid/.Receiver` + - ADB shell command `dpm set-device-owner com.bintianqi.owndroid/.Receiver` - [Dhizuku](https://github.com/iamr0s/Dhizuku) -- 工作资料 +- Work profile ## FAQ -### 设备上有账号 +### Already some accounts on the device ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already some accounts on the device ``` -解决办法: -- 冻结持有这些账号的app。 -- 删除这些账号。 +Solutions: +- Freeze apps who hold those accounts. +- Delete these accounts. -### 设备上有多个用户 +### Already several users on the device ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already several users on the device ``` -解决办法: -- 删除次级用户。 +Solutions: +- Delete secondary users. > [!NOTE] -> 一些系统有应用克隆、儿童空间等功能,它们通常是用户。 +> Some systems have features such as app cloning and children space, which are usually users. + +#### Device owner is already set + +```text +java.lang.IllegalStateException: Trying to set the device owner (com.bintianqi.owndroid/.Receiver), but device owner (xxx) is already set. +``` + +Only 1 device owner can exist on a device. Please deactivate the existing device owner first. ### MIUI & HyperOS @@ -63,7 +71,9 @@ java.lang.IllegalStateException: Not allowed to set the device owner because the java.lang.SecurityException: Neither user 2000 nor current process has android.permission.MANAGE_DEVICE_ADMINS. ``` -解决办法: 在开发者设置中打开`USB调试(安全设置)`,或在root命令行中执行激活命令。 +Solutions: +- Enable `USB debugging (Security setting)` in developer options. +- Or execute activating command in root shell. ### ColorOS @@ -71,41 +81,45 @@ java.lang.SecurityException: Neither user 2000 nor current process has android.p java.lang.IllegalStateException: Unexpected @ProvisioningPreCondition ``` -解决办法:使用 OwnDroid testkey 版本 +Solution: Use OwnDroid testkey version -### 三星 +### Samsung ```text user limit reached ``` -三星限制了多用户功能,暂无解决办法。 +Samsung restricts Android's multiple users feature. There is currently no solution. ## API -| ID | Extra | 最小安卓版本 | -|--------------------------|------------------------|:------:| -| `HIDE` | `package` | | -| `UNHIDE` | `package` | | -| `SUSPEND` | `package` | 7 | -| `UNSUSPEND` | `package` | 7 | -| `ADD_USER_RESTRICTION` | `restriction` | | -| `CLEAR_USER_RESTRICTION` | `restriction` | | -| `SET_PERMISSION_DEFAULT` | `package` `permission` | 6 | -| `SET_PERMISSION_GRANTED` | `package` `permission` | 6 | -| `SET_PERMISSION_DENIED` | `package` `permission` | 6 | -| `LOCK` | | | -| `REBOOT` | | 7 | +OwnDroid provides an Intent-based API. You need to set the API key in settings and enable the API. The numbers in brackets represent the minimum Android version required. -[可用的用户限制](https://developer.android.google.cn/reference/android/os/UserManager#constants_1) +- HIDE(package: String) +- UNHIDE(package: String) +- SUSPEND(package: String) (7) +- UNSUSPEND(package: String) (7) +- ADD_USER_RESTRICTION(restriction: Boolean) +- CLEAR_USER_RESTRICTION(restriction: Boolean) +- SET_PERMISSION_DEFAULT(package: String, permission: String) (6) +- SET_PERMISSION_GRANTED(package: String, permission: String) (6) +- SET_PERMISSION_DENIED(package: String, permission: String) (6) +- SET_SCREEN_CAPTURE_DISABLED() +- SET_SCREEN_CAPTURE_ENABLED() +- SET_CAMERA_DISABLED() +- SET_CAMERA_ENABLED() +- SET_USB_DISABLED() (12) +- SET_USB_ENABLED() (12) +- LOCK() +- REBOOT() (7) ```shell -# 一个在ADB shell中隐藏app的示例 +# An example of hiding app in ADB shell am broadcast -a com.bintianqi.owndroid.action.HIDE -n com.bintianqi.owndroid/.ApiReceiver --es key abcdefg --es package com.example.app ``` ```kotlin -// 一个在Kotlin中隐藏app的示例 +// An example of hiding app in Kotlin val intent = Intent("com.bintianqi.owndroid.action.HIDE") .setComponent(ComponentName("com.bintianqi.owndroid", "com.bintianqi.owndroid.ApiReceiver")) .putExtra("key", "abcdefg") @@ -113,18 +127,20 @@ val intent = Intent("com.bintianqi.owndroid.action.HIDE") context.sendBroadcast(intent) ``` -## 构建 +[Available user restrictions](https://developer.android.com/reference/android/os/UserManager#constants_1) -你可以在命令行中使用Gradle以构建OwnDroid +## Build + +You can use Gradle in command line to build OwnDroid. ```shell -# 使用testkey签名(默认) +# Use testkey for signing (default) ./gradlew build -# 使用你的jks密钥签名 +# Use your custom .jks key for signing ./gradlew build -PStoreFile="/path/to/your/jks/file" -PStorePassword="YOUR_KEYSTORE_PASSWORD" -PKeyPassword="YOUR_KEY_PASSWORD" -PKeyAlias="YOUR_KEY_ALIAS" ``` -(在Windows系统中应使用`./gradlew.bat`) +(Use `./gradlew.bat` instead on Windows) -## 许可证 +## License [License.md](LICENSE.md) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d94f989..84c2009 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "com.bintianqi.owndroid" minSdk = 21 targetSdk = 36 - versionCode = 40 - versionName = "7.1" + versionCode = 41 + versionName = "7.2" multiDexEnabled = false } @@ -44,6 +44,10 @@ android { debug { signingConfig = signingConfigs.getByName("defaultSignature") } + create("fastDebug") { + initWith(getByName("debug")) + isDebuggable = false + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 @@ -91,6 +95,7 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) + implementation(libs.material.icons.core) implementation(libs.shizuku.provider) implementation(libs.shizuku.api) implementation(libs.dhizuku.api) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b19edd8..48b55e8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,3 +24,5 @@ -dontwarn android.app.ActivityThread -dontwarn android.app.ContextImpl -dontwarn android.app.LoadedApk + +-keep class com.bintianqi.owndroid.MyViewModel { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f71f941..506c110 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ - + + @@ -60,11 +61,6 @@ - Privilege.DPM.setApplicationHidden(Privilege.DAR, app, true) "UNHIDE" -> Privilege.DPM.setApplicationHidden(Privilege.DAR, app, false) - "SUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), true).isEmpty() - "UNSUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), false).isEmpty() - "ADD_USER_RESTRICTION" -> { Privilege.DPM.addUserRestriction(Privilege.DAR, restriction); true } - "CLEAR_USER_RESTRICTION" -> { Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction); true } + "SUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), true) + "UNSUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), false) + "ADD_USER_RESTRICTION" -> { Privilege.DPM.addUserRestriction(Privilege.DAR, restriction) } + "CLEAR_USER_RESTRICTION" -> { Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction) } "SET_PERMISSION_DEFAULT" -> { Privilege.DPM.setPermissionGrantState( Privilege.DAR, app!!, permission!!, @@ -45,14 +44,30 @@ class ApiReceiver: BroadcastReceiver() { DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED ) } - "LOCK" -> { Privilege.DPM.lockNow(); true } - "REBOOT" -> { Privilege.DPM.reboot(Privilege.DAR); true } + "LOCK" -> { Privilege.DPM.lockNow() } + "REBOOT" -> { Privilege.DPM.reboot(Privilege.DAR) } + "SET_CAMERA_DISABLED" -> { + Privilege.DPM.setCameraDisabled(Privilege.DAR, true) + } + "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" - false } } - log += "\nsuccess: $ok" } catch(e: Exception) { e.printStackTrace() val message = (e::class.qualifiedName ?: "Exception") + ": " + (e.message ?: "") diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt index f37be6c..ca6b948 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt @@ -1,401 +1,29 @@ package com.bintianqi.owndroid -import android.app.Application -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageInfo -import android.content.pm.PackageInstaller -import android.net.Uri -import android.os.Build import android.os.Bundle -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage -import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem -import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.AppInstaller import com.bintianqi.owndroid.ui.theme.OwnDroidTheme -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.URLDecoder class AppInstallerActivity:FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - val myVm by viewModels() val vm by viewModels() vm.initialize(intent) + val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) setContent { - val theme by myVm.theme.collectAsStateWithLifecycle() OwnDroidTheme(theme) { - val installing by vm.installing.collectAsStateWithLifecycle() - val options by vm.options.collectAsStateWithLifecycle() - val packages by vm.packages.collectAsStateWithLifecycle() - val writtenPackages by vm.writtenPackages.collectAsStateWithLifecycle() - val writingPackage by vm.writingPackage.collectAsStateWithLifecycle() - val result by vm.result.collectAsStateWithLifecycle() + val uiState by vm.uiState.collectAsState() AppInstaller( - installing, options, { if(!installing) vm.options.value = it }, - packages, { uri -> vm.packages.update { it.minus(uri) } }, - { uris -> vm.packages.update { it.plus(uris) } }, - vm::startInstall, writtenPackages, writingPackage, - result, { vm.result.value = null } + uiState, vm::onPackagesAdd, vm::onPackageRemove, vm::startInstall, vm::closeResultDialog ) } } } } - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun AppInstaller( - installing: Boolean = false, - options: SessionParamsOptions = SessionParamsOptions(), - onOptionsChange: (SessionParamsOptions) -> Unit = {}, - packages: Set = setOf("https://example.com".toUri()), - onPackageRemove: (Uri) -> Unit = {}, - onPackageChoose: (List) -> Unit = {}, - onStartInstall: () -> Unit = {}, - writtenPackages: Set = setOf("https://example.com".toUri()), - writingPackage: Uri? = null, - result: Intent? = null, - onResultDialogClose: () -> Unit = {} -) { - var appLockDialog by rememberSaveable { mutableStateOf(false) } - val coroutine = rememberCoroutineScope() - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.app_installer)) } - ) - }, - floatingActionButton = { - if(packages.isNotEmpty()) ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.start)) }, - icon = { - if(installing) CircularProgressIndicator(modifier = Modifier.size(24.dp)) - else Icon(Icons.Default.PlayArrow, null) - }, - onClick = { - if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall() else appLockDialog = true - }, - expanded = !installing - ) - } - ) { paddingValues -> - var tab by remember { mutableIntStateOf(0) } - val pagerState = rememberPagerState { 2 } - val scrollState = rememberScrollState() - tab = pagerState.targetPage - Column(modifier = Modifier.padding(paddingValues)) { - TabRow(tab) { - Tab( - tab == 0, - onClick = { - coroutine.launch { scrollState.animateScrollTo(0) } - coroutine.launch { pagerState.animateScrollToPage(0) } - }, - text = { Text(stringResource(R.string.packages)) } - ) - Tab( - tab == 1, - onClick = { - coroutine.launch { scrollState.animateScrollTo(0) } - coroutine.launch { pagerState.animateScrollToPage(1) } - }, - text = { Text(stringResource(R.string.options)) } - ) - } - HorizontalPager(pagerState) { page -> - Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(top = 8.dp)) { - if(page == 0) Packages(installing, packages, onPackageRemove, onPackageChoose, writtenPackages, writingPackage) - else Options(options, onOptionsChange) - } - } - ResultDialog(result, onResultDialogClose) - } - } - if(appLockDialog) { - AppLockDialog({ - appLockDialog = false - onStartInstall() - }) { appLockDialog = false } - } -} - - -@Composable -private fun ColumnScope.Packages( - installing: Boolean, - packages: Set, onRemove: (Uri) -> Unit, onChoose: (List) -> Unit, - writtenPackages: Set, writingPackage: Uri? -) { - val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onChoose) - packages.forEach { - PackageItem( - it, installing, - { onRemove(it) }, it in writtenPackages, it == writingPackage - ) - } - AnimatedVisibility(!installing) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { - chooseSplitPackage.launch(APK_MIME) - }.padding(vertical = 12.dp) - ) { - Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp)) - Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium) - } - } -} - - -@Composable -private fun PackageItem(uri: Uri, installing: Boolean, onRemove: () -> Unit, isWritten: Boolean, isWriting: Boolean) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 6.dp, bottom = 6.dp).heightIn(min = 40.dp) - ) { - Text( - URLDecoder.decode(URLDecoder.decode(uri.path ?: uri.toString())), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth(0.85F) - ) - if(!installing) IconButton(onRemove) { - Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove)) - } - if(isWritten) Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary) - if(isWriting) CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp)) - } -} - -data class SessionParamsOptions( - val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL, - val keepOriginalEnabledSetting: Boolean = false, - val noKill: Boolean = false, - val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY, -) - -@Composable -private fun ColumnScope.Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) { - Text( - stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), - style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) - FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) { - onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false)) - } - FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { - onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING)) - } - if(Build.VERSION.SDK_INT >= 34) { - AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { - FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) { - onChange(options.copy(noKill = it)) - } - } - FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) { - onChange(options.copy(keepOriginalEnabledSetting = it)) - } - } - Text( - stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), - style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) - FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO)) - } - FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)) - } - FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL)) - } -} - -@Composable -private fun ResultDialog(result: Intent?, onDialogClose: () -> Unit) { - if(result != null) { - val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - AlertDialog( - title = { - val text = if(status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure - Text(stringResource(text)) - }, - text = { - val context = LocalContext.current - Text(parsePackageInstallerMessage(context, result)) - }, - confirmButton = { - TextButton(onDialogClose) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = onDialogClose - ) - } -} - -class AppInstallerViewModel(application: Application): AndroidViewModel(application) { - fun initialize(intent: Intent) { - intent.data?.let { uri -> packages.update { it + uri } } - intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uri -> packages.update { it + uri } } - intent.getParcelableArrayExtra(Intent.EXTRA_STREAM)?.forEach { uri -> packages.update { it + (uri as Uri) } } - intent.clipData?.let { clipData -> - for(i in 0..clipData.itemCount) { - packages.update { it + clipData.getItemAt(i).uri } - } - } - } - val installing = MutableStateFlow(false) - val result = MutableStateFlow(null) - val packages = MutableStateFlow(setOf()) - - val options = MutableStateFlow(SessionParamsOptions()) - - val writtenPackages = MutableStateFlow(setOf()) - val writingPackage = MutableStateFlow(null) - private fun getSessionParams(): PackageInstaller.SessionParams { - return PackageInstaller.SessionParams(options.value.mode).apply { - if(Build.VERSION.SDK_INT >= 34) { - if(options.value.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent() - setDontKillApp(options.value.noKill) - } - setInstallLocation(options.value.location) - } - } - fun startInstall() { - if(installing.value) return - installing.value = true - viewModelScope.launch(Dispatchers.IO) { - val context = getApplication() - val packageInstaller = context.packageManager.packageInstaller - val sessionId = packageInstaller.createSession(getSessionParams()) - val session = packageInstaller.openSession(sessionId) - try { - packages.value.forEach { splitPackageUri -> - withContext(Dispatchers.Main) { writingPackage.value = splitPackageUri } - session.openWrite(splitPackageUri.hashCode().toString(), 0, -1).use { splitPackageOut -> - context.contentResolver.openInputStream(splitPackageUri)!!.use { splitPackageIn -> - splitPackageIn.copyTo(splitPackageOut) - } - session.fsync(splitPackageOut) - } - withContext(Dispatchers.Main) { writtenPackages.update { it.plus(splitPackageUri) } } - } - withContext(Dispatchers.Main) { writingPackage.value = null } - } catch(e: Exception) { - e.printStackTrace() - session.abandon() - return@launch - } - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { - @SuppressWarnings("UnsafeIntentLaunch") - context.startActivity( - (intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) - ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } else { - result.value = intent - writtenPackages.value = setOf() - if(statusExtra == PackageInstaller.STATUS_SUCCESS) { - packages.value = setOf() - } - installing.value = false - context.unregisterReceiver(this) - } - } - } - ContextCompat.registerReceiver( - context, receiver, IntentFilter(ACTION), null, - null, ContextCompat.RECEIVER_EXPORTED - ) - val pi = if(Build.VERSION.SDK_INT >= 34) { - PendingIntent.getBroadcast( - context, sessionId, Intent(ACTION), - PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE - ).intentSender - } else { - PendingIntent.getBroadcast(context, sessionId, Intent(ACTION), PendingIntent.FLAG_MUTABLE).intentSender - } - session.commit(pi) - } - } - - override fun onCleared() { - super.onCleared() - viewModelScope.cancel() - } - companion object { - const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED" - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt new file mode 100644 index 0000000..db89514 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt @@ -0,0 +1,150 @@ +package com.bintianqi.owndroid + +import android.app.Application +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +class AppInstallerViewModel(application: Application): AndroidViewModel(application) { + val uiState = MutableStateFlow(UiState()) + data class UiState( + val packages: List = emptyList(), + val installing: Boolean = false, + val packageWriting: Int = -1, + val result: Intent? = null + ) + + fun initialize(intent: Intent) { + val list = mutableListOf() + intent.data?.let { list += it } + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { list += it } + intent.getParcelableArrayExtra(Intent.EXTRA_STREAM)?.forEach { list += it as Uri } + intent.clipData?.let { clipData -> + for(i in 0..clipData.itemCount - 1) { + list += clipData.getItemAt(i).uri + } + } + uiState.update { it.copy(it.packages + list.distinct()) } + } + + fun onPackagesAdd(packages: List) { + uiState.update { + it.copy(packages = it.packages.plus(packages).distinct()) + } + } + + fun onPackageRemove(uri: Uri) { + uiState.update { + it.copy(packages = it.packages.minus(uri)) + } + } + + private fun getSessionParams(options: SessionParamsOptions): PackageInstaller.SessionParams { + return PackageInstaller.SessionParams(options.mode).apply { + if(Build.VERSION.SDK_INT >= 34) { + if(options.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent() + setDontKillApp(options.noKill) + } + setInstallLocation(options.location) + } + } + + fun startInstall(options: SessionParamsOptions) { + if (uiState.value.installing) return + viewModelScope.launch(Dispatchers.IO) { + installPackages(options) + } + } + + private fun installPackages(options: SessionParamsOptions) { + val packageInstaller = application.packageManager.packageInstaller + val sessionId = packageInstaller.createSession(getSessionParams(options)) + val session = packageInstaller.openSession(sessionId) + try { + uiState.update { it.copy(packageWriting = 0) } + uiState.value.packages.forEach { uri -> + session.openWrite(uri.hashCode().toString(), 0, -1).use { splitPackageOut -> + application.contentResolver.openInputStream(uri)!!.use { splitPackageIn -> + splitPackageIn.copyTo(splitPackageOut) + } + session.fsync(splitPackageOut) + } + uiState.update { it.copy(packageWriting = it.packageWriting + 1) } + } + } catch(e: Exception) { + e.printStackTrace() + session.abandon() + uiState.update { it.copy(installing = false, packageWriting = -1) } + return + } + ContextCompat.registerReceiver( + application, Receiver(), IntentFilter(ACTION), null, + null, ContextCompat.RECEIVER_EXPORTED + ) + val pi = if(Build.VERSION.SDK_INT >= 34) { + PendingIntent.getBroadcast( + application, sessionId, Intent(ACTION), + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE + ).intentSender + } else { + PendingIntent.getBroadcast(application, sessionId, Intent(ACTION), PendingIntent.FLAG_MUTABLE).intentSender + } + session.commit(pi) + } + + inner class Receiver() : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + if (statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @SuppressWarnings("UnsafeIntentLaunch") + context.startActivity( + (intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } else { + uiState.update { it.copy(result = intent) } + context.unregisterReceiver(this) + } + } + } + + fun closeResultDialog() { + if (uiState.value.result?.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) == PackageInstaller.STATUS_SUCCESS) { + uiState.update { it.copy(emptyList(), packageWriting = -1, result = null) } + } else { + uiState.update { it.copy(packageWriting = -1, result = null) } + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.cancel() + } + companion object { + const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED" + } +} + +@Serializable +data class SessionParamsOptions( + val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL, + val keepOriginalEnabledSetting: Boolean = false, + val noKill: Boolean = false, + val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY, +) diff --git a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt index d3c0052..820c554 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppLock.kt @@ -26,9 +26,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -44,8 +47,9 @@ import androidx.compose.ui.window.DialogProperties fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismiss, DialogProperties(true, false)) { val context = LocalContext.current val fm = LocalFocusManager.current - var input by remember { mutableStateOf("") } - var isError by remember { mutableStateOf(false) } + val fr = remember { FocusRequester() } + var input by rememberSaveable { mutableStateOf("") } + var isError by rememberSaveable { mutableStateOf(false) } fun unlock() { if(input.hash() == SP.lockPasswordHash) { fm.clearFocus() @@ -55,14 +59,18 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi } } LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= 28 && SP.biometricsUnlock) startBiometricsUnlock(context, onSucceed) + if (Build.VERSION.SDK_INT >= 28 && SP.biometricsUnlock) { + startBiometricsUnlock(context, onSucceed) + } else { + fr.requestFocus() + } } BackHandler(onBack = onDismiss) Card(Modifier.pointerInput(Unit) { detectTapGestures(onTap = { fm.clearFocus() }) }, shape = RoundedCornerShape(16.dp)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( - input, { input = it; isError = false }, Modifier.width(200.dp), + input, { input = it; isError = false }, Modifier.width(200.dp).focusRequester(fr), label = { Text(stringResource(R.string.password)) }, isError = isError, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = if(input.length >= 4) ImeAction.Go else ImeAction.Done diff --git a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt index 488a19f..51bce41 100644 --- a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt +++ b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt @@ -9,7 +9,6 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog @@ -20,11 +19,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.ui.theme.OwnDroidTheme import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.rosan.dhizuku.aidl.IDhizukuClient @@ -34,13 +33,9 @@ import com.rosan.dhizuku.server_api.DhizukuService import com.rosan.dhizuku.shared.DhizukuVariables import kotlinx.coroutines.delay import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json private const val TAG = "DhizukuServer" -const val DHIZUKU_CLIENTS_FILE = "dhizuku_clients.json" - class MyDhizukuProvider(): DhizukuProvider() { override fun onCreateService(client: IDhizukuClient): DhizukuService? { Log.d(TAG, "Creating MyDhizukuService") @@ -57,8 +52,6 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC pm.getNameForUid(callingUid) ?: return false, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES ) - val file = mContext.filesDir.resolve(DHIZUKU_CLIENTS_FILE) - val clients = Json.decodeFromString>(file.readText()) val signature = getPackageSignature(packageInfo) val requiredPermission = when (func) { "remote_transact", "remote_process" -> func @@ -66,9 +59,10 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC "get_delegated_scopes", "set_delegated_scopes" -> "delegated_scopes" else -> "other" } - val hasPermission = clients.find { - callingUid == it.uid && signature == it.signature && requiredPermission in it.permissions - } != null + val hasPermission = (mContext.applicationContext as MyApplication).myRepo + .checkDhizukuClientPermission( + callingUid, signature, requiredPermission + ) Log.d(TAG, "UID $callingUid, PID $callingPid, required permission: $requiredPermission, has permission: $hasPermission") return hasPermission } @@ -98,26 +92,19 @@ class DhizukuActivity : ComponentActivity() { val icon = appInfo.loadIcon(packageManager) val label = appInfo.loadLabel(packageManager).toString() fun close(grantPermission: Boolean) { - val file = filesDir.resolve(DHIZUKU_CLIENTS_FILE) - val json = Json { ignoreUnknownKeys = true } - val clients = json.decodeFromString>(file.readText()) - val index = clients.indexOfFirst { it.uid == uid } val clientInfo = DhizukuClientInfo( uid, getPackageSignature(packageInfo), if (grantPermission) DhizukuPermissions else emptyList() ) - if (index == -1) clients += clientInfo - else clients[index] = clientInfo - file.writeText(Json.encodeToString(clients)) + (application as MyApplication).myRepo.setDhizukuClient(clientInfo) finish() listener.onRequestPermission( if (grantPermission) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED ) } - val vm by viewModels() enableEdgeToEdge() + val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) setContent { - var appLockDialog by remember { mutableStateOf(false) } - val theme by vm.theme.collectAsStateWithLifecycle() + var appLockDialog by rememberSaveable { mutableStateOf(false) } OwnDroidTheme(theme) { if (!appLockDialog) AlertDialog( icon = { diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 53ee8f6..446d856 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -1,9 +1,12 @@ package com.bintianqi.owndroid +import android.Manifest +import android.content.pm.PackageManager import android.os.Build.VERSION import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -11,10 +14,9 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -62,8 +64,6 @@ import com.bintianqi.owndroid.dpm.AddApnSetting import com.bintianqi.owndroid.dpm.AddApnSettingScreen import com.bintianqi.owndroid.dpm.AddDelegatedAdmin import com.bintianqi.owndroid.dpm.AddDelegatedAdminScreen -import com.bintianqi.owndroid.dpm.AddNetwork -import com.bintianqi.owndroid.dpm.AddNetworkScreen import com.bintianqi.owndroid.dpm.AddPreferentialNetworkServiceConfig import com.bintianqi.owndroid.dpm.AddPreferentialNetworkServiceConfigScreen import com.bintianqi.owndroid.dpm.AffiliationId @@ -79,7 +79,6 @@ import com.bintianqi.owndroid.dpm.AutoTimePolicyScreen import com.bintianqi.owndroid.dpm.AutoTimeZonePolicy import com.bintianqi.owndroid.dpm.AutoTimeZonePolicyScreen import com.bintianqi.owndroid.dpm.BlockUninstall -import com.bintianqi.owndroid.dpm.BlockUninstallScreen import com.bintianqi.owndroid.dpm.CaCert import com.bintianqi.owndroid.dpm.CaCertScreen import com.bintianqi.owndroid.dpm.ChangeTime @@ -101,9 +100,7 @@ import com.bintianqi.owndroid.dpm.CredentialManagerPolicyScreen import com.bintianqi.owndroid.dpm.CrossProfileIntentFilter import com.bintianqi.owndroid.dpm.CrossProfileIntentFilterScreen import com.bintianqi.owndroid.dpm.CrossProfilePackages -import com.bintianqi.owndroid.dpm.CrossProfilePackagesScreen import com.bintianqi.owndroid.dpm.CrossProfileWidgetProviders -import com.bintianqi.owndroid.dpm.CrossProfileWidgetProvidersScreen import com.bintianqi.owndroid.dpm.DelegatedAdmins import com.bintianqi.owndroid.dpm.DelegatedAdminsScreen import com.bintianqi.owndroid.dpm.DeleteWorkProfile @@ -115,9 +112,9 @@ import com.bintianqi.owndroid.dpm.DhizukuServerSettingsScreen import com.bintianqi.owndroid.dpm.DisableAccountManagement import com.bintianqi.owndroid.dpm.DisableAccountManagementScreen import com.bintianqi.owndroid.dpm.DisableMeteredData -import com.bintianqi.owndroid.dpm.DisableMeteredDataScreen import com.bintianqi.owndroid.dpm.DisableUserControl -import com.bintianqi.owndroid.dpm.DisableUserControlScreen +import com.bintianqi.owndroid.dpm.EditAppGroup +import com.bintianqi.owndroid.dpm.EditAppGroupScreen import com.bintianqi.owndroid.dpm.EnableSystemApp import com.bintianqi.owndroid.dpm.EnableSystemAppScreen import com.bintianqi.owndroid.dpm.FrpPolicy @@ -125,13 +122,11 @@ import com.bintianqi.owndroid.dpm.FrpPolicyScreen import com.bintianqi.owndroid.dpm.HardwareMonitor import com.bintianqi.owndroid.dpm.HardwareMonitorScreen import com.bintianqi.owndroid.dpm.Hide -import com.bintianqi.owndroid.dpm.HideScreen import com.bintianqi.owndroid.dpm.InstallExistingApp import com.bintianqi.owndroid.dpm.InstallExistingAppScreen import com.bintianqi.owndroid.dpm.InstallSystemUpdate import com.bintianqi.owndroid.dpm.InstallSystemUpdateScreen import com.bintianqi.owndroid.dpm.KeepUninstalledPackages -import com.bintianqi.owndroid.dpm.KeepUninstalledPackagesScreen import com.bintianqi.owndroid.dpm.Keyguard import com.bintianqi.owndroid.dpm.KeyguardDisabledFeatures import com.bintianqi.owndroid.dpm.KeyguardDisabledFeaturesScreen @@ -140,6 +135,8 @@ import com.bintianqi.owndroid.dpm.LockScreenInfo import com.bintianqi.owndroid.dpm.LockScreenInfoScreen import com.bintianqi.owndroid.dpm.LockTaskMode import com.bintianqi.owndroid.dpm.LockTaskModeScreen +import com.bintianqi.owndroid.dpm.ManageAppGroups +import com.bintianqi.owndroid.dpm.ManageAppGroupsScreen import com.bintianqi.owndroid.dpm.MtePolicy import com.bintianqi.owndroid.dpm.MtePolicyScreen import com.bintianqi.owndroid.dpm.NearbyStreamingPolicy @@ -157,6 +154,8 @@ import com.bintianqi.owndroid.dpm.OrganizationOwnedProfile import com.bintianqi.owndroid.dpm.OrganizationOwnedProfileScreen import com.bintianqi.owndroid.dpm.OverrideApn import com.bintianqi.owndroid.dpm.OverrideApnScreen +import com.bintianqi.owndroid.dpm.PackageFunctionScreen +import com.bintianqi.owndroid.dpm.PackageFunctionScreenWithoutResult import com.bintianqi.owndroid.dpm.Password import com.bintianqi.owndroid.dpm.PasswordInfo import com.bintianqi.owndroid.dpm.PasswordInfoScreen @@ -166,10 +165,10 @@ import com.bintianqi.owndroid.dpm.PermissionPolicyScreen import com.bintianqi.owndroid.dpm.PermissionsManager import com.bintianqi.owndroid.dpm.PermissionsManagerScreen import com.bintianqi.owndroid.dpm.PermittedAccessibilityServices -import com.bintianqi.owndroid.dpm.PermittedAccessibilityServicesScreen +import com.bintianqi.owndroid.dpm.PermittedAsAndImPackages import com.bintianqi.owndroid.dpm.PermittedInputMethods -import com.bintianqi.owndroid.dpm.PermittedInputMethodsScreen import com.bintianqi.owndroid.dpm.PreferentialNetworkService +import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceInfo import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceScreen import com.bintianqi.owndroid.dpm.PrivateDns import com.bintianqi.owndroid.dpm.PrivateDnsScreen @@ -184,7 +183,6 @@ import com.bintianqi.owndroid.dpm.ResetPassword import com.bintianqi.owndroid.dpm.ResetPasswordScreen import com.bintianqi.owndroid.dpm.ResetPasswordToken import com.bintianqi.owndroid.dpm.ResetPasswordTokenScreen -import com.bintianqi.owndroid.dpm.Restriction import com.bintianqi.owndroid.dpm.SecurityLogging import com.bintianqi.owndroid.dpm.SecurityLoggingScreen import com.bintianqi.owndroid.dpm.SetDefaultDialer @@ -195,7 +193,6 @@ import com.bintianqi.owndroid.dpm.SupportMessageScreen import com.bintianqi.owndroid.dpm.Suspend import com.bintianqi.owndroid.dpm.SuspendPersonalApp import com.bintianqi.owndroid.dpm.SuspendPersonalAppScreen -import com.bintianqi.owndroid.dpm.SuspendScreen import com.bintianqi.owndroid.dpm.SystemManager import com.bintianqi.owndroid.dpm.SystemManagerScreen import com.bintianqi.owndroid.dpm.SystemOptions @@ -205,6 +202,8 @@ import com.bintianqi.owndroid.dpm.TransferOwnership import com.bintianqi.owndroid.dpm.TransferOwnershipScreen import com.bintianqi.owndroid.dpm.UninstallApp import com.bintianqi.owndroid.dpm.UninstallAppScreen +import com.bintianqi.owndroid.dpm.UpdateNetwork +import com.bintianqi.owndroid.dpm.UpdateNetworkScreen import com.bintianqi.owndroid.dpm.UserInfo import com.bintianqi.owndroid.dpm.UserInfoScreen import com.bintianqi.owndroid.dpm.UserOperation @@ -222,8 +221,6 @@ import com.bintianqi.owndroid.dpm.UsersOptions import com.bintianqi.owndroid.dpm.UsersOptionsScreen import com.bintianqi.owndroid.dpm.UsersScreen import com.bintianqi.owndroid.dpm.WiFi -import com.bintianqi.owndroid.dpm.WifiAuthKeypair -import com.bintianqi.owndroid.dpm.WifiAuthKeypairScreen import com.bintianqi.owndroid.dpm.WifiScreen import com.bintianqi.owndroid.dpm.WifiSecurityLevel import com.bintianqi.owndroid.dpm.WifiSecurityLevelScreen @@ -235,7 +232,7 @@ import com.bintianqi.owndroid.dpm.WorkModesScreen import com.bintianqi.owndroid.dpm.WorkProfile import com.bintianqi.owndroid.dpm.WorkProfileScreen import com.bintianqi.owndroid.dpm.dhizukuErrorStatus -import com.bintianqi.owndroid.ui.Animations +import com.bintianqi.owndroid.ui.NavTransition import com.bintianqi.owndroid.ui.theme.OwnDroidTheme import kotlinx.serialization.Serializable import java.util.Locale @@ -249,6 +246,13 @@ class MainActivity : FragmentActivity() { val locale = context.resources?.configuration?.locale zhCN = locale == Locale.SIMPLIFIED_CHINESE || locale == Locale.CHINESE || locale == Locale.CHINA val vm by viewModels() + if ( + VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } setContent { var appLockDialog by rememberSaveable { mutableStateOf(false) } val theme by vm.theme.collectAsStateWithLifecycle() @@ -275,7 +279,17 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { val focusMgr = LocalFocusManager.current val lifecycleOwner = LocalLifecycleOwner.current fun navigateUp() { navController.navigateUp() } - fun navigate(destination: Any) { navController.navigate(destination) } + fun navigate(destination: Any) { + navController.navigate(destination) { + launchSingleTop = true + } + } + fun choosePackage() { + navController.navigate(ApplicationsList(false)) + } + fun navigateToAppGroups() { + navController.navigate(ManageAppGroups) + } LaunchedEffect(Unit) { if(!Privilege.status.value.activated) { navController.navigate(WorkModes(false)) { @@ -290,92 +304,215 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { .fillMaxSize() .background(colorScheme.background) .pointerInput(Unit) { detectTapGestures(onTap = { focusMgr.clearFocus() }) }, - enterTransition = Animations.navHostEnterTransition, - exitTransition = Animations.navHostExitTransition, - popEnterTransition = Animations.navHostPopEnterTransition, - popExitTransition = Animations.navHostPopExitTransition + enterTransition = { NavTransition.enterTransition }, + exitTransition = { NavTransition.exitTransition }, + popEnterTransition = { NavTransition.popEnterTransition }, + popExitTransition = { NavTransition.popExitTransition } ) { composable { HomeScreen(::navigate) } composable { - WorkModesScreen(it.toRoute(), ::navigateUp, { + WorkModesScreen(vm, it.toRoute(), ::navigateUp, { navController.navigate(Home) { popUpTo { inclusive = true } } + }, { + navController.navigate(WorkModes(false)) { + popUpTo(Home) { inclusive = true } + } }, ::navigate) } - composable { DhizukuServerSettingsScreen(::navigateUp) } + composable { + DhizukuServerSettingsScreen(vm.dhizukuClients, vm::getDhizukuClients, + vm::updateDhizukuClient, vm::getDhizukuServerEnabled, vm::setDhizukuServerEnabled, + ::navigateUp) + } - composable { DelegatedAdminsScreen(::navigateUp, ::navigate) } - composable{ AddDelegatedAdminScreen(it.toRoute(), ::navigateUp) } - composable { DeviceInfoScreen(::navigateUp) } - composable { LockScreenInfoScreen(::navigateUp) } - composable { SupportMessageScreen(::navigateUp) } + composable { + DelegatedAdminsScreen(vm.delegatedAdmins, vm::getDelegatedAdmins, ::navigateUp, ::navigate) + } + composable{ + AddDelegatedAdminScreen(vm.chosenPackage, ::choosePackage, it.toRoute(), + vm::setDelegatedAdmin, ::navigateUp) + } + composable { DeviceInfoScreen(vm, ::navigateUp) } + composable { + LockScreenInfoScreen(vm::getLockScreenInfo, vm::setLockScreenInfo, ::navigateUp) + } + composable { + SupportMessageScreen(vm::getShortSupportMessage, vm::getLongSupportMessage, + vm::setShortSupportMessage, vm::setLongSupportMessage, ::navigateUp) + } composable { - TransferOwnershipScreen(::navigateUp) { + TransferOwnershipScreen(vm.deviceAdminReceivers, vm::getDeviceAdminReceivers, + vm::transferOwnership, ::navigateUp) { navController.navigate(WorkModes(false)) { popUpTo(Home) { inclusive = true } } } } - composable { SystemManagerScreen(::navigateUp, ::navigate) } - composable { SystemOptionsScreen(::navigateUp) } - composable { KeyguardScreen(::navigateUp) } - composable { HardwareMonitorScreen(::navigateUp) } - composable { ChangeTimeScreen(::navigateUp) } - composable { ChangeTimeZoneScreen(::navigateUp) } - composable { AutoTimePolicyScreen(::navigateUp) } - composable { AutoTimeZonePolicyScreen(::navigateUp) } + composable { SystemManagerScreen(vm, ::navigateUp, ::navigate) } + composable { SystemOptionsScreen(vm, ::navigateUp) } + composable { + KeyguardScreen(vm::setKeyguardDisabled, vm::lockScreen, ::navigateUp) + } + composable { + HardwareMonitorScreen(vm.hardwareProperties, vm::getHardwareProperties, + vm::setHpRefreshInterval, ::navigateUp) + } + composable { ChangeTimeScreen(vm::setTime, ::navigateUp) } + composable { ChangeTimeZoneScreen(vm::setTimeZone, ::navigateUp) } + composable { + AutoTimePolicyScreen(vm::getAutoTimePolicy, vm::setAutoTimePolicy, ::navigateUp) + } + composable { + AutoTimeZonePolicyScreen(vm::getAutoTimeZonePolicy, vm::setAutoTimeZonePolicy, + ::navigateUp) + } //composable<> { KeyPairs(::navigateUp) } - composable { ContentProtectionPolicyScreen(::navigateUp) } - composable { PermissionPolicyScreen(::navigateUp) } - composable { MtePolicyScreen(::navigateUp) } - composable { NearbyStreamingPolicyScreen(::navigateUp) } - composable { LockTaskModeScreen(::navigateUp) } - composable { CaCertScreen(::navigateUp) } - composable { SecurityLoggingScreen(::navigateUp) } - composable { DisableAccountManagementScreen(::navigateUp) } - composable { SystemUpdatePolicyScreen(::navigateUp) } - composable { InstallSystemUpdateScreen(::navigateUp) } - composable { FrpPolicyScreen(::navigateUp) } - composable { WipeDataScreen(::navigateUp) } + composable { + ContentProtectionPolicyScreen(vm::getContentProtectionPolicy, + vm::setContentProtectionPolicy, ::navigateUp) + } + composable { + PermissionPolicyScreen(vm::getPermissionPolicy, vm::setPermissionPolicy, ::navigateUp) + } + composable { + MtePolicyScreen(vm::getMtePolicy, vm::setMtePolicy, ::navigateUp) + } + composable { + NearbyStreamingPolicyScreen(vm::getNsAppPolicy, vm::setNsAppPolicy, + vm::getNsNotificationPolicy, vm::setNsNotificationPolicy, ::navigateUp) + } + composable { + LockTaskModeScreen(vm.chosenPackage, ::choosePackage, vm.lockTaskPackages, + vm::getLockTaskPackages, vm::setLockTaskPackage, vm::startLockTaskMode, + vm:: getLockTaskFeatures, vm::setLockTaskFeatures, ::navigateUp) + } + composable { + CaCertScreen(vm.installedCaCerts, vm::getCaCerts, vm.selectedCaCert, vm::selectCaCert, vm::installCaCert, vm::parseCaCert, + vm::exportCaCert, vm::uninstallCaCert, vm::uninstallAllCaCerts, ::navigateUp) + } + composable { + SecurityLoggingScreen(vm::getSecurityLoggingEnabled, vm::setSecurityLoggingEnabled, + vm::exportSecurityLogs, vm::getSecurityLogsCount, vm::deleteSecurityLogs, + vm::getPreRebootSecurityLogs, vm::exportPreRebootSecurityLogs, ::navigateUp) + } + composable { + DisableAccountManagementScreen(vm.mdAccountTypes, vm::getMdAccountTypes, + vm::setMdAccountType, ::navigateUp) + } + composable { + SystemUpdatePolicyScreen(vm::getSystemUpdatePolicy, vm::setSystemUpdatePolicy, + vm::getPendingSystemUpdate, ::navigateUp) + } + composable { + InstallSystemUpdateScreen(vm::installSystemUpdate, ::navigateUp) + } + composable { + FrpPolicyScreen(vm.getFrpPolicy(), vm::setFrpPolicy, ::navigateUp) + } + composable { WipeDataScreen(vm::wipeData, ::navigateUp) } composable { NetworkScreen(::navigateUp, ::navigate) } - composable { WifiScreen(::navigateUp, ::navigate) { navController.navigate(AddNetwork, it)} } - composable { NetworkOptionsScreen(::navigateUp) } - composable { AddNetworkScreen(it.arguments!!, ::navigateUp) } - composable { WifiSecurityLevelScreen(::navigateUp) } - composable { WifiSsidPolicyScreen(::navigateUp) } - composable { NetworkStatsScreen(::navigateUp, ::navigate) } - composable(mapOf(serializableNavTypePair>())) { - NetworkStatsViewerScreen(it.toRoute(), ::navigateUp) + composable { + WifiScreen(vm, ::navigateUp, ::navigate) { navController.navigate(UpdateNetwork(it)) } + } + composable { + NetworkOptionsScreen(vm::getLanEnabled, vm::setLanEnabled, ::navigateUp) + } + composable { + val info = vm.configuredNetworks.collectAsStateWithLifecycle().value[ + (it.toRoute() as UpdateNetwork).index + ] + UpdateNetworkScreen(info, vm::setWifi, ::navigateUp) + } + composable { + WifiSecurityLevelScreen(vm::getMinimumWifiSecurityLevel, + vm::setMinimumWifiSecurityLevel, ::navigateUp) + } + composable { + WifiSsidPolicyScreen(vm::getSsidPolicy, vm::setSsidPolicy, ::navigateUp) + } + composable { + NetworkStatsScreen(vm.chosenPackage, ::choosePackage, vm::getPackageUid, + vm::queryNetworkStats, ::navigateUp) { navController.navigate(NetworkStatsViewer) } + } + composable { + NetworkStatsViewerScreen(vm.networkStatsData, vm::clearNetworkStats, ::navigateUp) + } + composable { + PrivateDnsScreen(vm::getPrivateDns, vm::setPrivateDns, ::navigateUp) + } + composable { + AlwaysOnVpnPackageScreen(vm::getAlwaysOnVpnPackage, vm::getAlwaysOnVpnLockdown, + vm::setAlwaysOnVpn, vm.chosenPackage, ::choosePackage, ::navigateUp) + } + composable { + RecommendedGlobalProxyScreen(vm::setRecommendedGlobalProxy, ::navigateUp) + } + composable { + NetworkLoggingScreen(vm::getNetworkLoggingEnabled, vm::setNetworkLoggingEnabled, + vm::getNetworkLogsCount, vm::exportNetworkLogs, vm::deleteNetworkLogs, ::navigateUp) + } + //composable { WifiAuthKeypairScreen(::navigateUp) } + composable { + PreferentialNetworkServiceScreen(vm::getPnsEnabled, vm::setPnsEnabled, vm.pnsConfigs, + vm::getPnsConfigs, ::navigateUp, ::navigate) + } + composable { + val info = vm.pnsConfigs.collectAsStateWithLifecycle().value.getOrNull( + it.toRoute().index + ) ?: PreferentialNetworkServiceInfo() + AddPreferentialNetworkServiceConfigScreen(info, vm::setPnsConfig, ::navigateUp) + } + composable { + OverrideApnScreen(vm.apnConfigs, vm::getApnConfigs, vm::getApnEnabled, + vm::setApnEnabled, ::navigateUp) { navController.navigate(AddApnSetting(it)) } + } + composable { + val origin = vm.apnConfigs.collectAsStateWithLifecycle().value.getOrNull((it.toRoute() as AddApnSetting).index) + AddApnSettingScreen(vm::setApnConfig, vm::removeApnConfig, origin, ::navigateUp) } - composable { PrivateDnsScreen(::navigateUp) } - composable { AlwaysOnVpnPackageScreen(::navigateUp) } - composable { RecommendedGlobalProxyScreen(::navigateUp) } - composable { NetworkLoggingScreen(::navigateUp) } - composable { WifiAuthKeypairScreen(::navigateUp) } - composable { PreferentialNetworkServiceScreen(::navigateUp, ::navigate) } - composable { AddPreferentialNetworkServiceConfigScreen(it.toRoute(), ::navigateUp) } - composable { OverrideApnScreen(::navigateUp) { navController.navigate(AddApnSetting, it) } } - composable { AddApnSettingScreen(it.arguments?.getParcelable("setting"), ::navigateUp) } composable { WorkProfileScreen(::navigateUp, ::navigate) } - composable { OrganizationOwnedProfileScreen(::navigateUp) } - composable { CreateWorkProfileScreen(::navigateUp) } - composable { SuspendPersonalAppScreen(::navigateUp) } - composable { CrossProfileIntentFilterScreen(::navigateUp) } - composable { DeleteWorkProfileScreen(::navigateUp) } + composable { + OrganizationOwnedProfileScreen(vm::activateOrgProfileByShizuku, ::navigateUp) + } + composable { + CreateWorkProfileScreen(vm::createWorkProfile, ::navigateUp) + } + composable { + SuspendPersonalAppScreen( + vm::getPersonalAppsSuspendedReason, vm::setPersonalAppsSuspended, + vm::getProfileMaxTimeOff, vm::setProfileMaxTimeOff, ::navigateUp + ) + } + composable { + CrossProfileIntentFilterScreen(vm::addCrossProfileIntentFilter, ::navigateUp) + } + composable { DeleteWorkProfileScreen(vm::wipeData, ::navigateUp) } composable { - AppChooserScreen(it.toRoute(), { dest -> - if(dest == null) navigateUp() else navigate(ApplicationDetails(dest)) + val canSwitchView = (it.toRoute() as ApplicationsList).canSwitchView + AppChooserScreen( + canSwitchView, vm.installedPackages, vm.refreshPackagesProgress, { name -> + if (canSwitchView) { + if (name == null) { + navigateUp() + } else { + navigate(ApplicationDetails(name)) + } + } else { + if (name != null) vm.chosenPackage.trySend(name) + navigateUp() + } }, { SP.applicationsListView = false navController.navigate(ApplicationsFeatures) { popUpTo(Home) } - }) + }, vm::refreshPackageList) } composable { ApplicationsFeaturesScreen(::navigateUp, ::navigate) { @@ -385,63 +522,165 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } } } - composable { ApplicationDetailsScreen(it.toRoute(), ::navigateUp, ::navigate) } - composable { SuspendScreen(::navigateUp) } - composable { HideScreen(::navigateUp) } - composable { BlockUninstallScreen(::navigateUp) } - composable { DisableUserControlScreen(::navigateUp) } - composable { PermissionsManagerScreen(::navigateUp, it.toRoute()) } - composable { DisableMeteredDataScreen(::navigateUp) } - composable { ClearAppStorageScreen(::navigateUp) } - composable { UninstallAppScreen(::navigateUp) } - composable { KeepUninstalledPackagesScreen(::navigateUp) } - composable { InstallExistingAppScreen(::navigateUp) } - composable { CrossProfilePackagesScreen(::navigateUp) } - composable { CrossProfileWidgetProvidersScreen(::navigateUp) } - composable { CredentialManagerPolicyScreen(::navigateUp) } - composable { PermittedAccessibilityServicesScreen(::navigateUp) } - composable { PermittedInputMethodsScreen(::navigateUp) } - composable { EnableSystemAppScreen(::navigateUp) } - composable { SetDefaultDialerScreen(::navigateUp) } + composable { + ApplicationDetailsScreen(it.toRoute(), vm, ::navigateUp, ::navigate) + } + composable { + PackageFunctionScreen(R.string.suspend, vm.suspendedPackages, vm::getSuspendedPackaged, + vm::setPackageSuspended, ::navigateUp, vm.chosenPackage, ::choosePackage, + ::navigateToAppGroups, vm.appGroups, R.string.info_suspend_app) + } + composable { + PackageFunctionScreen(R.string.hide, vm.hiddenPackages, vm::getHiddenPackages, + vm::setPackageHidden, ::navigateUp, vm.chosenPackage, ::choosePackage, ::navigateToAppGroups, vm.appGroups) + } + composable { + PackageFunctionScreenWithoutResult(R.string.block_uninstall, vm.ubPackages, + vm::getUbPackages, vm::setPackageUb, ::navigateUp, vm.chosenPackage, ::choosePackage, ::navigateToAppGroups, vm.appGroups) + } + composable { + PackageFunctionScreenWithoutResult(R.string.disable_user_control, vm.ucdPackages, + vm::getUcdPackages, vm::setPackageUcd, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups, R.string.info_disable_user_control) + } + composable { + PermissionsManagerScreen(vm.packagePermissions, vm::getPackagePermissions, + vm::setPackagePermission, ::navigateUp, it.toRoute(), vm.chosenPackage, ::choosePackage) + } + composable { + PackageFunctionScreen(R.string.disable_metered_data, vm.mddPackages, + vm::getMddPackages, vm::setPackageMdd, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups) + } + composable { + ClearAppStorageScreen(vm.chosenPackage, ::choosePackage, vm::clearAppData, ::navigateUp) + } + composable { + UninstallAppScreen(vm.chosenPackage, ::choosePackage, vm::uninstallPackage, ::navigateUp) + } + composable { + PackageFunctionScreenWithoutResult(R.string.keep_uninstalled_packages, vm.kuPackages, + vm::getKuPackages, vm::setPackageKu, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups, + R.string.info_keep_uninstalled_apps) + } + composable { + InstallExistingAppScreen(vm.chosenPackage, ::choosePackage, + vm::installExistingApp, ::navigateUp) + } + composable { + PackageFunctionScreenWithoutResult(R.string.cross_profile_apps, vm.cpPackages, + vm::getCpPackages, vm::setPackageCp, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups) + } + composable { + PackageFunctionScreen(R.string.cross_profile_widget, vm.cpwProviders, + vm::getCpwProviders, vm::setCpwProvider, ::navigateUp, vm.chosenPackage, + ::choosePackage, ::navigateToAppGroups, vm.appGroups) + } + composable { + CredentialManagerPolicyScreen(vm.chosenPackage, ::choosePackage, + vm.cmPackages, vm::getCmPolicy, vm::setCmPackage, vm::setCmPolicy, ::navigateUp) + } + composable { + PermittedAsAndImPackages(R.string.permitted_accessibility_services, + R.string.system_accessibility_always_allowed, vm.chosenPackage, ::choosePackage, + vm.pasPackages, vm::getPasPackages, vm::setPasPackage, vm::setPasPolicy, ::navigateUp) + } + composable { + PermittedAsAndImPackages(R.string.permitted_ime, R.string.system_ime_always_allowed, + vm.chosenPackage, ::choosePackage, vm.pimPackages, vm::getPimPackages, + vm::setPimPackage, vm::setPimPolicy, ::navigateUp) + } + composable { + EnableSystemAppScreen(vm.chosenPackage, ::choosePackage, vm::enableSystemApp, ::navigateUp) + } + composable { + SetDefaultDialerScreen(vm.chosenPackage, ::choosePackage, vm::setDefaultDialer, ::navigateUp) + } + composable { + ManageAppGroupsScreen( + vm.appGroups, + { id, name, apps -> navController.navigate(EditAppGroup(id, name, apps)) }, + ::navigateUp + ) + } + composable { + EditAppGroupScreen( + it.toRoute(), vm::getAppInfo, ::navigateUp, vm::setAppGroup, + vm::deleteAppGroup, ::choosePackage, vm.chosenPackage + ) + } composable { - UserRestrictionScreen(::navigateUp) { - navigate(it) - } + UserRestrictionScreen(vm::getUserRestrictions, ::navigateUp, ::navigate) } composable { - UserRestrictionEditorScreen(::navigateUp) + UserRestrictionEditorScreen(vm.userRestrictions, vm::setUserRestriction, ::navigateUp) } - composable(mapOf(serializableNavTypePair>())) { - UserRestrictionOptionsScreen(it.toRoute(), ::navigateUp) + composable { + UserRestrictionOptionsScreen(it.toRoute(), vm.userRestrictions, + vm::setUserRestriction, vm::createUserRestrictionShortcut, ::navigateUp) } - composable { UsersScreen(::navigateUp, ::navigate) } - composable { UserInfoScreen(::navigateUp) } - composable { UsersOptionsScreen(::navigateUp) } - composable { UserOperationScreen(::navigateUp) } - composable { CreateUserScreen(::navigateUp) } - composable { ChangeUsernameScreen(::navigateUp) } - composable { UserSessionMessageScreen(::navigateUp) } - composable { AffiliationIdScreen(::navigateUp) } + composable { UsersScreen(vm, ::navigateUp, ::navigate) } + composable { UserInfoScreen(vm::getUserInformation, ::navigateUp) } + composable { + UsersOptionsScreen(vm::getLogoutEnabled, vm::setLogoutEnabled, ::navigateUp) + } + composable { + UserOperationScreen(vm::getUserIdentifiers, vm::doUserOperation, + vm::createUserOperationShortcut, ::navigateUp) + } + composable { CreateUserScreen(vm::createUser, ::navigateUp) } + composable { ChangeUsernameScreen(vm::setProfileName, ::navigateUp) } + composable { + UserSessionMessageScreen(vm::getUserSessionMessages, vm::setStartUserSessionMessage, + vm::setEndUserSessionMessage, ::navigateUp) + } + composable { + AffiliationIdScreen(vm.affiliationIds, vm::getAffiliationIds, vm::setAffiliationId, + ::navigateUp) + } - composable { PasswordScreen(::navigateUp, ::navigate) } - composable { PasswordInfoScreen(::navigateUp) } - composable { ResetPasswordTokenScreen(::navigateUp) } - composable { ResetPasswordScreen(::navigateUp) } - composable { RequiredPasswordComplexityScreen(::navigateUp) } - composable { KeyguardDisabledFeaturesScreen(::navigateUp) } + composable { PasswordScreen(vm, ::navigateUp, ::navigate) } + composable { + PasswordInfoScreen(vm::getPasswordComplexity, vm::isPasswordComplexitySufficient, + vm::isUsingUnifiedPassword, ::navigateUp) + } + composable { + ResetPasswordTokenScreen(vm::getRpTokenState, vm::setRpToken, + vm::createActivateRpTokenIntent, vm::clearRpToken, ::navigateUp) + } + composable { ResetPasswordScreen(vm::resetPassword, ::navigateUp) } + composable { + RequiredPasswordComplexityScreen(vm::getRequiredPasswordComplexity, + vm::setRequiredPasswordComplexity, ::navigateUp) + } + composable { + KeyguardDisabledFeaturesScreen(vm::getKeyguardDisableConfig, + vm::setKeyguardDisableConfig, ::navigateUp) + } composable { RequiredPasswordQualityScreen(::navigateUp) } composable { SettingsScreen(::navigateUp, ::navigate) } - composable { SettingsOptionsScreen(::navigateUp) } - composable { - val theme by vm.theme.collectAsStateWithLifecycle() - AppearanceScreen(::navigateUp, theme, vm::changeTheme) + composable { + SettingsOptionsScreen(vm::getDisplayDangerousFeatures, vm::getShortcutsEnabled, + vm::setDisplayDangerousFeatures, vm::setShortcutsEnabled, ::navigateUp) + } + composable { + AppearanceScreen(::navigateUp, vm.theme, vm::changeTheme) + } + composable { + AppLockSettingsScreen(vm.getAppLockConfig(), vm::setAppLockConfig, ::navigateUp) + } + composable { + ApiSettings(vm::getApiEnabled, vm::setApiKey, ::navigateUp) + } + composable { + NotificationsScreen(vm.enabledNotifications, vm::getEnabledNotifications, + vm::setNotificationEnabled, ::navigateUp) } - composable { AppLockSettingsScreen(::navigateUp) } - composable { ApiSettings(::navigateUp) } - composable { NotificationsScreen(::navigateUp) } composable { AboutScreen(::navigateUp) } } DisposableEffect(lifecycleOwner) { @@ -495,9 +734,12 @@ private fun HomeScreen(onNavigate: (Any) -> Unit) { scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { - Column(Modifier.fillMaxSize().padding(it).verticalScroll(rememberScrollState())) { + Column(Modifier + .fillMaxSize() + .padding(it) + .verticalScroll(rememberScrollState())) { if(privilege.device || privilege.profile) { HomePageItem(R.string.system, R.drawable.android_fill0) { onNavigate(SystemManager) } HomePageItem(R.string.network, R.drawable.wifi_fill0) { onNavigate(Network) } @@ -517,7 +759,7 @@ private fun HomeScreen(onNavigate: (Any) -> Unit) { HomePageItem(R.string.users,R.drawable.manage_accounts_fill0) { onNavigate(Users) } HomePageItem(R.string.password_and_keyguard, R.drawable.password_fill0) { onNavigate(Password) } } - Spacer(Modifier.padding(vertical = 20.dp)) + Spacer(Modifier.height(BottomPadding)) } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt index 9372942..d6105c3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt @@ -5,12 +5,15 @@ import android.os.Build.VERSION import org.lsposed.hiddenapibypass.HiddenApiBypass class MyApplication : Application() { + lateinit var myRepo: MyRepository override fun onCreate() { super.onCreate() if (VERSION.SDK_INT >= 28) HiddenApiBypass.setHiddenApiExemptions("") SP = SharedPrefs(applicationContext) + val dbHelper = MyDbHelper(this) + myRepo = MyRepository(dbHelper) Privilege.initialize(applicationContext) - Privilege.updateStatus() + NotificationUtils.createChannels(this) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt new file mode 100644 index 0000000..dfa0805 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -0,0 +1,37 @@ +package com.bintianqi.owndroid + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(DHIZUKU_CLIENTS_TABLE) + db.execSQL(SECURITY_LOGS_TABLE) + db.execSQL(NETWORK_LOGS_TABLE) + db.execSQL(APP_GROUPS_TABLE) + } + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 2) { + db.execSQL(SECURITY_LOGS_TABLE) + } + if (oldVersion < 3) { + db.execSQL(NETWORK_LOGS_TABLE) + } + if (oldVersion < 4) { + db.execSQL(APP_GROUPS_TABLE) + } + } + companion object { + const val DHIZUKU_CLIENTS_TABLE = "CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + + "signature TEXT, permissions TEXT)" + const val SECURITY_LOGS_TABLE = "CREATE TABLE security_logs (id INTEGER, tag INTEGER," + + "level INTEGER, time INTEGER, data TEXT)" + const val NETWORK_LOGS_TABLE = "CREATE TABLE network_logs (id INTEGER, package INTEGER," + + "time INTEGER, type TEXT, host TEXT, count INTEGER, addresses TEXT," + + "address TEXT, port INTEGER)" + const val APP_GROUPS_TABLE = "CREATE TABLE app_groups(" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "name TEXT, apps TEXT)" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt new file mode 100644 index 0000000..62f7c57 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt @@ -0,0 +1,251 @@ +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.dpm.AppGroup +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())) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index db9be42..654ee05 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,10 +1,134 @@ 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.pm.ApplicationInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.IpConfiguration +import android.net.LinkAddress +import android.net.ProxyInfo +import android.net.StaticIpConfiguration +import android.net.Uri +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.net.wifi.WifiSsid +import android.os.Binder +import android.os.Build.VERSION +import android.os.HardwarePropertiesManager +import android.os.UserHandle +import android.os.UserManager +import android.telephony.data.ApnSetting +import androidx.annotation.RequiresApi +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.Privilege.DAR +import com.bintianqi.owndroid.Privilege.DPM +import com.bintianqi.owndroid.dpm.ACTIVATE_DEVICE_OWNER_COMMAND +import com.bintianqi.owndroid.dpm.ApnAuthType +import com.bintianqi.owndroid.dpm.ApnConfig +import com.bintianqi.owndroid.dpm.ApnMvnoType +import com.bintianqi.owndroid.dpm.ApnProtocol +import com.bintianqi.owndroid.dpm.AppGroup +import com.bintianqi.owndroid.dpm.AppStatus +import com.bintianqi.owndroid.dpm.CaCertInfo +import com.bintianqi.owndroid.dpm.CreateUserResult +import com.bintianqi.owndroid.dpm.CreateWorkProfileOptions +import com.bintianqi.owndroid.dpm.DelegatedAdmin +import com.bintianqi.owndroid.dpm.DeviceAdmin +import com.bintianqi.owndroid.dpm.FrpPolicyInfo +import com.bintianqi.owndroid.dpm.HardwareProperties +import com.bintianqi.owndroid.dpm.IntentFilterDirection +import com.bintianqi.owndroid.dpm.IntentFilterOptions +import com.bintianqi.owndroid.dpm.IpMode +import com.bintianqi.owndroid.dpm.KeyguardDisableConfig +import com.bintianqi.owndroid.dpm.KeyguardDisableMode +import com.bintianqi.owndroid.dpm.NetworkStatsData +import com.bintianqi.owndroid.dpm.NetworkStatsTarget +import com.bintianqi.owndroid.dpm.PasswordComplexity +import com.bintianqi.owndroid.dpm.PendingSystemUpdateInfo +import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceInfo +import com.bintianqi.owndroid.dpm.PrivateDnsConfiguration +import com.bintianqi.owndroid.dpm.PrivateDnsMode +import com.bintianqi.owndroid.dpm.ProxyMode +import com.bintianqi.owndroid.dpm.ProxyType +import com.bintianqi.owndroid.dpm.QueryNetworkStatsParams +import com.bintianqi.owndroid.dpm.RecommendedProxyConf +import com.bintianqi.owndroid.dpm.RpTokenState +import com.bintianqi.owndroid.dpm.SsidPolicy +import com.bintianqi.owndroid.dpm.SsidPolicyType +import com.bintianqi.owndroid.dpm.SystemOptionsStatus +import com.bintianqi.owndroid.dpm.SystemUpdatePolicyInfo +import com.bintianqi.owndroid.dpm.UserIdentifier +import com.bintianqi.owndroid.dpm.UserInformation +import com.bintianqi.owndroid.dpm.UserOperationType +import com.bintianqi.owndroid.dpm.WifiInfo +import com.bintianqi.owndroid.dpm.WifiSecurity +import com.bintianqi.owndroid.dpm.WifiStatus +import com.bintianqi.owndroid.dpm.activateOrgProfileCommand +import com.bintianqi.owndroid.dpm.delegatedScopesList +import com.bintianqi.owndroid.dpm.doUserOperationWithContext +import com.bintianqi.owndroid.dpm.getPackageInstaller +import com.bintianqi.owndroid.dpm.handlePrivilegeChange +import com.bintianqi.owndroid.dpm.isValidPackageName +import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage +import com.bintianqi.owndroid.dpm.runtimePermissions +import com.bintianqi.owndroid.dpm.temperatureTypes +import com.rosan.dhizuku.api.Dhizuku +import com.rosan.dhizuku.api.DhizukuRequestPermissionListener +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.InetAddress +import java.security.MessageDigest +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +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 @@ -12,6 +136,1770 @@ class MyViewModel(application: Application): AndroidViewModel(application) { SP.darkTheme = newTheme.darkTheme SP.blackTheme = newTheme.blackTheme } + fun getDisplayDangerousFeatures(): Boolean { + return SP.displayDangerousFeatures + } + fun getShortcutsEnabled(): Boolean { + return SP.shortcuts + } + fun setDisplayDangerousFeatures(state: Boolean) { + SP.displayDangerousFeatures = state + } + fun setShortcutsEnabled(enabled: Boolean) { + SP.shortcuts = enabled + ShortcutUtils.setAllShortcuts(application, enabled) + } + fun getAppLockConfig(): AppLockConfig { + val passwordHash = SP.lockPasswordHash + return AppLockConfig(passwordHash?.ifEmpty { null }, SP.biometricsUnlock, SP.lockWhenLeaving) + } + fun setAppLockConfig(config: AppLockConfig) { + SP.lockPasswordHash = if (config.password == null) { + "" + } else { + config.password.hash() + } + SP.biometricsUnlock = config.biometrics + SP.lockWhenLeaving = config.whenLeaving + } + fun getApiEnabled(): Boolean { + return SP.apiKeyHash?.isNotEmpty() ?: false + } + fun setApiKey(key: String) { + SP.apiKeyHash = if (key.isEmpty()) "" else key.hash() + } + 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 getAppInfo(info: ApplicationInfo) = + AppInfo(info.packageName, info.loadLabel(PM).toString(), info.loadIcon(PM), info.flags) + fun getAppInfo(name: String): AppInfo { + return try { + getAppInfo(PM.getApplicationInfo(name, getInstalledAppsFlags)) + } catch (_: PackageManager.NameNotFoundException) { + AppInfo(name, "???", Color.Transparent.toArgb().toDrawable(), 0) + } + } + + val suspendedPackages = MutableStateFlow(emptyList()) + @RequiresApi(24) + fun getSuspendedPackaged() { + val packages = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isPackageSuspended(DAR, it.packageName) + } + suspendedPackages.value = packages.map { getAppInfo(it) } + } + @RequiresApi(24) + fun setPackageSuspended(name: String, status: Boolean): Boolean { + val result = DPM.setPackagesSuspended(DAR, arrayOf(name), status) + getSuspendedPackaged() + return result.isEmpty() + } + + val hiddenPackages = MutableStateFlow(emptyList()) + fun getHiddenPackages() { + hiddenPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isApplicationHidden(DAR, it.packageName) + }.map { getAppInfo(it) } + } + fun setPackageHidden(name: String, status: Boolean): Boolean { + val result = DPM.setApplicationHidden(DAR, name, status) + getHiddenPackages() + return result + } + + // Uninstall blocked packages + val ubPackages = MutableStateFlow(emptyList()) + fun getUbPackages() { + ubPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { + DPM.isUninstallBlocked(DAR, it.packageName) + }.map { getAppInfo(it) } + } + fun setPackageUb(name: String, status: Boolean) { + DPM.setUninstallBlocked(DAR, name, status) + getUbPackages() + } + + // User control disabled packages + val ucdPackages = MutableStateFlow(emptyList()) + @RequiresApi(30) + fun getUcdPackages() { + ucdPackages.value = DPM.getUserControlDisabledPackages(DAR).distinct().map { + getAppInfo(it) + } + } + @RequiresApi(30) + fun setPackageUcd(name: String, status: Boolean) { + DPM.setUserControlDisabledPackages( + DAR, + ucdPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + ) + getUcdPackages() + } + + val packagePermissions = MutableStateFlow(emptyMap()) + @RequiresApi(23) + fun getPackagePermissions(name: String) { + if (name.isValidPackageName) { + packagePermissions.value = runtimePermissions.associate { + it.id to DPM.getPermissionGrantState(DAR, name, it.id) + } + } else { + packagePermissions.value = emptyMap() + } + } + @RequiresApi(23) + fun setPackagePermission(name: String, permission: String, status: Int): Boolean { + val result = DPM.setPermissionGrantState(DAR, name, permission, status) + getPackagePermissions(name) + return result + } + + // Metered data disabled packages + val mddPackages = MutableStateFlow(emptyList()) + @RequiresApi(28) + fun getMddPackages() { + mddPackages.value = DPM.getMeteredDataDisabledPackages(DAR).distinct().map { getAppInfo(it) } + } + @RequiresApi(28) + fun setPackageMdd(name: String, status: Boolean): Boolean { + val result = DPM.setMeteredDataDisabledPackages( + DAR, mddPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + ) + getMddPackages() + return result.isEmpty() + } + + // 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(name: String, status: Boolean) { + DPM.setKeepUninstalledPackages( + DAR, kuPackages.value.map { it.name }.run { if (status) plus(name) else minus(name) } + ) + getKuPackages() + } + + // Cross profile packages + val cpPackages = MutableStateFlow(emptyList()) + @RequiresApi(30) + fun getCpPackages() { + cpPackages.value = DPM.getCrossProfilePackages(DAR).map { getAppInfo(it) } + } + @RequiresApi(30) + fun setPackageCp(name: String, status: Boolean) { + DPM.setCrossProfilePackages( + DAR, + cpPackages.value.map { it.name }.toSet().run { if (status) plus(name) else minus(name) } + ) + getCpPackages() + } + + // Cross-profile widget providers + val cpwProviders = MutableStateFlow(emptyList()) + fun getCpwProviders() { + cpwProviders.value = DPM.getCrossProfileWidgetProviders(DAR).distinct().map { getAppInfo(it) } + } + fun setCpwProvider(name: String, status: Boolean): Boolean { + val result = if (status) { + DPM.addCrossProfileWidgetProvider(DAR, name) + } else { + DPM.removeCrossProfileWidgetProvider(DAR, name) + } + getCpwProviders() + return result + } + + @RequiresApi(28) + fun clearAppData(name: String, callback: (Boolean) -> Unit) { + DPM.clearApplicationUserData(DAR, name, Executors.newSingleThreadExecutor()) { _, result -> + callback(result) + } + } + + fun uninstallPackage(packageName: String, onComplete: (String?) -> Unit) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @SuppressWarnings("UnsafeIntentLaunch") + context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) + } else { + context.unregisterReceiver(this) + if(statusExtra == PackageInstaller.STATUS_SUCCESS) { + onComplete(null) + } else { + onComplete(parsePackageInstallerMessage(context, intent)) + } + } + } + } + ContextCompat.registerReceiver( + application, receiver, IntentFilter(AppInstallerViewModel.ACTION), null, + null, ContextCompat.RECEIVER_EXPORTED + ) + val pi = if(VERSION.SDK_INT >= 34) { + PendingIntent.getBroadcast( + application, 0, Intent(AppInstallerViewModel.ACTION), + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE + ).intentSender + } else { + PendingIntent.getBroadcast(application, 0, Intent(AppInstallerViewModel.ACTION), PendingIntent.FLAG_MUTABLE).intentSender + } + application.getPackageInstaller().uninstall(packageName, pi) + } + + @RequiresApi(28) + fun installExistingApp(name: String): Boolean { + return DPM.installExistingPackage(DAR, name) + } + + // Credential manager policy + val cmPackages = MutableStateFlow(emptyList()) + @RequiresApi(34) + fun getCmPolicy(): Int { + return DPM.credentialManagerPolicy?.let { policy -> + cmPackages.value = policy.packageNames.distinct().map { getAppInfo(it) } + policy.policyType + } ?: -1 + } + fun setCmPackage(name: String, status: Boolean) { + cmPackages.update { list -> + if (status) list + getAppInfo(name) else list.filter { it.name != name } + } + } + @RequiresApi(34) + fun setCmPolicy(type: Int) { + DPM.credentialManagerPolicy = if (type != -1 && cmPackages.value.isNotEmpty()) { + PackagePolicy(type, cmPackages.value.map { it.name }.toSet()) + } else null + getCmPolicy() + } + + // Permitted input method + val pimPackages = MutableStateFlow(emptyList()) + fun getPimPackages(): Boolean { + return DPM.getPermittedInputMethods(DAR).let { packages -> + pimPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList() + packages == null + } + } + fun setPimPackage(name: String, status: Boolean) { + pimPackages.update { packages -> + if (status) packages + getAppInfo(name) else packages.filter { it.name != name } + } + } + fun setPimPolicy(allowAll: Boolean): Boolean { + val result = DPM.setPermittedInputMethods( + DAR, if (allowAll) null else pimPackages.value.map { it.name }) + getPimPackages() + return result + } + + // Permitted accessibility services + val pasPackages = MutableStateFlow(emptyList()) + fun getPasPackages(): Boolean { + return DPM.getPermittedAccessibilityServices(DAR).let { packages -> + pasPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList() + packages == null + } + } + fun setPasPackage(name: String, status: Boolean) { + pasPackages.update { packages -> + if (status) packages + getAppInfo(name) else packages.filter { it.name != name } + } + } + fun setPasPolicy(allowAll: Boolean): Boolean { + val result = DPM.setPermittedAccessibilityServices( + DAR, if (allowAll) null else pasPackages.value.map { it.name }) + getPasPackages() + return result + } + + fun enableSystemApp(name: String) { + DPM.enableSystemApp(DAR, name) + } + + val appStatus = MutableStateFlow(AppStatus(false, false, false, false, false, false)) + fun getAppStatus(name: String) { + appStatus.value = AppStatus( + if (VERSION.SDK_INT >= 24) DPM.isPackageSuspended(DAR, name) else false, + DPM.isApplicationHidden(DAR, name), + DPM.isUninstallBlocked(DAR, name), + if (VERSION.SDK_INT >= 30) name in DPM.getUserControlDisabledPackages(DAR) else false, + if (VERSION.SDK_INT >= 28) name in DPM.getMeteredDataDisabledPackages(DAR) else false, + if (VERSION.SDK_INT >= 28) DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true else false + ) + } + // Application details + @RequiresApi(24) + fun adSetPackageSuspended(name: String, status: Boolean) { + DPM.setPackagesSuspended(DAR, arrayOf(name), status) + appStatus.update { it.copy(suspend = DPM.isPackageSuspended(DAR, name)) } + } + fun adSetPackageHidden(name: String, status: Boolean) { + DPM.setApplicationHidden(DAR, name, status) + appStatus.update { it.copy(hide = DPM.isApplicationHidden(DAR, name)) } + } + fun adSetPackageUb(name: String, status: Boolean) { + DPM.setUninstallBlocked(DAR, name, status) + appStatus.update { it.copy(uninstallBlocked = DPM.isUninstallBlocked(DAR, name)) } + } + @RequiresApi(30) + fun adSetPackageUcd(name: String, status: Boolean) { + DPM.setUserControlDisabledPackages(DAR, + DPM.getUserControlDisabledPackages(DAR).run { if (status) plus(name) else minus(name) }) + appStatus.update { + it.copy(userControlDisabled = name in DPM.getUserControlDisabledPackages(DAR)) + } + } + @RequiresApi(28) + fun adSetPackageMdd(name: String, status: Boolean) { + DPM.setMeteredDataDisabledPackages(DAR, + DPM.getMeteredDataDisabledPackages(DAR).run { if (status) plus(name) else minus(name) }) + appStatus.update { + it.copy(meteredDataDisabled = name in DPM.getMeteredDataDisabledPackages(DAR)) + } + } + @RequiresApi(28) + fun adSetPackageKu(name: String, status: Boolean) { + DPM.setKeepUninstalledPackages(DAR, + DPM.getKeepUninstalledPackages(DAR)?.run { if (status) plus(name) else minus(name) } ?: emptyList()) + appStatus.update { + it.copy(keepUninstalled = DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true ) + } + } + + @RequiresApi(34) + fun setDefaultDialer(name: String): Boolean { + return try { + DPM.setDefaultDialerApplication(name) + true + } catch (e: IllegalArgumentException) { + e.printStackTrace() + false + } + } + + val appGroups = MutableStateFlow(emptyList()) + init { + getAppGroups() + } + fun getAppGroups() { + appGroups.value = myRepo.getAppGroups() + } + fun setAppGroup(id: Int?, name: String, apps: List) { + myRepo.setAppGroup(id, name, apps) + getAppGroups() + } + fun deleteAppGroup(id: Int) { + myRepo.deleteAppGroup(id) + appGroups.update { group -> + group.filter { it.id != id } + } + } + + @RequiresApi(24) + fun reboot() { + DPM.reboot(DAR) + } + @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.run { device || org }) + DPM.getAutoTimeEnabled(DAR) else false, + autoTimeZoneEnabled = if (VERSION.SDK_INT >= 30 && privilege.run { device || org }) + DPM.getAutoTimeZoneEnabled(DAR) else false, + autoTimeRequired = if (VERSION.SDK_INT < 30) DPM.autoTimeRequired else false, + masterVolumeMuted = DPM.isMasterVolumeMuted(DAR), + backupServiceEnabled = if (VERSION.SDK_INT >= 26) DPM.isBackupServiceEnabled(DAR) else false, + btContactSharingDisabled = if (VERSION.SDK_INT >= 23 && privilege.work) + DPM.getBluetoothContactSharingDisabled(DAR) else false, + commonCriteriaMode = if (VERSION.SDK_INT >= 30) DPM.isCommonCriteriaModeEnabled(DAR) else false, + usbSignalEnabled = if (VERSION.SDK_INT >= 31) DPM.isUsbDataSignalingEnabled else false, + canDisableUsbSignal = if (VERSION.SDK_INT >= 31) DPM.canUsbDataSignalingBeDisabled() else false + ) + } + fun setCameraDisabled(disabled: Boolean) { + DPM.setCameraDisabled(DAR, disabled) + 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)) + } + } + @RequiresApi(23) + fun setStatusBarDisabled(disabled: Boolean) { + val result = DPM.setStatusBarDisabled(DAR, disabled) + if (result) systemOptionsStatus.update { it.copy(statusBarDisabled = disabled) } + } + @RequiresApi(30) + fun setAutoTimeEnabled(enabled: Boolean) { + DPM.setAutoTimeEnabled(DAR, enabled) + systemOptionsStatus.update { it.copy(autoTimeEnabled = DPM.getAutoTimeEnabled(DAR)) } + } + @RequiresApi(30) + fun setAutoTimeZoneEnabled(enabled: Boolean) { + DPM.setAutoTimeZoneEnabled(DAR, enabled) + systemOptionsStatus.update { + it.copy(autoTimeZoneEnabled = DPM.getAutoTimeZoneEnabled(DAR)) + } + } + @Suppress("DEPRECATION") + fun setAutoTimeRequired(required: Boolean) { + DPM.setAutoTimeRequired(DAR, required) + systemOptionsStatus.update { it.copy(autoTimeRequired = DPM.autoTimeRequired) } + } + fun setMasterVolumeMuted(muted: Boolean) { + DPM.setMasterVolumeMuted(DAR, muted) + 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)) + } + } + @RequiresApi(23) + fun setBtContactSharingDisabled(disabled: Boolean) { + DPM.setBluetoothContactSharingDisabled(DAR, disabled) + systemOptionsStatus.update { + it.copy(btContactSharingDisabled = DPM.getBluetoothContactSharingDisabled(DAR)) + } + } + @RequiresApi(30) + fun setCommonCriteriaModeEnabled(enabled: Boolean) { + DPM.setCommonCriteriaModeEnabled(DAR, enabled) + systemOptionsStatus.update { + it.copy(commonCriteriaMode = DPM.isCommonCriteriaModeEnabled(DAR)) + } + } + @RequiresApi(31) + fun setUsbSignalEnabled(enabled: Boolean) { + DPM.isUsbDataSignalingEnabled = enabled + systemOptionsStatus.update { it.copy(usbSignalEnabled = DPM.isUsbDataSignalingEnabled) } + } + @RequiresApi(23) + fun setKeyguardDisabled(disabled: Boolean): Boolean { + return DPM.setKeyguardDisabled(DAR, disabled) + } + fun lockScreen(evictKey: Boolean) { + if (VERSION.SDK_INT >= 26 && Privilege.status.value.work) { + DPM.lockNow(if (evictKey) DevicePolicyManager.FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY else 0) + } else { + DPM.lockNow() + } + } + val hardwareProperties = MutableStateFlow(HardwareProperties()) + var hpRefreshInterval = 1000L + fun setHpRefreshInterval(interval: Float) { + hpRefreshInterval = (interval * 1000).toLong() + } + @RequiresApi(24) + suspend fun getHardwareProperties() { + val hpm = application.getSystemService(HardwarePropertiesManager::class.java) + while (true) { + val properties = HardwareProperties( + temperatureTypes.map { (type, _) -> + type to hpm.getDeviceTemperatures(type, HardwarePropertiesManager.TEMPERATURE_CURRENT).toList() + }.toMap(), + hpm.cpuUsages.map { it.active to it.total }, + hpm.fanSpeeds.toList() + ) + if (properties.cpuUsages.isEmpty() && properties.fanSpeeds.isEmpty() && + properties.temperatures.isEmpty()) { + break + } + hardwareProperties.value = properties + delay(hpRefreshInterval) + } + } + @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) + } + @RequiresApi(23) + fun getPermissionPolicy(): Int { + return DPM.getPermissionPolicy(DAR) + } + @RequiresApi(23) + fun setPermissionPolicy(policy: Int) { + DPM.setPermissionPolicy(DAR, policy) + } + @RequiresApi(34) + fun getMtePolicy(): Int { + return DPM.mtePolicy + } + @RequiresApi(34) + fun setMtePolicy(policy: Int): Boolean { + return try { + DPM.mtePolicy = policy + true + } catch (_: UnsupportedOperationException) { + false + } + } + @RequiresApi(31) + fun getNsAppPolicy(): Int { + return DPM.nearbyAppStreamingPolicy + } + @RequiresApi(31) + fun setNsAppPolicy(policy: Int) { + DPM.nearbyAppStreamingPolicy = policy + } + @RequiresApi(31) + fun getNsNotificationPolicy(): Int { + return DPM.nearbyNotificationStreamingPolicy + } + @RequiresApi(31) + fun setNsNotificationPolicy(policy: Int) { + DPM.nearbyNotificationStreamingPolicy = policy + } + val lockTaskPackages = MutableStateFlow(emptyList()) + @RequiresApi(26) + fun getLockTaskPackages() { + lockTaskPackages.value = DPM.getLockTaskPackages(DAR).map { getAppInfo(it) } + } + @RequiresApi(26) + fun setLockTaskPackage(name: String, status: Boolean) { + DPM.setLockTaskPackages(DAR, + lockTaskPackages.value.map { it.name } + .run { if (status) plus(name) else minus(name) } + .toTypedArray() + ) + getLockTaskPackages() + } + @RequiresApi(28) + fun startLockTaskMode(packageName: String, activity: String): Boolean { + if (!DPM.isLockTaskPermitted(packageName)) { + val list = lockTaskPackages.value.map { it.name } + packageName + DPM.setLockTaskPackages(DAR, list.toTypedArray()) + getLockTaskPackages() + } + val options = ActivityOptions.makeBasic().setLockTaskEnabled(true) + val intent = if(activity.isNotEmpty()) { + Intent().setComponent(ComponentName(packageName, activity)) + } else PM.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + application.startActivity(intent, options.toBundle()) + 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) + } + } + } + @RequiresApi(23) + fun getSystemUpdatePolicy(): SystemUpdatePolicyInfo { + val policy = DPM.systemUpdatePolicy + return SystemUpdatePolicyInfo( + policy?.policyType ?: -1, policy?.installWindowStart ?: 0, policy?.installWindowEnd ?: 0 + ) + } + @RequiresApi(23) + fun setSystemUpdatePolicy(info: SystemUpdatePolicyInfo) { + val policy = when (info.type) { + SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC -> SystemUpdatePolicy.createAutomaticInstallPolicy() + SystemUpdatePolicy.TYPE_INSTALL_WINDOWED -> + SystemUpdatePolicy.createWindowedInstallPolicy(info.start, info.end) + SystemUpdatePolicy.TYPE_POSTPONE -> SystemUpdatePolicy.createPostponeInstallPolicy() + else -> null + } + DPM.setSystemUpdatePolicy(DAR, policy) + } + @RequiresApi(26) + fun getPendingSystemUpdate(): PendingSystemUpdateInfo { + val update = DPM.getPendingSystemUpdate(DAR) + return PendingSystemUpdateInfo(update != null, update?.receivedTime ?: 0, + update?.securityPatchState == SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE) + } + @RequiresApi(29) + fun installSystemUpdate(uri: Uri, callback: (String) -> Unit) { + val callback = object: InstallSystemUpdateCallback() { + override fun onInstallUpdateError(errorCode: Int, errorMessage: String) { + super.onInstallUpdateError(errorCode, errorMessage) + val errDetail = when(errorCode) { + UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low + UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid + UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver + UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist + else -> R.string.unknown_error + } + callback(application.getString(errDetail) + "\n$errorMessage") + } + } + DPM.installSystemUpdate(DAR, uri, application.mainExecutor, callback) + } + @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()) + @RequiresApi(24) + fun getUserRestrictions() { + val bundle = DPM.getUserRestrictions(DAR) + userRestrictions.value = bundle.keySet().associateWith { bundle.getBoolean(it) } + } + fun setUserRestriction(name: String, state: Boolean): Boolean { + return try { + if (state) { + DPM.addUserRestriction(DAR, name) + } else { + DPM.clearUserRestriction(DAR, name) + } + userRestrictions.update { it.plus(name to state) } + ShortcutUtils.updateUserRestrictionShortcut(application, name, !state, true) + true + } catch (_: SecurityException) { + false + } + } + fun createUserRestrictionShortcut(id: String): Boolean { + return ShortcutUtils.setUserRestrictionShortcut( + application, id, userRestrictions.value[id] ?: true + ) + } + fun createWorkProfile(options: CreateWorkProfileOptions): Intent { + val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) + if (VERSION.SDK_INT >= 23) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, + MyAdminComponent + ) + } else { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_PACKAGE_NAME, + application.packageName + ) + } + if (options.migrateAccount && VERSION.SDK_INT >= 22) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE, + Account(options.accountName, options.accountType) + ) + if (VERSION.SDK_INT >= 26) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION, + options.keepAccount + ) + } + } + if (VERSION.SDK_INT >= 24) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, + options.skipEncrypt + ) + } + if (VERSION.SDK_INT >= 33) { + intent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_ALLOW_OFFLINE, options.offline) + } + return intent + } + fun activateOrgProfileByShizuku(callback: (Boolean) -> Unit) { + viewModelScope.launch(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) + val flags = when(options.direction) { + IntentFilterDirection.ToManaged -> DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED + IntentFilterDirection.ToParent -> DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT + IntentFilterDirection.Both -> DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED or + DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT + } + DPM.addCrossProfileIntentFilter(DAR, filter, flags) + } + + val UM = application.getSystemService(Context.USER_SERVICE) as UserManager + @RequiresApi(28) + fun getLogoutEnabled(): Boolean { + return DPM.isLogoutEnabled + } + @RequiresApi(28) + fun setLogoutEnabled(enabled: Boolean) { + DPM.setLogoutEnabled(DAR, enabled) + } + fun getUserInformation(): UserInformation { + val uh = Binder.getCallingUserHandle() + return UserInformation( + if (VERSION.SDK_INT >= 24) UserManager.supportsMultipleUsers() else false, + if (VERSION.SDK_INT >= 31) UserManager.isHeadlessSystemUserMode() else false, + if (VERSION.SDK_INT >= 23) UM.isSystemUser else false, + if (VERSION.SDK_INT >= 34) UM.isAdminUser else false, + if (VERSION.SDK_INT >= 25) UM.isDemoUser else false, + if (VERSION.SDK_INT >= 23) UM.getUserCreationTime(uh) else 0, + if (VERSION.SDK_INT >= 28) DPM.isLogoutEnabled else false, + if (VERSION.SDK_INT >= 28) DPM.isEphemeralUser(DAR) else false, + if (VERSION.SDK_INT >= 28) DPM.isAffiliatedUser else false, + UM.getSerialNumberForUser(uh) + ) + } + @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_CURRENT_USER -> R.string.fail_current_user + else -> R.string.unknown + } + } + @RequiresApi(24) + fun createUser(name: String, flags: Int, callback: (CreateUserResult) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + try { + val uh = DPM.createAndManageUser(DAR, name, DAR, null, flags) + if (uh == null) { + callback(CreateUserResult(R.string.failed)) + } else { + callback(CreateUserResult(R.string.succeeded, UM.getSerialNumberForUser(uh))) + } + } catch (e: Exception) { + e.printStackTrace() + if (VERSION.SDK_INT >= 28 && e is UserManager.UserOperationException) { + callback(CreateUserResult(getUserOperationResultText(e.userOperationResult))) + } else { + callback(CreateUserResult(R.string.error)) + } + } + } + } + val affiliationIds = MutableStateFlow(emptyList()) + @RequiresApi(26) + fun getAffiliationIds() { + affiliationIds.value = DPM.getAffiliationIds(DAR).toList() + } + @RequiresApi(26) + fun setAffiliationId(id: String, state: Boolean) { + val newList = affiliationIds.value.run { if (state) plus(id) else minus(id) } + DPM.setAffiliationIds(DAR, newList.toSet()) + affiliationIds.value = newList + } + fun setProfileName(name: String) { + DPM.setProfileName(DAR, name) + } + @RequiresApi(23) + fun setUserIcon(bitmap: Bitmap) { + DPM.setUserIcon(DAR, bitmap) + } + @RequiresApi(28) + fun 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() + @RequiresApi(23) + fun readNetworkStats(stats: NetworkStats): List { + val list = mutableListOf() + while (stats.hasNextBucket()) { + val bucket = NetworkStats.Bucket() + stats.getNextBucket(bucket) + list += readNetworkStatsBucket(bucket) + } + stats.close() + return list + } + @RequiresApi(23) + fun readNetworkStatsBucket(bucket: NetworkStats.Bucket): NetworkStatsData { + return NetworkStatsData( + bucket.rxBytes, bucket.rxPackets, bucket.txBytes, bucket.txPackets, + bucket.uid, bucket.state, bucket.startTimeStamp, bucket.endTimeStamp, + if (VERSION.SDK_INT >= 24) bucket.tag else null, + if (VERSION.SDK_INT >= 24) bucket.roaming else null, + if (VERSION.SDK_INT >= 26) bucket.metered else null + ) + } + @Suppress("NewApi") + fun queryNetworkStats(params: QueryNetworkStatsParams, callback: (String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val nsm = application.getSystemService(NetworkStatsManager::class.java) + try { + val data = when (params.target) { + NetworkStatsTarget.Device -> listOf(readNetworkStatsBucket( + nsm.querySummaryForDevice( + params.networkType.type, null, params.startTime, params.endTime + ) + )) + NetworkStatsTarget.User -> listOf(readNetworkStatsBucket( + nsm.querySummaryForUser( + params.networkType.type, null, params.startTime, params.endTime + ) + )) + NetworkStatsTarget.Uid -> readNetworkStats(nsm.queryDetailsForUid( + params.networkType.type, null, params.startTime, params.endTime, params.uid + )) + NetworkStatsTarget.UidTag -> readNetworkStats(nsm.queryDetailsForUidTag( + params.networkType.type, null, params.startTime, params.endTime, + params.uid, params.tag + )) + NetworkStatsTarget.UidTagState -> readNetworkStats( + nsm.queryDetailsForUidTagState( + params.networkType.type, null, params.startTime, params.endTime, + params.uid, params.tag, params.state.id + ) + ) + } + networkStatsData = data + withContext(Dispatchers.Main) { + if (data.isEmpty()) { + callback(application.getString(R.string.no_data)) + } else { + callback(null) + } + } + } catch(e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + callback(e.message ?: "") + } + } + } + } + fun clearNetworkStats() { + networkStatsData = emptyList() + } + @RequiresApi(29) + fun getPrivateDns(): PrivateDnsConfiguration { + 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 DPM.setResetPasswordToken(DAR, token.encodeToByteArray()) + } + @RequiresApi(26) + fun clearRpToken(): Boolean { + return DPM.clearResetPasswordToken(DAR) + } + @RequiresApi(26) + fun createActivateRpTokenIntent(): Intent? { + val km = application.getSystemService(KeyguardManager::class.java) + val title = application.getString(R.string.activate_reset_password_token) + return km.createConfirmDeviceCredentialIntent(title, "") + } + fun resetPassword(password: String, token: String, flags: Int): Boolean { + return if (VERSION.SDK_INT >= 26) { + DPM.resetPasswordWithToken(DAR, password, token.encodeToByteArray(), flags) + } else { + DPM.resetPassword(password, flags) + } + } + @RequiresApi(31) + fun getRequiredPasswordComplexity(): PasswordComplexity { + val complexity = DPM.requiredPasswordComplexity + return PasswordComplexity.entries.find { it.id == complexity }!! + } + @RequiresApi(31) + fun setRequiredPasswordComplexity(complexity: PasswordComplexity) { + DPM.requiredPasswordComplexity = complexity.id + } + fun getKeyguardDisableConfig(): KeyguardDisableConfig { + val flags = DPM.getKeyguardDisabledFeatures(DAR) + val mode = when (flags) { + DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -> KeyguardDisableMode.None + DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -> KeyguardDisableMode.All + else -> KeyguardDisableMode.Custom + } + return KeyguardDisableConfig(mode, flags) + } + fun setKeyguardDisableConfig(config: KeyguardDisableConfig) { + val flags = when (config.mode) { + KeyguardDisableMode.None -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE + KeyguardDisableMode.All -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL + else -> config.flags + } + DPM.setKeyguardDisabledFeatures(DAR, flags) + } + fun getMaxTimeToLock(): Long { + return DPM.getMaximumTimeToLock(DAR) + } + @RequiresApi(26) + fun getRequiredStrongAuthTimeout(): Long { + return DPM.getRequiredStrongAuthTimeout(DAR) + } + fun getPasswordExpirationTimeout(): Long { + return DPM.getPasswordExpirationTimeout(DAR) + } + fun getMaxFailedPasswordsForWipe(): Int { + return DPM.getMaximumFailedPasswordsForWipe(DAR) + } + fun getPasswordHistoryLength(): Int { + return DPM.getPasswordHistoryLength(DAR) + } + fun setMaxTimeToLock(time: Long) { + DPM.setMaximumTimeToLock(DAR, time) + } + @RequiresApi(26) + fun setRequiredStrongAuthTimeout(time: Long) { + DPM.setRequiredStrongAuthTimeout(DAR, time) + } + fun setPasswordExpirationTimeout(time: Long) { + DPM.setPasswordExpirationTimeout(DAR, time) + } + fun setMaxFailedPasswordsForWipe(times: Int) { + DPM.setMaximumFailedPasswordsForWipe(DAR, times) + } + fun setPasswordHistoryLength(length: Int) { + DPM.setPasswordHistoryLength(DAR, length) + } } data class ThemeSettings( diff --git a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt index 9cc55d8..2c326f8 100644 --- a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt @@ -1,49 +1,86 @@ package com.bintianqi.owndroid -import android.Manifest -import android.app.Notification -import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context -import android.content.pm.PackageManager -import android.os.Build +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat object NotificationUtils { - fun checkPermission(context: Context): Boolean { - return if(Build.VERSION.SDK_INT >= 33) - context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - else false + fun createChannels(context: Context) { + val channels = MyNotificationChannel.entries.map { + NotificationChannelCompat.Builder(it.id, it.importance) + .setName(context.getString(it.text)) + .build() + } + NotificationManagerCompat.from(context).createNotificationChannelsCompat(channels) } - fun registerChannels(context: Context) { - if(Build.VERSION.SDK_INT < 26) return + fun sendBasicNotification( + context: Context, type: NotificationType, text: String + ) { + val notification = NotificationCompat.Builder(context, type.channel.id) + .setSmallIcon(type.icon) + .setContentTitle(context.getString(type.text)) + .setContentText(text) + .build() val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val lockTaskMode = NotificationChannel(Channel.LOCK_TASK_MODE, context.getString(R.string.lock_task_mode), NotificationManager.IMPORTANCE_HIGH) - val events = NotificationChannel(Channel.EVENTS, context.getString(R.string.events), NotificationManager.IMPORTANCE_HIGH) - nm.createNotificationChannels(listOf(lockTaskMode, events)) + nm.notify(type.id, notification) } - fun notify(context: Context, id: Int, notification: Notification) { - val sp = context.getSharedPreferences("data", Context.MODE_PRIVATE) - if(sp.getBoolean("n_$id", true) && checkPermission(context)) { - registerChannels(context) - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(id, notification) + fun notifyEvent(context: Context, type: NotificationType, text: String) { + val enabledNotifications = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } + if (enabledNotifications == null || type.id in enabledNotifications) { + sendBasicNotification(context, type, text) } } - object Channel { - const val LOCK_TASK_MODE = "LockTaskMode" - const val EVENTS = "Events" + fun cancel(context: Context, type: NotificationType) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.cancel(type.id) } - object ID { - const val LOCK_TASK_MODE = 1 - const val PASSWORD_CHANGED = 2 - const val USER_ADDED = 3 - const val USER_STARTED = 4 - const val USER_SWITCHED = 5 - const val USER_STOPPED = 6 - const val USER_REMOVED = 7 - const val BUG_REPORT_SHARED = 8 - const val BUG_REPORT_SHARING_DECLINED = 9 - const val BUG_REPORT_FAILED = 10 - const val SYSTEM_UPDATE_PENDING = 11 - } -} \ No newline at end of file +} + +enum class NotificationType( + val id: Int, val text: Int, val icon: Int, val channel: MyNotificationChannel +) { + LockTaskMode( + 1, R.string.lock_task_mode, R.drawable.lock_fill0, MyNotificationChannel.LockTaskMode + ), + PasswordChanged( + 2, R.string.password_changed, R.drawable.password_fill0, MyNotificationChannel.Events + ), + UserAdded(3, R.string.user_added, R.drawable.person_add_fill0, MyNotificationChannel.Events), + UserStarted(4, R.string.user_started, R.drawable.person_fill0, MyNotificationChannel.Events), + UserSwitched(5, R.string.user_switched, R.drawable.person_fill0, MyNotificationChannel.Events), + UserStopped(6, R.string.user_stopped, R.drawable.person_off, MyNotificationChannel.Events), + UserRemoved( + 7, R.string.user_removed, R.drawable.person_remove_fill0, MyNotificationChannel.Events + ), + BugReportShared( + 8, R.string.bug_report_shared, R.drawable.bug_report_fill0, MyNotificationChannel.Events + ), + BugReportSharingDeclined( + 9, R.string.bug_report_sharing_declined, R.drawable.bug_report_fill0, + MyNotificationChannel.Events + ), + BugReportFailed( + 10, R.string.bug_report_failed, R.drawable.bug_report_fill0, MyNotificationChannel.Events + ), + SystemUpdatePending( + 11, R.string.system_update_pending, R.drawable.system_update_fill0, + MyNotificationChannel.Events + ), + SecurityLogsCollected( + 12, R.string.security_logs_collected, R.drawable.description_fill0, + MyNotificationChannel.SecurityLogging + ), + NetworkLogsCollected( + 13, R.string.network_logs_collected, R.drawable.description_fill0, + MyNotificationChannel.NetworkLogging + ), +} + +enum class MyNotificationChannel(val id: String, val text: Int, val importance: Int) { + LockTaskMode("LockTaskMode", R.string.lock_task_mode, NotificationManagerCompat.IMPORTANCE_HIGH), + Events("Events", R.string.events, NotificationManagerCompat.IMPORTANCE_LOW), + SecurityLogging("SecurityLogging", R.string.security_logging, NotificationManagerCompat.IMPORTANCE_MIN), + NetworkLogging("NetworkLogging", R.string.network_logging, NotificationManagerCompat.IMPORTANCE_MIN) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt index c63cf79..ec7b1f7 100644 --- a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt +++ b/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt @@ -1,26 +1,18 @@ package com.bintianqi.owndroid -import android.content.Context -import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.os.Build -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -44,10 +36,8 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -62,35 +52,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ui.theme.OwnDroidTheme import com.google.accompanist.drawablepainter.rememberDrawablePainter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -class PackageChooserActivity: ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val vm by viewModels() - enableEdgeToEdge() - setContent { - val theme by vm.theme.collectAsStateWithLifecycle() - OwnDroidTheme(theme) { - AppChooserScreen(ApplicationsList(false), { - setResult(0, Intent().putExtra("package", it)) - finish() - }, {}) - } - } - } -} - -val installedApps = MutableStateFlow(emptyList()) - data class AppInfo( val name: String, val label: String, @@ -105,11 +70,14 @@ private fun searchInString(query: String, content: String) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Unit, onSwitchView: () -> Unit) { - val packages by installedApps.collectAsStateWithLifecycle() - val coroutine = rememberCoroutineScope() +fun AppChooserScreen( + canSwitchView: Boolean, packageList: MutableStateFlow>, + refreshProgress: MutableStateFlow, onChoosePackage: (String?) -> Unit, + onSwitchView: () -> Unit, onRefresh: () -> Unit +) { + val packages by packageList.collectAsStateWithLifecycle() val context = LocalContext.current - var progress by remember { mutableFloatStateOf(1F) } + val progress by refreshProgress.collectAsStateWithLifecycle() var system by rememberSaveable { mutableStateOf(false) } var query by rememberSaveable { mutableStateOf("") } var searchMode by rememberSaveable { mutableStateOf(false) } @@ -119,7 +87,7 @@ fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Uni } val focusMgr = LocalFocusManager.current LaunchedEffect(Unit) { - if(packages.size <= 1) getInstalledApps(coroutine, context) { progress = it } + if(packages.size <= 1) onRefresh() } Scaffold( topBar = { @@ -135,20 +103,17 @@ fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Uni }) { Icon(painter = painterResource(R.drawable.filter_alt_fill0), contentDescription = null) } - IconButton( - { getInstalledApps(coroutine, context) { progress = it } }, - enabled = progress == 1F - ) { + IconButton(onRefresh, enabled = progress == 1F) { Icon(painter = painterResource(R.drawable.refresh_fill0), contentDescription = null) } - if(params.canSwitchView) IconButton(onSwitchView) { + if (canSwitchView) IconButton(onSwitchView) { Icon(Icons.AutoMirrored.Default.List, null) } } }, title = { if(searchMode) { - val fr = FocusRequester() + val fr = remember { FocusRequester() } LaunchedEffect(Unit) { fr.requestFocus() } OutlinedTextField( value = query, @@ -180,7 +145,7 @@ fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Uni colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn(Modifier.fillMaxSize().padding(paddingValues)) { if (progress < 1F) stickyHeader { @@ -208,25 +173,7 @@ fun AppChooserScreen(params: ApplicationsList, onChoosePackage: (String?) -> Uni } } } - item { Spacer(Modifier.padding(vertical = 30.dp)) } - } - } -} - -fun getInstalledApps(scope: CoroutineScope, context: Context, onProgressUpdated: (Float) -> Unit) { - installedApps.value = emptyList() - scope.launch(Dispatchers.IO) { - val pm = context.packageManager - val apps = pm.getInstalledApplications(getInstalledAppsFlags) - for(pkg in apps) { - val label = pkg.loadLabel(pm).toString() - val icon = pkg.loadIcon(pm) - withContext(Dispatchers.Main) { - installedApps.update { - it + AppInfo(pkg.packageName, label, icon, pkg.flags) - } - onProgressUpdated(installedApps.value.size.toFloat() / apps.size) - } + item { Spacer(Modifier.height(BottomPadding)) } } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt index 242a16d..5b2f284 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt @@ -13,24 +13,24 @@ import kotlinx.coroutines.flow.MutableStateFlow object Privilege { fun initialize(context: Context) { if (SP.dhizuku) { - Dhizuku.init(context) - val hasPermission = try { - Dhizuku.isPermissionGranted() - } catch(_: Exception) { - false - } - if (hasPermission) { - val dhizukuDpm = binderWrapperDevicePolicyManager(context) - if (dhizukuDpm != null) { - DPM = dhizukuDpm - DAR = Dhizuku.getOwnerComponent() - return + 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 diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index e7b88d9..b542132 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -6,42 +6,35 @@ import android.app.admin.DeviceAdminReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.Binder import android.os.Build.VERSION import android.os.UserHandle import android.os.UserManager import androidx.core.app.NotificationCompat -import com.bintianqi.owndroid.dpm.handleNetworkLogs import com.bintianqi.owndroid.dpm.handlePrivilegeChange -import com.bintianqi.owndroid.dpm.processSecurityLogs -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import com.bintianqi.owndroid.dpm.retrieveNetworkLogs +import com.bintianqi.owndroid.dpm.retrieveSecurityLogs class Receiver : DeviceAdminReceiver() { override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) if(VERSION.SDK_INT >= 26 && intent.action == "com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") { - val dpm = getManager(context) val receiver = ComponentName(context, this::class.java) - val packages = dpm.getLockTaskPackages(receiver) - dpm.setLockTaskPackages(receiver, arrayOf()) - dpm.setLockTaskPackages(receiver, packages) + val packages = Privilege.DPM.getLockTaskPackages(receiver) + Privilege.DPM.setLockTaskPackages(receiver, arrayOf()) + Privilege.DPM.setLockTaskPackages(receiver, packages) } } override fun onEnabled(context: Context, intent: Intent) { super.onEnabled(context, intent) Privilege.updateStatus() - handlePrivilegeChange(context) + if (Binder.getCallingUid() / 100000 != 0) handlePrivilegeChange(context) } override fun onDisabled(context: Context, intent: Intent) { super.onDisabled(context, intent) Privilege.updateStatus() - handlePrivilegeChange(context) } override fun onProfileProvisioningComplete(context: Context, intent: Intent) { @@ -51,92 +44,79 @@ class Receiver : DeviceAdminReceiver() { override fun onNetworkLogsAvailable(context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int) { super.onNetworkLogsAvailable(context, intent, batchToken, networkLogsCount) - if(VERSION.SDK_INT >= 26) { - CoroutineScope(Dispatchers.IO).launch { - handleNetworkLogs(context, batchToken) - } + if (VERSION.SDK_INT >= 26) { + retrieveNetworkLogs(context.applicationContext as MyApplication, batchToken) } } override fun onSecurityLogsAvailable(context: Context, intent: Intent) { super.onSecurityLogsAvailable(context, intent) - if(VERSION.SDK_INT >= 24) { - CoroutineScope(Dispatchers.IO).launch { - val events = getManager(context).retrieveSecurityLogs(MyAdminComponent) ?: return@launch - val file = context.filesDir.resolve("SecurityLogs.json") - val fileExists = file.exists() - file.outputStream().use { - if(fileExists) it.write(",".encodeToByteArray()) - processSecurityLogs(events, it) - } - } + if (VERSION.SDK_INT >= 24) { + retrieveSecurityLogs(context.applicationContext as MyApplication) } } override fun onLockTaskModeEntering(context: Context, intent: Intent, pkg: String) { super.onLockTaskModeEntering(context, intent, pkg) - if(!NotificationUtils.checkPermission(context)) return - NotificationUtils.registerChannels(context) - val intent = Intent(context, this::class.java).setAction("com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") - val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.LOCK_TASK_MODE) + val stopIntent = Intent(context, this::class.java) + .setAction("com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") + val pendingIntent = PendingIntent.getBroadcast(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE) + val notification = NotificationCompat.Builder(context, MyNotificationChannel.LockTaskMode.id) .setContentTitle(context.getText(R.string.lock_task_mode)) .setSmallIcon(R.drawable.lock_fill0) .addAction(NotificationCompat.Action.Builder(null, context.getString(R.string.stop), pendingIntent).build()) - NotificationUtils.notify(context, NotificationUtils.ID.LOCK_TASK_MODE, builder.build()) + .build() + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NotificationType.LockTaskMode.id, notification) } override fun onLockTaskModeExiting(context: Context, intent: Intent) { super.onLockTaskModeExiting(context, intent) - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.cancel(NotificationUtils.ID.LOCK_TASK_MODE) + NotificationUtils.cancel(context, NotificationType.LockTaskMode) } override fun onPasswordChanged(context: Context, intent: Intent, userHandle: UserHandle) { super.onPasswordChanged(context, intent, userHandle) - sendUserRelatedNotification(context, userHandle, NotificationUtils.ID.PASSWORD_CHANGED, R.string.password_changed, R.drawable.password_fill0) + sendUserRelatedNotification(context, userHandle, NotificationType.PasswordChanged) } override fun onUserAdded(context: Context, intent: Intent, addedUser: UserHandle) { super.onUserAdded(context, intent, addedUser) - sendUserRelatedNotification(context, addedUser, NotificationUtils.ID.USER_ADDED, R.string.user_added, R.drawable.person_add_fill0) + sendUserRelatedNotification(context, addedUser, NotificationType.UserAdded) } override fun onUserStarted(context: Context, intent: Intent, startedUser: UserHandle) { super.onUserStarted(context, intent, startedUser) - sendUserRelatedNotification(context, startedUser, NotificationUtils.ID.USER_STARTED, R.string.user_started, R.drawable.person_fill0) + sendUserRelatedNotification(context, startedUser, NotificationType.UserStarted) } override fun onUserSwitched(context: Context, intent: Intent, switchedUser: UserHandle) { super.onUserSwitched(context, intent, switchedUser) - sendUserRelatedNotification(context, switchedUser, NotificationUtils.ID.USER_SWITCHED, R.string.user_switched, R.drawable.person_fill0) + sendUserRelatedNotification(context, switchedUser, NotificationType.UserSwitched) } override fun onUserStopped(context: Context, intent: Intent, stoppedUser: UserHandle) { super.onUserStopped(context, intent, stoppedUser) - sendUserRelatedNotification(context, stoppedUser, NotificationUtils.ID.USER_STOPPED, R.string.user_stopped, R.drawable.person_fill0) + sendUserRelatedNotification(context, stoppedUser, NotificationType.UserStopped) } override fun onUserRemoved(context: Context, intent: Intent, removedUser: UserHandle) { super.onUserRemoved(context, intent, removedUser) - sendUserRelatedNotification(context, removedUser, NotificationUtils.ID.USER_REMOVED, R.string.user_removed, R.drawable.person_remove_fill0) + sendUserRelatedNotification(context, removedUser, NotificationType.UserRemoved) + val um = context.getSystemService(Context.USER_SERVICE) as UserManager + ShortcutUtils.disableUserOperationShortcut( + context, um.getSerialNumberForUser(removedUser).toInt() + ) } override fun onBugreportShared(context: Context, intent: Intent, hash: String) { super.onBugreportShared(context, intent, hash) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_shared)) - .setContentText("SHA-256 hash: $hash") - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_SHARED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportShared, "SHA-256 hash: $hash") } override fun onBugreportSharingDeclined(context: Context, intent: Intent) { super.onBugreportSharingDeclined(context, intent) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_sharing_declined)) - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_SHARING_DECLINED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportSharingDeclined, "") } override fun onBugreportFailed(context: Context, intent: Intent, failureCode: Int) { @@ -146,30 +126,21 @@ class Receiver : DeviceAdminReceiver() { BUGREPORT_FAILURE_FILE_NO_LONGER_AVAILABLE -> R.string.bug_report_failure_no_longer_available else -> R.string.place_holder } - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_failed)) - .setContentText(context.getString(message)) - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_FAILED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportFailed, context.getString(message)) } override fun onSystemUpdatePending(context: Context, intent: Intent, receivedTime: Long) { super.onSystemUpdatePending(context, intent, receivedTime) - val time = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(Date(receivedTime)) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.system_update_pending)) - .setContentText(context.getString(R.string.received_time) + ": $time") - .setSmallIcon(R.drawable.system_update_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.SYSTEM_UPDATE_PENDING, builder.build()) + val text = context.getString(R.string.received_time) + ": " + formatDate(receivedTime) + NotificationUtils.notifyEvent(context, NotificationType.SystemUpdatePending, text) } - private fun sendUserRelatedNotification(context: Context, userHandle: UserHandle, id: Int, title: Int, icon: Int) { + private fun sendUserRelatedNotification( + context: Context, userHandle: UserHandle, type: NotificationType + ) { val um = context.getSystemService(Context.USER_SERVICE) as UserManager val serial = um.getSerialNumberForUser(userHandle) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(title)) - .setContentText(context.getString(R.string.serial_number) + ": $serial") - .setSmallIcon(icon) - NotificationUtils.notify(context, id, builder.build()) + val text = context.getString(R.string.serial_number) + ": $serial" + NotificationUtils.notifyEvent(context, type, text) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/Settings.kt index 6eb883b..a004147 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Settings.kt @@ -12,14 +12,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -39,25 +35,23 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.core.content.edit -import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.ui.FunctionItem @@ -65,8 +59,8 @@ import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable -import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -119,7 +113,7 @@ fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -143,21 +137,25 @@ fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { @Serializable object SettingsOptions @Composable -fun SettingsOptionsScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun SettingsOptionsScreen( + getDisplayDangerousFeatures: () -> Boolean, getShortcutsEnabled: () -> Boolean, + setDisplayDangerousFeatures: (Boolean) -> Unit, setShortcutsEnabled: (Boolean) -> Unit, + onNavigateUp: () -> Unit +) { + var dangerousFeatures by remember { mutableStateOf(getDisplayDangerousFeatures()) } + var shortcuts by remember { mutableStateOf(getShortcutsEnabled()) } MyScaffold(R.string.options, onNavigateUp, 0.dp) { SwitchItem( - R.string.show_dangerous_features, icon = R.drawable.warning_fill0, - getState = { SP.displayDangerousFeatures }, - onCheckedChange = { SP.displayDangerousFeatures = it } + R.string.show_dangerous_features, dangerousFeatures, { + setDisplayDangerousFeatures(it) + dangerousFeatures = it + }, R.drawable.warning_fill0 ) SwitchItem( - R.string.shortcuts, icon = R.drawable.open_in_new, - getState = { SP.shortcuts }, onCheckedChange = { - SP.shortcuts = it - ShortcutManagerCompat.removeAllDynamicShortcuts(context) - createShortcuts(context) - } + R.string.shortcuts, shortcuts, { + setShortcutsEnabled(it) + shortcuts = it + }, R.drawable.open_in_new ) } } @@ -165,13 +163,12 @@ fun SettingsOptionsScreen(onNavigateUp: () -> Unit) { @Serializable object Appearance @Composable -fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onThemeChange: (ThemeSettings) -> Unit) { +fun AppearanceScreen( + onNavigateUp: () -> Unit, currentTheme: StateFlow, + setTheme: (ThemeSettings) -> Unit +) { var darkThemeMenu by remember { mutableStateOf(false) } - var theme by remember { mutableStateOf(currentTheme) } - fun update(it: ThemeSettings) { - theme = it - onThemeChange(it) - } + val theme by currentTheme.collectAsStateWithLifecycle() val darkThemeTextID = when(theme.darkTheme) { 1 -> R.string.on 0 -> R.string.off @@ -182,7 +179,7 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh SwitchItem( R.string.material_you_color, state = theme.materialYou, - onCheckedChange = { update(theme.copy(materialYou = it)) } + onCheckedChange = { setTheme(theme.copy(materialYou = it)) } ) } Box { @@ -194,22 +191,21 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh DropdownMenuItem( text = { Text(stringResource(R.string.follow_system)) }, onClick = { - update(theme.copy(darkTheme = -1)) + setTheme(theme.copy(darkTheme = -1)) darkThemeMenu = false } ) DropdownMenuItem( text = { Text(stringResource(R.string.on)) }, onClick = { - update(theme.copy(darkTheme = 1)) - theme = theme.copy(darkTheme = 1) + setTheme(theme.copy(darkTheme = 1)) darkThemeMenu = false } ) DropdownMenuItem( text = { Text(stringResource(R.string.off)) }, onClick = { - update(theme.copy(darkTheme = 0)) + setTheme(theme.copy(darkTheme = 0)) darkThemeMenu = false } ) @@ -218,134 +214,133 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh AnimatedVisibility(theme.darkTheme == 1 || (theme.darkTheme == -1 && isSystemInDarkTheme())) { SwitchItem( R.string.black_theme, state = theme.blackTheme, - onCheckedChange = { update(theme.copy(blackTheme = it)) } + onCheckedChange = { setTheme(theme.copy(blackTheme = it)) } ) } } } +data class AppLockConfig( + /** null means no password, empty means password already set */ + val password: String?, val biometrics: Boolean, val whenLeaving: Boolean +) + @Serializable object AppLockSettings @Composable -fun AppLockSettingsScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.app_lock, onNavigateUp, 0.dp) { - val fm = LocalFocusManager.current - var password by remember { mutableStateOf("") } - var confirmPassword by remember { mutableStateOf("") } - var allowBiometrics by remember { mutableStateOf(SP.biometricsUnlock) } - var lockWhenLeaving by remember { mutableStateOf(SP.lockWhenLeaving) } - val fr = FocusRequester() - val alreadySet = !SP.lockPasswordHash.isNullOrEmpty() - val isInputLegal = password.length !in 1..3 && (alreadySet || (password.isNotEmpty() && password.isNotBlank())) - Column(Modifier.widthIn(max = 300.dp).align(Alignment.CenterHorizontally)) { - OutlinedTextField( - password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text(stringResource(R.string.password)) }, - supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() } - ) - OutlinedTextField( - confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth().focusRequester(fr), - label = { Text(stringResource(R.string.confirm_password)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) - if(VERSION.SDK_INT >= 28) Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.allow_biometrics)) - Switch(allowBiometrics, { allowBiometrics = it }) - } - Row(Modifier.fillMaxWidth().padding(bottom = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.lock_when_leaving)) - Switch(lockWhenLeaving, { lockWhenLeaving = it }) - } - Button( - onClick = { - fm.clearFocus() - if(password.isNotEmpty()) SP.lockPasswordHash = password.hash() - SP.biometricsUnlock = allowBiometrics - SP.lockWhenLeaving = lockWhenLeaving - onNavigateUp() - }, - modifier = Modifier.fillMaxWidth(), - enabled = isInputLegal && confirmPassword == password - ) { - Text(stringResource(if(alreadySet) R.string.update else R.string.set)) - } - if(alreadySet) FilledTonalButton( - onClick = { - fm.clearFocus() - SP.lockPasswordHash = "" - SP.biometricsUnlock = false - SP.lockWhenLeaving = false - onNavigateUp() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.disable)) - } +fun AppLockSettingsScreen( + config: AppLockConfig, setConfig: (AppLockConfig) -> Unit, + onNavigateUp: () -> Unit +) = MyScaffold(R.string.app_lock, onNavigateUp) { + var password by rememberSaveable { mutableStateOf(config.password ?: "") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + var allowBiometrics by rememberSaveable { mutableStateOf(config.biometrics) } + var lockWhenLeaving by rememberSaveable { mutableStateOf(config.whenLeaving) } + var alreadySet by rememberSaveable { mutableStateOf(config.password != null) } + val isInputLegal = password.length !in 1..3 && (alreadySet || password.isNotBlank()) + OutlinedTextField( + password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + label = { Text(stringResource(R.string.password)) }, + supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next) + ) + OutlinedTextField( + confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 28) Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.allow_biometrics)) + Switch(allowBiometrics, { allowBiometrics = it }) + } + Row( + Modifier.fillMaxWidth().padding(bottom = 6.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.lock_when_leaving)) + Switch(lockWhenLeaving, { lockWhenLeaving = it }) + } + Button( + onClick = { + setConfig(AppLockConfig(password, allowBiometrics, lockWhenLeaving)) + onNavigateUp() + }, + modifier = Modifier.fillMaxWidth(), + enabled = isInputLegal && confirmPassword == password + ) { + Text(stringResource(if(alreadySet) R.string.update else R.string.set)) + } + if (alreadySet) FilledTonalButton( + onClick = { + setConfig(AppLockConfig(null, false, false)) + onNavigateUp() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.disable)) } } @Serializable object ApiSettings @Composable -fun ApiSettings(onNavigateUp: () -> Unit) { +fun ApiSettings( + getEnabled: () -> Boolean, setKey: (String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var alreadyEnabled by remember { mutableStateOf(getEnabled()) } MyScaffold(R.string.api, onNavigateUp) { - var enabled by remember { mutableStateOf(SP.isApiEnabled) } + var enabled by remember { mutableStateOf(alreadyEnabled) } + var key by rememberSaveable { mutableStateOf("") } SwitchItem(R.string.enable, state = enabled, onCheckedChange = { enabled = it - SP.isApiEnabled = it - if(!it) SP.sharedPrefs.edit { remove("api.key") } }, padding = false) - if(enabled) { - var key by remember { mutableStateOf("") } + if (enabled) { OutlinedTextField( - value = key, onValueChange = { key = it }, label = { Text(stringResource(R.string.api_key)) }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), readOnly = true, + key, { key = it }, Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.api_key)) }, trailingIcon = { - IconButton( - onClick = { - val charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - val sr = SecureRandom() - key = (1..20).map { charset[sr.nextInt(charset.length)] }.joinToString("") - } - ) { - Icon(painter = painterResource(R.drawable.casino_fill0), contentDescription = "Random") + IconButton({ key = generateBase64Key(10) }) { + Icon(painterResource(R.drawable.casino_fill0), null) } } ) - Button( - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - onClick = { - SP.apiKey = key - context.showOperationResultToast(true) - }, - enabled = key.isNotEmpty() - ) { - Text(stringResource(R.string.apply)) - } - if(SP.apiKey != null) Notes(R.string.api_key_exist) } + Button( + onClick = { + setKey(if (enabled) key else "") + alreadyEnabled = enabled + context.showOperationResultToast(true) + }, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + enabled = !enabled || key.length !in 0..7 + ) { + Text(stringResource(R.string.apply)) + } + if (enabled && alreadyEnabled) Notes(R.string.api_key_exist) } } @Serializable object Notifications @Composable -fun NotificationsScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { - val sp = LocalContext.current.getSharedPreferences("data", Context.MODE_PRIVATE) - val map = mapOf( - NotificationUtils.ID.PASSWORD_CHANGED to R.string.password_changed, NotificationUtils.ID.USER_ADDED to R.string.user_added, - NotificationUtils.ID.USER_STARTED to R.string.user_started, NotificationUtils.ID.USER_SWITCHED to R.string.user_switched, - NotificationUtils.ID.USER_STOPPED to R.string.user_stopped, NotificationUtils.ID.USER_REMOVED to R.string.user_removed, - NotificationUtils.ID.BUG_REPORT_SHARED to R.string.bug_report_shared, - NotificationUtils.ID.BUG_REPORT_SHARING_DECLINED to R.string.bug_report_sharing_declined, - NotificationUtils.ID.BUG_REPORT_FAILED to R.string.bug_report_failed, - NotificationUtils.ID.SYSTEM_UPDATE_PENDING to R.string.system_update_pending - ) - map.forEach { (k, v) -> - SwitchItem(v, getState = { sp.getBoolean("n_$k", true) }, onCheckedChange = { sp.edit(true) { putBoolean("n_$k", it) } }) +fun NotificationsScreen( + enabledNotifications: StateFlow>, getState: () -> Unit, + setNotification: (NotificationType, Boolean) -> Unit, onNavigateUp: () -> Unit +) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { + val notifications by enabledNotifications.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + getState() + } + NotificationType.entries.filter { + it.channel == MyNotificationChannel.Events + }.forEach { type -> + SwitchItem(type.text, type.id in notifications, { setNotification(type, it) }) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt index 2dd9369..27a880e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt +++ b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt @@ -13,8 +13,7 @@ class SharedPrefs(context: Context) { var dhizuku by BooleanSharedPref("dhizuku_mode") var isDefaultAffiliationIdSet by BooleanSharedPref("default_affiliation_id_set") var displayDangerousFeatures by BooleanSharedPref("display_dangerous_features") - var isApiEnabled by BooleanSharedPref("api.enabled") - var apiKey by StringSharedPref("api.key") + var apiKeyHash by StringSharedPref("api_key_hash") var materialYou by BooleanSharedPref("theme.material_you", Build.VERSION.SDK_INT >= 31) /** -1: follow system, 0: off, 1: on */ var darkTheme by IntSharedPref("theme.dark", -1) @@ -25,6 +24,8 @@ class SharedPrefs(context: Context) { var applicationsListView by BooleanSharedPref("applications.list_view", true) var shortcuts by BooleanSharedPref("shortcuts") var dhizukuServer by BooleanSharedPref("dhizuku_server") + var notifications by StringSharedPref("notifications") + var shortcutKey by StringSharedPref("shortcut_key") } private class BooleanSharedPref(val key: String, val defValue: Boolean = false): ReadWriteProperty { diff --git a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt index a70eecf..99c5b0d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt @@ -57,7 +57,9 @@ fun useShizuku(context: Context, action: (IBinder?) -> Unit) { } else { Sui.init(context.packageName) fun requestPermissionResultListener(requestCode: Int, grantResult: Int) { - if(grantResult != PackageManager.PERMISSION_GRANTED) { + if (grantResult == PackageManager.PERMISSION_GRANTED) { + Shizuku.bindUserService(getShizukuArgs(context), connection) + } else { context.popToast(R.string.permission_denied) } Shizuku.removeRequestPermissionResultListener(::requestPermissionResultListener) diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt new file mode 100644 index 0000000..21e4e00 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt @@ -0,0 +1,137 @@ +package com.bintianqi.owndroid + +import android.content.Context +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.bintianqi.owndroid.dpm.UserOperationType + +object ShortcutUtils { + fun setAllShortcuts(context: Context, enabled: Boolean) { + if (enabled) { + setShortcutKey() + val list = listOf( + createShortcut(context, MyShortcut.Lock, true), + createShortcut(context, MyShortcut.DisableCamera, + !Privilege.DPM.getCameraDisabled(Privilege.DAR)), + createShortcut(context, MyShortcut.Mute, + !Privilege.DPM.isMasterVolumeMuted(Privilege.DAR)) + ) + ShortcutManagerCompat.setDynamicShortcuts(context, list) + } else { + ShortcutManagerCompat.removeDynamicShortcuts(context, MyShortcut.entries.map { it.id }) + } + } + fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) { + setShortcutKey() + ShortcutManagerCompat.pushDynamicShortcut( + context, createShortcut(context, shortcut, state) + ) + } + private fun createShortcut( + context: Context, shortcut: MyShortcut, state: Boolean + ): ShortcutInfoCompat { + val icon = IconCompat.createWithResource( + context, + if (!state && shortcut.iconDisable != null) shortcut.iconDisable else shortcut.iconEnable + ) + return ShortcutInfoCompat.Builder(context, shortcut.id) + .setIcon(icon) + .setShortLabel(context.getText( + if (!state && shortcut.labelDisable != null) shortcut.labelDisable else shortcut.labelEnable + )) + .setIntent( + Intent(context, ShortcutsReceiverActivity::class.java) + .setAction("com.bintianqi.owndroid.action.${shortcut.id}") + .putExtra("key", SP.shortcutKey) + ) + .build() + } + /** @param state If true, set the user restriction */ + fun createUserRestrictionShortcut(context: Context, id: String, state: Boolean): ShortcutInfoCompat { + val restriction = UserRestrictionsRepository.findRestrictionById(id) + val label = context.getString(if (state) R.string.disable else R.string.enable) + " " + + context.getString(restriction.name) + setShortcutKey() + return ShortcutInfoCompat.Builder(context, "USER_RESTRICTION-$id") + .setIcon(IconCompat.createWithResource(context, restriction.icon)) + .setShortLabel(label) + .setIntent( + Intent(context, ShortcutsReceiverActivity::class.java) + .setAction("com.bintianqi.owndroid.action.USER_RESTRICTION") + .putExtra("restriction", id) + .putExtra("state", state) + .putExtra("key", SP.shortcutKey) + ) + .build() + } + fun setUserRestrictionShortcut(context: Context, id: String, state: Boolean): Boolean { + val shortcut = createUserRestrictionShortcut(context, id, state) + return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + } + fun updateUserRestrictionShortcut(context: Context, id: String, state: Boolean, checkExist: Boolean) { + if (checkExist) { + val shortcuts = ShortcutManagerCompat.getShortcuts( + context, ShortcutManagerCompat.FLAG_MATCH_PINNED + ) + if (shortcuts.find { it.id == "USER_RESTRICTION-$id" } == null) return + } + val shortcut = createUserRestrictionShortcut(context, id, state) + ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut)) + } + fun buildUserOperationShortcut( + context: Context, type: UserOperationType, serial: Int + ): ShortcutInfoCompat { + setShortcutKey() + val icon = when (type) { + UserOperationType.Start, UserOperationType.Switch -> R.drawable.person_fill0 + UserOperationType.Stop -> R.drawable.person_off + else -> R.drawable.person_fill0 + } + val text = when (type) { + UserOperationType.Start -> R.string.start_user_n + UserOperationType.Switch -> R.string.switch_to_user_n + UserOperationType.Stop -> R.string.stop_user_n + else -> R.string.place_holder + } + return ShortcutInfoCompat.Builder(context, "USER_OPERATION-${type.name}-$serial") + .setIcon(IconCompat.createWithResource(context, icon)) + .setShortLabel(context.getString(text, serial)) + .setIntent( + Intent(context, ShortcutsReceiverActivity::class.java) + .setAction("com.bintianqi.owndroid.action.USER_OPERATION") + .putExtra("operation", type.name) + .putExtra("serial", serial) + .putExtra("key", SP.shortcutKey) + ) + .build() + } + fun setUserOperationShortcut(context: Context, type: UserOperationType, serial: Int): Boolean { + val shortcut = buildUserOperationShortcut(context, type, serial) + return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + } + fun disableUserOperationShortcut(context: Context, serial: Int) { + val shortcuts = UserOperationType.entries.map { + "USER_OPERATION-${it.name}-$serial" + } + ShortcutManagerCompat.disableShortcuts( + context, shortcuts, context.getString(R.string.user_removed) + ) + } + fun setShortcutKey() { + if (SP.shortcutKey.isNullOrEmpty()) { + SP.shortcutKey = generateBase64Key(10) + } + } +} + +enum class MyShortcut( + val id: String, val labelEnable: Int, val labelDisable: Int? = null, val iconEnable: Int, + val iconDisable: Int? = null +) { + Lock("LOCK", R.string.lock_screen, iconEnable = R.drawable.lock_fill0), + DisableCamera("DISABLE_CAMERA", R.string.disable_cam, R.string.enable_camera, + R.drawable.no_photography_fill0, R.drawable.photo_camera_fill0), + Mute("MUTE", R.string.mute, R.string.unmute, R.drawable.volume_off_fill0, R.drawable.volume_up_fill0) +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt index b8dee4a..5d0c1f4 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt @@ -1,66 +1,62 @@ package com.bintianqi.owndroid import android.app.Activity -import android.content.Context -import android.content.Intent import android.os.Bundle -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat +import android.util.Log +import com.bintianqi.owndroid.dpm.UserOperationType +import com.bintianqi.owndroid.dpm.doUserOperationWithContext class ShortcutsReceiverActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) try { val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.") - if (action != null && SP.shortcuts) { + val key = SP.shortcutKey + val requestKey = intent?.getStringExtra("key") + if (action != null && key != null && requestKey == key) { when (action) { "LOCK" -> Privilege.DPM.lockNow() "DISABLE_CAMERA" -> { - Privilege.DPM.setCameraDisabled(Privilege.DAR, !Privilege.DPM.getCameraDisabled(Privilege.DAR)) - createShortcuts(this) + val state = Privilege.DPM.getCameraDisabled(Privilege.DAR) + Privilege.DPM.setCameraDisabled(Privilege.DAR, !state) + ShortcutUtils.setShortcut(this, MyShortcut.DisableCamera, state) } "MUTE" -> { - Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !Privilege.DPM.isMasterVolumeMuted(Privilege.DAR)) - createShortcuts(this) + val state = Privilege.DPM.isMasterVolumeMuted(Privilege.DAR) + Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !state) + ShortcutUtils.setShortcut(this, MyShortcut.Mute, state) + } + "USER_RESTRICTION" -> { + val state = intent?.getBooleanExtra("state", false) + val id = intent?.getStringExtra("restriction") + if (state == null || id == null) return + if (state) { + Privilege.DPM.addUserRestriction(Privilege.DAR, id) + } else { + Privilege.DPM.clearUserRestriction(Privilege.DAR, id) + } + ShortcutUtils.updateUserRestrictionShortcut(this, id, !state, false) + } + "USER_OPERATION" -> { + val typeName = intent.getStringExtra("operation") ?: return + val type = UserOperationType.valueOf(typeName) + val serial = intent.getIntExtra("serial", -1) + if (serial == -1) return + doUserOperationWithContext(this, type, serial, false) } } + Log.d(TAG, "Received intent: $action") + showOperationResultToast(true) + } else { + showOperationResultToast(false) } + } catch(e: Exception) { + e.printStackTrace() } finally { finish() } } -} - -fun createShortcuts(context: Context) { - if (!SP.shortcuts) return - val action = "com.bintianqi.owndroid.action" - val baseIntent = Intent(context, ShortcutsReceiverActivity::class.java) - val cameraDisabled = Privilege.DPM.getCameraDisabled(Privilege.DAR) - val muted = Privilege.DPM.isMasterVolumeMuted(Privilege.DAR) - val list = listOf( - ShortcutInfoCompat.Builder(context, "LOCK") - .setIcon(IconCompat.createWithResource(context, R.drawable.screen_lock_portrait_fill0)) - .setShortLabel(context.getString(R.string.lock_screen)) - .setIntent(Intent(baseIntent).setAction("$action.LOCK")), - ShortcutInfoCompat.Builder(context, "DISABLE_CAMERA") - .setIcon( - IconCompat.createWithResource( - context, - if (cameraDisabled) R.drawable.photo_camera_fill0 else R.drawable.no_photography_fill0 - ) - ) - .setShortLabel(context.getString(if (cameraDisabled) R.string.enable_camera else R.string.disable_cam)) - .setIntent(Intent(baseIntent).setAction("$action.DISABLE_CAMERA")), - ShortcutInfoCompat.Builder(context, "MUTE") - .setIcon( - IconCompat.createWithResource( - context, - if (muted) R.drawable.volume_up_fill0 else R.drawable.volume_off_fill0 - ) - ) - .setShortLabel(context.getString(if (muted) R.string.unmute else R.string.mute)) - .setIntent(Intent(baseIntent).setAction("$action.MUTE")) - ) - ShortcutManagerCompat.setDynamicShortcuts(context, list.map { it.build() }) + companion object { + private const val TAG = "ShortcutsReceiver" + } } diff --git a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt new file mode 100644 index 0000000..e00916b --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt @@ -0,0 +1,117 @@ +package com.bintianqi.owndroid + +import android.os.Build +import android.os.UserManager +import com.bintianqi.owndroid.dpm.Restriction + +@Suppress("InlinedApi") +object UserRestrictionsRepository { + val network = listOf( + Restriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS, R.string.config_mobile_network, R.drawable.signal_cellular_alt_fill0), + Restriction(UserManager.DISALLOW_CONFIG_WIFI, R.string.config_wifi, R.drawable.wifi_fill0), + Restriction(UserManager.DISALLOW_DATA_ROAMING, R.string.data_roaming, R.drawable.network_cell_fill0, 24), + Restriction(UserManager.DISALLOW_CELLULAR_2G, R.string.cellular_2g, R.drawable.network_cell_fill0, 34), + Restriction(UserManager.DISALLOW_ULTRA_WIDEBAND_RADIO, R.string.ultra_wideband_radio, R.drawable.wifi_tethering_fill0, 34), + Restriction(UserManager.DISALLOW_ADD_WIFI_CONFIG, R.string.add_wifi_conf, R.drawable.wifi_fill0, 33), + Restriction(UserManager.DISALLOW_CHANGE_WIFI_STATE, R.string.change_wifi_state, R.drawable.wifi_fill0, 33), + Restriction(UserManager.DISALLOW_WIFI_DIRECT, R.string.wifi_direct, R.drawable.wifi_tethering_fill0), + Restriction(UserManager.DISALLOW_WIFI_TETHERING, R.string.wifi_tethering, R.drawable.wifi_tethering_fill0, 33), + Restriction(UserManager.DISALLOW_SHARING_ADMIN_CONFIGURED_WIFI, R.string.share_admin_wifi, R.drawable.share_fill0, 33), + Restriction(UserManager.DISALLOW_NETWORK_RESET, R.string.network_reset, R.drawable.reset_wrench_fill0, 23), + Restriction(UserManager.DISALLOW_CONFIG_TETHERING, R.string.config_tethering, R.drawable.wifi_tethering_fill0), + Restriction(UserManager.DISALLOW_CONFIG_VPN, R.string.config_vpn, R.drawable.vpn_key_fill0), + Restriction(UserManager.DISALLOW_CONFIG_PRIVATE_DNS, R.string.config_private_dns, R.drawable.dns_fill0, 29), + Restriction(UserManager.DISALLOW_AIRPLANE_MODE, R.string.airplane_mode, R.drawable.airplanemode_active_fill0, 28), + Restriction(UserManager.DISALLOW_CONFIG_CELL_BROADCASTS, R.string.config_cell_broadcasts, R.drawable.cell_tower_fill0), + Restriction(UserManager.DISALLOW_SMS, R.string.sms, R.drawable.sms_fill0), + Restriction(UserManager.DISALLOW_OUTGOING_CALLS, R.string.outgoing_calls, R.drawable.phone_forwarded_fill0), + Restriction(UserManager.DISALLOW_SIM_GLOBALLY, R.string.download_esim, R.drawable.sim_card_download_fill0), + Restriction(UserManager.DISALLOW_THREAD_NETWORK, R.string.thread_network, R.drawable.router_fill0, 36) + ) + val connectivity = listOf( + Restriction(UserManager.DISALLOW_BLUETOOTH, R.string.bluetooth, R.drawable.bluetooth_fill0, 26), + Restriction(UserManager.DISALLOW_BLUETOOTH_SHARING, R.string.bt_share, R.drawable.bluetooth_searching_fill0, 26), + Restriction(UserManager.DISALLOW_SHARE_LOCATION, R.string.share_location, R.drawable.location_on_fill0), + Restriction(UserManager.DISALLOW_CONFIG_LOCATION, R.string.config_location, R.drawable.location_on_fill0, 28), + Restriction(UserManager.DISALLOW_NEAR_FIELD_COMMUNICATION_RADIO, R.string.nfc, R.drawable.nfc_fill0, 35), + Restriction(UserManager.DISALLOW_OUTGOING_BEAM, R.string.outgoing_beam, R.drawable.nfc_fill0, 22), + Restriction(UserManager.DISALLOW_USB_FILE_TRANSFER, R.string.usb_file_transfer, R.drawable.usb_fill0), + Restriction(UserManager.DISALLOW_MOUNT_PHYSICAL_MEDIA, R.string.mount_physical_media, R.drawable.sd_card_fill0), + Restriction(UserManager.DISALLOW_PRINTING, R.string.printing, R.drawable.print_fill0, 28) + ) + val applications = listOf( + Restriction(UserManager.DISALLOW_INSTALL_APPS, R.string.install_app, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY, R.string.install_unknown_src_globally, R.drawable.android_fill0, 29), + Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES, R.string.inst_unknown_src, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_UNINSTALL_APPS, R.string.uninstall_app, R.drawable.delete_fill0), + Restriction(UserManager.DISALLOW_APPS_CONTROL, R.string.apps_ctrl, R.drawable.apps_fill0), + Restriction(UserManager.DISALLOW_CONFIG_DEFAULT_APPS, R.string.config_default_apps, R.drawable.apps_fill0, 34) + ) + val media = listOf( + Restriction(UserManager.DISALLOW_CONFIG_BRIGHTNESS, R.string.config_brightness, R.drawable.brightness_5_fill0, 28), + Restriction(UserManager.DISALLOW_CONFIG_SCREEN_TIMEOUT, R.string.config_scr_timeout, R.drawable.screen_lock_portrait_fill0, 28), + Restriction(UserManager.DISALLOW_AMBIENT_DISPLAY, R.string.ambient_display, R.drawable.brightness_5_fill0, 28), + Restriction(UserManager.DISALLOW_ADJUST_VOLUME, R.string.adjust_volume, R.drawable.volume_up_fill0), + Restriction(UserManager.DISALLOW_UNMUTE_MICROPHONE, R.string.unmute_microphone, R.drawable.mic_fill0), + Restriction(UserManager.DISALLOW_CAMERA_TOGGLE, R.string.camera_toggle, R.drawable.cameraswitch_fill0, 31), + Restriction(UserManager.DISALLOW_MICROPHONE_TOGGLE, R.string.microphone_toggle, R.drawable.mic_fill0, 31) + ) + val users = listOf( + Restriction(UserManager.DISALLOW_ADD_USER, R.string.add_user, R.drawable.account_circle_fill0), + Restriction(UserManager.DISALLOW_REMOVE_USER, R.string.remove_user, R.drawable.account_circle_fill0), + Restriction(UserManager.DISALLOW_USER_SWITCH, R.string.switch_user, R.drawable.account_circle_fill0, 28), + Restriction(UserManager.DISALLOW_ADD_MANAGED_PROFILE, R.string.create_work_profile, R.drawable.work_fill0, 26), + Restriction(UserManager.DISALLOW_REMOVE_MANAGED_PROFILE, R.string.delete_work_profile, R.drawable.delete_forever_fill0, 26), + Restriction(UserManager.DISALLOW_ADD_PRIVATE_PROFILE, R.string.create_private_space, R.drawable.lock_fill0, 35), + Restriction(UserManager.DISALLOW_SET_USER_ICON, R.string.set_user_icon, R.drawable.account_circle_fill0, 24), + Restriction(UserManager.DISALLOW_CROSS_PROFILE_COPY_PASTE, R.string.cross_profile_copy, R.drawable.content_paste_fill0), + Restriction(UserManager.DISALLOW_SHARE_INTO_MANAGED_PROFILE, R.string.share_into_managed_profile, R.drawable.share_fill0, 28), + Restriction(UserManager.DISALLOW_UNIFIED_PASSWORD, R.string.unified_pwd, R.drawable.work_fill0, 28) + ) + val other = listOf( + Restriction(UserManager.DISALLOW_AUTOFILL, R.string.autofill, R.drawable.password_fill0, 26), + Restriction(UserManager.DISALLOW_CONFIG_CREDENTIALS, R.string.config_credentials, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_CONTENT_CAPTURE, R.string.content_capture, R.drawable.screenshot_fill0, 29), + Restriction(UserManager.DISALLOW_CONTENT_SUGGESTIONS, R.string.content_suggestions, R.drawable.sms_fill0, 29), + Restriction(UserManager.DISALLOW_ASSIST_CONTENT, R.string.assist_content, R.drawable.info_fill0, 35), + Restriction(UserManager.DISALLOW_CREATE_WINDOWS, R.string.create_windows, R.drawable.web_asset), + Restriction(UserManager.DISALLOW_SET_WALLPAPER, R.string.set_wallpaper, R.drawable.wallpaper_fill0, 24), + Restriction(UserManager.DISALLOW_GRANT_ADMIN, R.string.grant_admin, R.drawable.security_fill0, 34), + Restriction(UserManager.DISALLOW_FUN, R.string.`fun`, R.drawable.stadia_controller_fill0, 23), + Restriction(UserManager.DISALLOW_MODIFY_ACCOUNTS, R.string.modify_accounts, R.drawable.manage_accounts_fill0), + Restriction(UserManager.DISALLOW_CONFIG_LOCALE, R.string.config_locale, R.drawable.language_fill0, 28), + Restriction(UserManager.DISALLOW_CONFIG_DATE_TIME, R.string.config_date_time, R.drawable.schedule_fill0, 28), + Restriction(UserManager.DISALLOW_SYSTEM_ERROR_DIALOGS, R.string.sys_err_dialog, R.drawable.warning_fill0, 28), + Restriction(UserManager.DISALLOW_FACTORY_RESET, R.string.factory_reset, R.drawable.android_fill0), + Restriction(UserManager.DISALLOW_SAFE_BOOT, R.string.safe_boot, R.drawable.security_fill0, 23), + Restriction(UserManager.DISALLOW_DEBUGGING_FEATURES, R.string.debug_features, R.drawable.adb_fill0) + ) + + fun getData(id: String): Pair> { + val category = UserRestrictionCategory.valueOf(id) + return category.title to when (category) { + UserRestrictionCategory.Network -> network + UserRestrictionCategory.Connectivity -> connectivity + UserRestrictionCategory.Applications -> applications + UserRestrictionCategory.Media -> media + UserRestrictionCategory.Users -> users + UserRestrictionCategory.Other -> other + }.filter { Build.VERSION.SDK_INT >= it.requiresApi } + } + fun findRestrictionById(id: String): Restriction { + listOf(network, connectivity, applications, media, users, other).forEach { list -> + val restriction = list.find { it.id == id } + if (restriction != null) return restriction + } + throw Exception("User restriction not found") + } +} + +enum class UserRestrictionCategory(val title: Int, val icon: Int) { + Network(R.string.network, R.drawable.language_fill0), + Connectivity(R.string.connectivity, R.drawable.devices_other_fill0), + Applications(R.string.applications, R.drawable.apps_fill0), + Media(R.string.media, R.drawable.volume_up_fill0), + Users(R.string.users, R.drawable.manage_accounts_fill0), + Other(R.string.other, R.drawable.more_horiz_fill0) +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index fded7d3..700658d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -4,32 +4,40 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.ComponentName import android.content.Context -import android.content.Intent import android.content.pm.PackageInfo import android.net.Uri import android.os.Build -import android.os.Bundle import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContract -import androidx.annotation.RequiresApi import androidx.annotation.StringRes +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.union +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import kotlinx.serialization.encodeToString +import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.security.MessageDigest +import java.security.SecureRandom import java.text.SimpleDateFormat -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit -import kotlin.reflect.typeOf +import kotlin.io.encoding.Base64 var zhCN = true @@ -72,20 +80,12 @@ fun formatFileSize(bytes: Long): String { val Boolean.yesOrNo @StringRes get() = if(this) R.string.yes else R.string.no -@RequiresApi(26) -fun parseTimestamp(timestamp: Long): String { - val instant = Instant.ofEpochMilli(timestamp) - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) - return formatter.format(instant) +fun formatDate(ms: Long): String { + return formatDate(Date(ms)) +} +fun formatDate(date: Date): String { + return SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(date) } - -fun parseDate(date: Date): String = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(date) - -val Long.humanReadableDate: String - get() = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault()).format(Date(this)) - -fun formatDate(pattern: String, value: Long): String - = SimpleDateFormat(pattern, Locale.getDefault()).format(Date(value)) fun Context.showOperationResultToast(success: Boolean) { popToast(if(success) R.string.success else R.string.failed) @@ -93,25 +93,6 @@ fun Context.showOperationResultToast(success: Boolean) { const val APK_MIME = "application/vnd.android.package-archive" -inline fun serializableNavTypePair() = - typeOf() to object : NavType(false) { - override fun get(bundle: Bundle, key: String): T? = - bundle.getString(key)?.let { parseValue(it) } - override fun put(bundle: Bundle, key: String, value: T) = - bundle.putString(key, serializeAsValue(value)) - override fun parseValue(value: String): T = - Json.decodeFromString(value) - override fun serializeAsValue(value: T): String = - Json.encodeToString(value) -} - -class ChoosePackageContract: ActivityResultContract() { - override fun createIntent(context: Context, input: Nothing?): Intent = - Intent(context, PackageChooserActivity::class.java) - override fun parseResult(resultCode: Int, intent: Intent?): String? = - intent?.getStringExtra("package") -} - fun exportLogs(context: Context, uri: Uri) { context.contentResolver.openOutputStream(uri)?.use { output -> val proc = Runtime.getRuntime().exec("logcat -d") @@ -122,12 +103,10 @@ fun exportLogs(context: Context, uri: Uri) { } } -fun NavHostController.navigate(route: T, args: Bundle) { - navigate(graph.findNode(route)!!.id, args) -} - val HorizontalPadding = 16.dp +val BottomPadding = 60.dp + @OptIn(ExperimentalStdlibApi::class) fun String.hash(): String { val md = MessageDigest.getInstance("SHA-256") @@ -151,3 +130,33 @@ fun Context.popToast(resId: Int) { fun Context.popToast(str: String) { Toast.makeText(this, str, Toast.LENGTH_SHORT).show() } + +class SerializableSaver(val serializer: KSerializer) : Saver { + override fun restore(value: String): T? { + return Json.decodeFromString(serializer, value) + } + override fun SaverScope.save(value: T): String { + return Json.encodeToString(serializer, value) + } +} + +fun generateBase64Key(length: Int): String { + val ba = ByteArray(length) + SecureRandom().nextBytes(ba) + return Base64.withPadding(Base64.PaddingOption.ABSENT).encode(ba) +} + +fun Modifier.clickableTextField(onClick: () -> Unit) = + pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) onClick() + } + } + +@Composable +fun adaptiveInsets(): WindowInsets { + val navbar = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) + return WindowInsets.ime.union(navbar).union(WindowInsets.displayCutout) +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index d898059..c4ffd1b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -1,36 +1,30 @@ package com.bintianqi.owndroid.dpm -import android.app.PendingIntent import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED import android.app.admin.PackagePolicy -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInstaller -import android.content.pm.PackageManager import android.os.Build.VERSION import android.os.Looper -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -38,12 +32,20 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar @@ -54,22 +56,22 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf 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.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -78,20 +80,17 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toDrawable +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.AppInfo import com.bintianqi.owndroid.AppInstallerActivity -import com.bintianqi.owndroid.AppInstallerViewModel -import com.bintianqi.owndroid.ChoosePackageContract +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.getInstalledAppsFlags -import com.bintianqi.owndroid.installedApps +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.ErrorDialog import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyLazyScaffold @@ -101,19 +100,10 @@ import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable -import java.util.concurrent.Executors - -fun PackageManager.retrieveAppInfo(packageName: String): AppInfo { - return try { - getApplicationInfo(packageName, getInstalledAppsFlags).retrieveAppInfo(this) - } catch (_: PackageManager.NameNotFoundException) { - AppInfo(packageName, "???", Color.Transparent.toArgb().toDrawable(), 0) - } -} - -fun ApplicationInfo.retrieveAppInfo(pm: PackageManager) = - installedApps.value.find { it.name == packageName } ?: AppInfo(packageName, loadLabel(pm).toString(), loadIcon(pm), flags) val String.isValidPackageName get() = Regex("""^(?:[a-zA-Z]\w*\.)+[a-zA-Z]\w*$""").matches(this) @@ -124,7 +114,7 @@ fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) { Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp).animateItem(), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Row(Modifier.fillMaxWidth(0.87F), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { Image( painter = rememberDrawablePainter(info.icon), contentDescription = null, modifier = Modifier.padding(start = 12.dp, end = 18.dp).size(30.dp) @@ -141,18 +131,16 @@ fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) { } @Composable -fun PackageNameTextField(value: String, modifier: Modifier = Modifier, onValueChange: (String) -> Unit) { - val launcher = rememberLauncherForActivityResult(ChoosePackageContract()) { - if(it != null) onValueChange(it) - } +fun PackageNameTextField( + value: String, onChoosePackage: () -> Unit, + modifier: Modifier = Modifier, onValueChange: (String) -> Unit +) { val fm = LocalFocusManager.current OutlinedTextField( value, onValueChange, Modifier.fillMaxWidth().then(modifier), label = { Text(stringResource(R.string.package_name)) }, trailingIcon = { - IconButton({ - launcher.launch(null) - }) { + IconButton(onChoosePackage) { Icon(Icons.AutoMirrored.Default.List, null) } }, @@ -182,7 +170,7 @@ fun ApplicationsFeaturesScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Un scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( Modifier @@ -242,274 +230,119 @@ fun ApplicationsFeaturesScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Un @Serializable data class ApplicationDetails(val packageName: String) +data class AppStatus( + val suspend: Boolean, + val hide: Boolean, + val uninstallBlocked: Boolean, + val userControlDisabled: Boolean, + val meteredDataDisabled: Boolean, + val keepUninstalled: Boolean +) + @Composable -fun ApplicationDetailsScreen(param: ApplicationDetails, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun ApplicationDetailsScreen( + param: ApplicationDetails, vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit +) { val packageName = param.packageName - val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - val pm = context.packageManager - var dialog by remember { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall - val info = pm.getApplicationInfo(packageName, getInstalledAppsFlags) + var dialog by rememberSaveable { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall + val info = vm.getAppInfo(packageName) + val status by vm.appStatus.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.getAppStatus(packageName) } MySmallTitleScaffold(R.string.place_holder, onNavigateUp, 0.dp) { Column(Modifier.align(Alignment.CenterHorizontally).padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Image(rememberDrawablePainter(info.loadIcon(pm)), null, Modifier.size(50.dp)) - Text(info.loadLabel(pm).toString(), Modifier.padding(top = 4.dp)) - Text(info.packageName, Modifier.alpha(0.7F).padding(bottom = 8.dp), style = typography.bodyMedium) + Image(rememberDrawablePainter(info.icon), null, Modifier.size(50.dp)) + Text(info.label, Modifier.padding(top = 4.dp)) + Text(info.name, Modifier.alpha(0.7F).padding(bottom = 8.dp), style = typography.bodyMedium) } FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) { onNavigate(PermissionsManager(packageName)) } if(VERSION.SDK_INT >= 24) SwitchItem( - R.string.suspend, icon = R.drawable.block_fill0, - getState = { Privilege.DPM.isPackageSuspended(Privilege.DAR, packageName) }, - onCheckedChange = { Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(packageName), it) } + R.string.suspend, icon = R.drawable.block_fill0, state = status.suspend, + onCheckedChange = { vm.adSetPackageSuspended(packageName, it) } ) SwitchItem( R.string.hide, icon = R.drawable.visibility_off_fill0, - getState = { Privilege.DPM.isApplicationHidden(Privilege.DAR, packageName) }, - onCheckedChange = { Privilege.DPM.setApplicationHidden(Privilege.DAR, packageName, it) } + state = status.hide, + onCheckedChange = { vm.adSetPackageHidden(packageName, it) } ) SwitchItem( R.string.block_uninstall, icon = R.drawable.delete_forever_fill0, - getState = { Privilege.DPM.isUninstallBlocked(Privilege.DAR, packageName) }, - onCheckedChange = { Privilege.DPM.setUninstallBlocked(Privilege.DAR, packageName, it) } + state = status.uninstallBlocked, + onCheckedChange = { vm.adSetPackageUb(packageName, it) } ) if(VERSION.SDK_INT >= 30) SwitchItem( R.string.disable_user_control, icon = R.drawable.do_not_touch_fill0, - getState = { packageName in Privilege.DPM.getUserControlDisabledPackages(Privilege.DAR) }, - onCheckedChange = { state -> - Privilege.DPM.setUserControlDisabledPackages(Privilege.DAR, - Privilege.DPM.getUserControlDisabledPackages(Privilege.DAR).let { if(state) it.plus(packageName) else it.minus(packageName) } - ) - } + state = status.userControlDisabled, + onCheckedChange = { vm.adSetPackageUcd(packageName, it) } ) if(VERSION.SDK_INT >= 28) SwitchItem( R.string.disable_metered_data, icon = R.drawable.money_off_fill0, - getState = { packageName in Privilege.DPM.getMeteredDataDisabledPackages(Privilege.DAR) }, - onCheckedChange = { state -> - Privilege.DPM.setMeteredDataDisabledPackages(Privilege.DAR, - Privilege.DPM.getMeteredDataDisabledPackages(Privilege.DAR).let { if(state) it.plus(packageName) else it.minus(packageName) } - ) - } + state = status.meteredDataDisabled, + onCheckedChange = { vm.adSetPackageMdd(packageName, it) } ) if(privilege.device && VERSION.SDK_INT >= 28) SwitchItem( R.string.keep_after_uninstall, icon = R.drawable.delete_fill0, - getState = { Privilege.DPM.getKeepUninstalledPackages(Privilege.DAR)?.contains(packageName) == true }, - onCheckedChange = { state -> - Privilege.DPM.setKeepUninstalledPackages(Privilege.DAR, - Privilege.DPM.getKeepUninstalledPackages(Privilege.DAR)?.let { if(state) it.plus(packageName) else it.minus(packageName) } ?: listOf(packageName) - ) - } + state = status.keepUninstalled, + onCheckedChange = { vm.adSetPackageKu(packageName, it) } ) if(VERSION.SDK_INT >= 28) FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { dialog = 1 } FunctionItem(R.string.uninstall, icon = R.drawable.delete_fill0) { dialog = 2 } + Spacer(Modifier.height(BottomPadding)) } - if(dialog == 1 && VERSION.SDK_INT >= 28) ClearAppStorageDialog(packageName) { dialog = 0 } - if(dialog == 2) UninstallAppDialog(packageName) { dialog = 0 } + if(dialog == 1 && VERSION.SDK_INT >= 28) + ClearAppStorageDialog(packageName, vm::clearAppData) { dialog = 0 } + if(dialog == 2) UninstallAppDialog(packageName, vm::uninstallPackage) { dialog = 0 } } @Serializable object Suspend -@RequiresApi(24) -@Composable -fun SuspendScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - pm.getInstalledApplications(getInstalledAppsFlags).filter { - Privilege.DPM.isPackageSuspended(Privilege.DAR, it.packageName) - }.forEach { - packages += it.retrieveAppInfo(pm) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.suspend, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(it.name), false) - refresh() - } - } - item { - Column(Modifier.padding(horizontal = HorizontalPadding)) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - if(Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(packageName), true).isEmpty()) packageName = "" - else context.showOperationResultToast(false) - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.suspend)) - } - Notes(R.string.info_suspend_app) - } - } - } -} - @Serializable object Hide -@Composable -fun HideScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - pm.getInstalledApplications(getInstalledAppsFlags).filter { Privilege.DPM.isApplicationHidden(Privilege.DAR, it.packageName) }.forEach { - packages += it.retrieveAppInfo(pm) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.hide, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.setApplicationHidden(Privilege.DAR, it.name, false) - refresh() - } - } - item { - Column(Modifier.padding(horizontal = HorizontalPadding)) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - if(Privilege.DPM.setApplicationHidden(Privilege.DAR, packageName, true)) packageName = "" - else context.showOperationResultToast(false) - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.hide)) - } - } - } - } -} - @Serializable object BlockUninstall -@Composable -fun BlockUninstallScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - pm.getInstalledApplications(getInstalledAppsFlags).filter { Privilege.DPM.isUninstallBlocked(Privilege.DAR, it.packageName) }.forEach { - packages += it.retrieveAppInfo(pm) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.block_uninstall, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.setUninstallBlocked(Privilege.DAR, it.name, false) - refresh() - } - } - item { - Column(Modifier.padding(horizontal = HorizontalPadding)) { - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - Privilege.DPM.setUninstallBlocked(Privilege.DAR, packageName, true) - packageName = "" - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.block_uninstall)) - } - } - } - } -} - @Serializable object DisableUserControl -@RequiresApi(30) -@Composable -fun DisableUserControlScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getUserControlDisabledPackages(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.disable_user_control, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setUserControlDisabledPackages(Privilege.DAR, packages.minus(info).map { it.name }) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - Privilege.DPM.setUserControlDisabledPackages(Privilege.DAR, packages.map { it.name } + packageName) - refresh() - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 8.dp), - ) { - Text(stringResource(R.string.add)) - } - Notes(R.string.info_disable_user_control, HorizontalPadding) - } - } -} - @Serializable data class PermissionsManager(val packageName: String? = null) @RequiresApi(23) @Composable -fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager) { +fun PermissionsManagerScreen( + packagePermissions: MutableStateFlow>, getPackagePermissions: (String) -> Unit, + setPackagePermission: (String, String, Int) -> Boolean, onNavigateUp: () -> Unit, + param: PermissionsManager, chosenPackage: Channel, onChoosePackage: () -> Unit +) { val packageNameParam = param.packageName - val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - var packageName by remember { mutableStateOf(packageNameParam ?: "") } - var selectedPermission by remember { mutableStateOf(null) } - val statusMap = remember { mutableStateMapOf() } + var packageName by rememberSaveable { mutableStateOf(packageNameParam ?: "") } + var selectedPermission by rememberSaveable { mutableIntStateOf(-1) } + val permissions by packagePermissions.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } LaunchedEffect(packageName) { - if(packageName.isValidPackageName) { - permissionList().forEach { statusMap[it.permission] = Privilege.DPM.getPermissionGrantState(Privilege.DAR, packageName, it.permission) } - } else { - statusMap.clear() - } + getPackagePermissions(packageName) } MyLazyScaffold(R.string.permissions, onNavigateUp) { item { if(packageNameParam == null) { - PackageNameTextField(packageName, Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } Spacer(Modifier.padding(vertical = 4.dp)) } } - items(permissionList(), { it.permission }) { + itemsIndexed(runtimePermissions, { _, it -> it.id }) { index, it -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable(packageName.isValidPackageName) { - selectedPermission = it + selectedPermission = index } .padding(8.dp) ) { Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp)) Column { - val state = when(statusMap[it.permission]) { + val state = when(permissions[it.id]) { PERMISSION_GRANT_STATE_DEFAULT -> R.string.default_stringres PERMISSION_GRANT_STATE_GRANTED -> R.string.granted PERMISSION_GRANT_STATE_DENIED -> R.string.denied @@ -521,19 +354,18 @@ fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager } } item { - Spacer(Modifier.padding(vertical = 30.dp)) + Spacer(Modifier.height(BottomPadding)) } } - if(selectedPermission != null) { + if(selectedPermission != -1) { + val permission = runtimePermissions[selectedPermission] fun changeState(state: Int) { - val result = Privilege.DPM.setPermissionGrantState(Privilege.DAR, packageName, selectedPermission!!.permission, state) - if (!result) context.showOperationResultToast(false) - statusMap[selectedPermission!!.permission] = Privilege.DPM.getPermissionGrantState(Privilege.DAR, packageName, selectedPermission!!.permission) - selectedPermission = null + val result = setPackagePermission(packageName, permission.id, state) + if (result) selectedPermission = -1 } @Composable fun GrantPermissionItem(label: Int, status: Int) { - val selected = statusMap[selectedPermission!!.permission] == status + val selected = permissions[permission.id] == status Row( Modifier .fillMaxWidth() @@ -548,14 +380,14 @@ fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager } } AlertDialog( - onDismissRequest = { selectedPermission = null }, - confirmButton = { TextButton({ selectedPermission = null }) { Text(stringResource(R.string.cancel)) } }, - title = { Text(stringResource(selectedPermission!!.label)) }, + onDismissRequest = { selectedPermission = -1 }, + confirmButton = { TextButton({ selectedPermission = -1 }) { Text(stringResource(R.string.cancel)) } }, + title = { Text(stringResource(permission.label)) }, text = { Column { - Text(selectedPermission!!.permission) + Text(permission.id) Spacer(Modifier.padding(vertical = 4.dp)) - if(!(VERSION.SDK_INT >= 31 && selectedPermission!!.profileOwnerRestricted && privilege.profile)) { + if(!(VERSION.SDK_INT >= 31 && permission.profileOwnerRestricted && privilege.profile)) { GrantPermissionItem(R.string.granted, PERMISSION_GRANT_STATE_GRANTED) } GrantPermissionItem(R.string.denied, PERMISSION_GRANT_STATE_DENIED) @@ -568,56 +400,22 @@ fun PermissionsManagerScreen(onNavigateUp: () -> Unit, param: PermissionsManager @Serializable object DisableMeteredData -@RequiresApi(28) -@Composable -fun DisableMeteredDataScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var packageName by remember { mutableStateOf("") } - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getMeteredDataDisabledPackages(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.disable_metered_data, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setMeteredDataDisabledPackages(Privilege.DAR, packages.minus(info).map { it.name }) - refresh() - } - } - item { - PackageNameTextField(packageName, Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } - Button( - { - if(Privilege.DPM.setMeteredDataDisabledPackages(Privilege.DAR, packages.map { it.name } + packageName).isEmpty()) { - packageName = "" - } else { - context.showOperationResultToast(false) - } - refresh() - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - } - } -} - @Serializable object ClearAppStorage @RequiresApi(28) @Composable -fun ClearAppStorageScreen(onNavigateUp: () -> Unit) { - var dialog by remember { mutableStateOf(false) } - var packageName by remember { mutableStateOf("") } +fun ClearAppStorageScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onClear: (String, (Boolean) -> Unit) -> 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, Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { dialog = true }, Modifier.fillMaxWidth(), @@ -626,14 +424,16 @@ fun ClearAppStorageScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.clear)) } } - if(dialog) ClearAppStorageDialog(packageName) { dialog = false } + if(dialog) ClearAppStorageDialog(packageName, onClear) { dialog = false } } @RequiresApi(28) @Composable -private fun ClearAppStorageDialog(packageName: String, onClose: () -> Unit) { +private fun ClearAppStorageDialog( + packageName: String, onClear: (String, (Boolean) -> Unit) -> Unit, onClose: () -> Unit +) { val context = LocalContext.current - var clearing by remember { mutableStateOf(false) } + var clearing by rememberSaveable { mutableStateOf(false) } AlertDialog( title = { Text(stringResource(R.string.clear_app_storage)) }, text = { @@ -643,9 +443,7 @@ private fun ClearAppStorageDialog(packageName: String, onClose: () -> Unit) { TextButton( { clearing = true - Privilege.DPM.clearApplicationUserData( - Privilege.DAR, packageName, Executors.newSingleThreadExecutor() - ) { _, it -> + onClear(packageName) { Looper.prepare() context.showOperationResultToast(it) onClose() @@ -660,18 +458,28 @@ private fun ClearAppStorageDialog(packageName: String, onClose: () -> Unit) { dismissButton = { TextButton(onClose, enabled = !clearing) { Text(stringResource(R.string.cancel)) } }, - onDismissRequest = onClose + onDismissRequest = { + if (!clearing) onClose() + }, + properties = DialogProperties(false, false) ) } @Serializable object UninstallApp @Composable -fun UninstallAppScreen(onNavigateUp: () -> Unit) { - var dialog by remember { mutableStateOf(false) } - var packageName by remember { mutableStateOf("") } +fun UninstallAppScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onUninstall: (String, (String?) -> Unit) -> 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, Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { dialog = true }, Modifier.fillMaxWidth(), @@ -680,14 +488,18 @@ fun UninstallAppScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.uninstall)) } } - if(dialog) UninstallAppDialog(packageName) { dialog = false } + if(dialog) UninstallAppDialog(packageName, onUninstall) { + packageName = "" + dialog = false + } } @Composable -private fun UninstallAppDialog(packageName: String, onClose: () -> Unit) { - val context = LocalContext.current - var uninstalling by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } +private fun UninstallAppDialog( + packageName: String, onUninstall: (String, (String?) -> Unit) -> Unit, onClose: () -> Unit +) { + var uninstalling by rememberSaveable { mutableStateOf(false) } + var errorMessage by rememberSaveable { mutableStateOf(null) } AlertDialog( title = { Text(stringResource(R.string.uninstall)) }, text = { @@ -698,7 +510,7 @@ private fun UninstallAppDialog(packageName: String, onClose: () -> Unit) { TextButton( { uninstalling = true - uninstallPackage(context, packageName) { + onUninstall(packageName) { uninstalling = false if(it == null) onClose() else errorMessage = it } @@ -712,64 +524,32 @@ private fun UninstallAppDialog(packageName: String, onClose: () -> Unit) { dismissButton = { TextButton(onClose, enabled = !uninstalling) { Text(stringResource(R.string.cancel)) } }, - onDismissRequest = onClose + onDismissRequest = onClose, + properties = DialogProperties(false, false) ) } @Serializable object KeepUninstalledPackages -@RequiresApi(28) -@Composable -fun KeepUninstalledPackagesScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getKeepUninstalledPackages(Privilege.DAR)?.forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.keep_uninstalled_packages, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setKeepUninstalledPackages(Privilege.DAR, packages.minus(info).map { it.name }) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } - Button( - { - Privilege.DPM.setKeepUninstalledPackages(Privilege.DAR, packages.map { it.name } + packageName) - packageName = "" - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 8.dp), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - Notes(R.string.info_keep_uninstalled_apps, HorizontalPadding) - } - } -} - @Serializable object InstallExistingApp @RequiresApi(28) @Composable -fun InstallExistingAppScreen(onNavigateUp: () -> Unit) { +fun InstallExistingAppScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onInstall: (String) -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.install_existing_app, onNavigateUp) { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } Button( { - context.showOperationResultToast( - Privilege.DPM.installExistingPackage(Privilege.DAR, packageName) - ) + context.showOperationResultToast(onInstall(packageName)) }, Modifier.fillMaxWidth(), packageName.isValidPackageName @@ -782,101 +562,24 @@ fun InstallExistingAppScreen(onNavigateUp: () -> Unit) { @Serializable object CrossProfilePackages -@RequiresApi(30) -@Composable -fun CrossProfilePackagesScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getCrossProfilePackages(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.cross_profile_apps, onNavigateUp) { - items(packages, { it.name }) { info -> - ApplicationItem(info) { - Privilege.DPM.setCrossProfilePackages(Privilege.DAR, packages.minus(info).map { it.name }.toSet()) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - Privilege.DPM.setCrossProfilePackages(Privilege.DAR, packages.map { it.name }.toSet() + packageName) - packageName = "" - refresh() - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - } - } -} - @Serializable object CrossProfileWidgetProviders -@Composable -fun CrossProfileWidgetProvidersScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val packages = remember { mutableStateListOf() } - fun refresh() { - val pm = context.packageManager - packages.clear() - Privilege.DPM.getCrossProfileWidgetProviders(Privilege.DAR).forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.cross_profile_widget, onNavigateUp) { - items(packages, { it.name }) { - ApplicationItem(it) { - Privilege.DPM.removeCrossProfileWidgetProvider(Privilege.DAR, it.name) - refresh() - } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - Privilege.DPM.addCrossProfileWidgetProvider(Privilege.DAR, packageName) - packageName = "" - refresh() - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - } - } -} - @Serializable object CredentialManagerPolicy @RequiresApi(34) @Composable -fun CredentialManagerPolicyScreen(onNavigateUp: () -> Unit) { +fun CredentialManagerPolicyScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + cmPackages: MutableStateFlow>, getCmPolicy: () -> Int, + setCmPackage: (String, Boolean) -> Unit, setCmPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val pm = context.packageManager - var policyType by remember{ mutableIntStateOf(-1) } - val packages = remember { mutableStateListOf() } - fun refresh() { - val policy = Privilege.DPM.credentialManagerPolicy - policyType = policy?.policyType ?: -1 - packages.clear() - policy?.packageNames?.forEach { - packages += pm.retrieveAppInfo(it) - } + var policy by rememberSaveable { mutableIntStateOf(getCmPolicy()) } + val packages by cmPackages.collectAsStateWithLifecycle() + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() } - LaunchedEffect(Unit) { refresh() } MyLazyScaffold(R.string.credential_manager_policy, onNavigateUp) { item { mapOf( @@ -885,45 +588,39 @@ fun CredentialManagerPolicyScreen(onNavigateUp: () -> Unit) { PackagePolicy.PACKAGE_POLICY_ALLOWLIST to R.string.whitelist, PackagePolicy.PACKAGE_POLICY_ALLOWLIST_AND_SYSTEM to R.string.whitelist_and_system_app ).forEach { (key, value) -> - FullWidthRadioButtonItem(value, policyType == key) { policyType = key } + FullWidthRadioButtonItem(value, policy == key) { policy = key } } Spacer(Modifier.padding(vertical = 4.dp)) } - items(packages, { it.name }) { - ApplicationItem(it) { packages -= it } + if (policy != -1) items(packages, { it.name }) { + ApplicationItem(it) { setCmPackage(it.name, false) } } item { Column(Modifier.padding(horizontal = HorizontalPadding)) { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - packages += pm.retrieveAppInfo(packageName) - }, - Modifier.fillMaxWidth(), - enabled = packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) + if (policy != -1) { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp)) { packageName = it } + Button( + { + setCmPackage(packageName, true) + packageName = "" + }, + Modifier.fillMaxWidth(), + enabled = packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } } Button( { - try { - if(policyType != -1 && packages.isNotEmpty()) { - Privilege.DPM.credentialManagerPolicy = PackagePolicy(policyType, packages.map { it.name }.toSet()) - } else { - Privilege.DPM.credentialManagerPolicy = null - } - context.showOperationResultToast(true) - } catch(_: IllegalArgumentException) { - context.showOperationResultToast(false) - } finally { - refresh() - } + setCmPolicy(policy) + context.showOperationResultToast(true) }, Modifier.fillMaxWidth() ) { Text(stringResource(R.string.apply)) } + Spacer(Modifier.height(BottomPadding)) } } } @@ -931,104 +628,54 @@ fun CredentialManagerPolicyScreen(onNavigateUp: () -> Unit) { @Serializable object PermittedAccessibilityServices -@Composable -fun PermittedAccessibilityServicesScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val pm = context.packageManager - val packages = remember { mutableStateListOf() } - var allowAll by remember { mutableStateOf(true) } - fun refresh() { - packages.clear() - val list = Privilege.DPM.getPermittedAccessibilityServices(Privilege.DAR) - allowAll = list == null - list?.forEach { - packages += pm.retrieveAppInfo(it) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.permitted_accessibility_services, onNavigateUp) { - item { - SwitchItem(R.string.allow_all, state = allowAll, onCheckedChange = { allowAll = it }) - } - items(packages, { it.name }) { - ApplicationItem(it) { packages -= it } - } - item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - packages += pm.retrieveAppInfo(packageName) - packageName = "" - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) - } - Button( - { - val result = Privilege.DPM.setPermittedAccessibilityServices(Privilege.DAR, if(allowAll) null else packages.map { it.name }) - context.showOperationResultToast(result) - refresh() - }, - Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.system_accessibility_always_allowed, HorizontalPadding) - } - } -} - @Serializable object PermittedInputMethods @Composable -fun PermittedInputMethodsScreen(onNavigateUp: () -> Unit) { +fun PermittedAsAndImPackages( + title: Int, note: Int, chosenPackage: Channel, onChoosePackage: () -> Unit, + packagesState: MutableStateFlow>, getPackages: () -> Boolean, + setPackage: (String, Boolean) -> Unit, setPolicy: (Boolean) -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val pm = context.packageManager - val packages = remember { mutableStateListOf() } - var allowAll by remember { mutableStateOf(true) } - fun refresh() { - packages.clear() - val list = Privilege.DPM.getPermittedInputMethods(Privilege.DAR) - allowAll = list == null - list?.forEach { - packages += pm.retrieveAppInfo(it) - } + val packages by packagesState.collectAsStateWithLifecycle() + var packageName by rememberSaveable { mutableStateOf("") } + var allowAll by rememberSaveable { mutableStateOf(getPackages()) } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(R.string.permitted_ime, onNavigateUp) { + MyLazyScaffold(title, onNavigateUp) { item { SwitchItem(R.string.allow_all, state = allowAll, onCheckedChange = { allowAll = it }) } - items(packages, { it.name }) { - ApplicationItem(it) { packages -= it } + if (!allowAll) items(packages, { it.name }) { + ApplicationItem(it) { setPackage(it.name, false) } } item { - var packageName by remember { mutableStateOf("") } - PackageNameTextField(packageName, Modifier.padding(vertical = 8.dp, horizontal = HorizontalPadding)) { packageName = it } - Button( - { - packages += pm.retrieveAppInfo(packageName) - packageName = "" - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.add)) + if (!allowAll) { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + Button( + { + setPackage(packageName, true) + packageName = "" + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } } Button( { - val result = Privilege.DPM.setPermittedInputMethods(Privilege.DAR, if(allowAll) null else packages.map { it.name }) - context.showOperationResultToast(result) - refresh() + context.showOperationResultToast(setPolicy(allowAll)) }, Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.apply)) } - Notes(R.string.system_ime_always_allowed, HorizontalPadding) + Spacer(Modifier.height(10.dp)) + Notes(note, HorizontalPadding) + Spacer(Modifier.height(BottomPadding)) } } } @@ -1036,15 +683,22 @@ fun PermittedInputMethodsScreen(onNavigateUp: () -> Unit) { @Serializable object EnableSystemApp @Composable -fun EnableSystemAppScreen(onNavigateUp: () -> Unit) { +fun EnableSystemAppScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onEnable: (String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.enable_system_app, onNavigateUp) { - var packageName by remember { mutableStateOf("") } Spacer(Modifier.padding(vertical = 4.dp)) - PackageNameTextField(packageName, Modifier.padding(bottom = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(bottom = 8.dp)) { packageName = it } Button( { - Privilege.DPM.enableSystemApp(Privilege.DAR, packageName) + onEnable(packageName) packageName = "" context.showOperationResultToast(true) }, @@ -1061,21 +715,21 @@ fun EnableSystemAppScreen(onNavigateUp: () -> Unit) { @RequiresApi(34) @Composable -fun SetDefaultDialerScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var errorMessage by remember { mutableStateOf(null) } +fun SetDefaultDialerScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onSet: (String) -> Unit, onNavigateUp: () -> Unit +) { + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } MyScaffold(R.string.set_default_dialer, onNavigateUp) { - var packageName by remember { mutableStateOf("") } Spacer(Modifier.padding(vertical = 4.dp)) - PackageNameTextField(packageName, Modifier.padding(bottom = 8.dp)) { packageName = it } + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(bottom = 8.dp)) { packageName = it } Button( { - try { - Privilege.DPM.setDefaultDialerApplication(packageName) - context.showOperationResultToast(true) - } catch(e: Exception) { - errorMessage = e.message - } + onSet(packageName) }, Modifier.fillMaxWidth(), packageName.isValidPackageName @@ -1083,37 +737,247 @@ fun SetDefaultDialerScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.set)) } } - ErrorDialog(errorMessage) { errorMessage = null } } -private fun uninstallPackage(context: Context, packageName: String, onComplete: (String?) -> Unit) { - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { - @SuppressWarnings("UnsafeIntentLaunch") - context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) - } else { - context.unregisterReceiver(this) - if(statusExtra == PackageInstaller.STATUS_SUCCESS) { - onComplete(null) - } else { - onComplete(parsePackageInstallerMessage(context, intent)) +@Composable +fun PackageFunctionScreenWithoutResult( + title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, + onSet: (String, Boolean) -> Unit, onNavigateUp: () -> Unit, + chosenPackage: Channel, onChoosePackage: () -> Unit, + navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null +) { + PackageFunctionScreen( + title, packagesState, onGet, { name, status -> onSet(name, status); null }, + onNavigateUp, chosenPackage, onChoosePackage, navigateToGroups, appGroups, notes + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PackageFunctionScreen( + title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, + onSet: (String, Boolean) -> Boolean?, onNavigateUp: () -> Unit, + chosenPackage: Channel, onChoosePackage: () -> Unit, + navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null +) { + val groups by appGroups.collectAsStateWithLifecycle() + val packages by packagesState.collectAsStateWithLifecycle() + var packageName by rememberSaveable { mutableStateOf("") } + var dialog by remember { mutableStateOf(false) } + var selectedGroup by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + onGet() + packageName = 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 + } + ) + } + } + } + ) + } + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(packages, { it.name }) { + ApplicationItem(it) { + onSet(it.name, false) + } + } + item { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + Button( + { + if (onSet(packageName, true) != false) { + packageName = "" + } + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), + packageName.isValidPackageName && + packages.find { it.name == packageName } == null + ) { + Text(stringResource(R.string.add)) + } + if (notes != null) Notes(notes, HorizontalPadding) + Spacer(Modifier.height(BottomPadding)) + } + } + } + if (dialog) AlertDialog( + text = { + Column { + Button({ + selectedGroup!!.apps.forEach { + onSet(it, true) + } + dialog = false + }) { + Text(stringResource(R.string.add_to_list)) + } + Button({ + selectedGroup!!.apps.forEach { + onSet(it, false) + } + dialog = false + }) { + Text(stringResource(R.string.remove_from_list)) + } + } + }, + confirmButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) +} + +class AppGroup(val id: Int, val name: String, val apps: List) + +@Serializable object ManageAppGroups + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageAppGroupsScreen( + appGroups: StateFlow>, + navigateToEditScreen: (Int?, String, List) -> Unit, navigateUp: () -> Unit +) { + val groups by appGroups.collectAsStateWithLifecycle() + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.app_group)) }, + navigationIcon = { NavIcon(navigateUp) } + ) + }, + floatingActionButton = { + FloatingActionButton({ + navigateToEditScreen(null, "", emptyList()) + }) { + Icon(Icons.Default.Add, null) + } + } + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(groups, { it.id }) { + Column( + Modifier.fillMaxWidth().clickable { + navigateToEditScreen(it.id, it.name, it.apps) + }.padding(HorizontalPadding, 8.dp) + ) { + Text(it.name) + Text( + it.apps.size.toString() + " apps", Modifier.alpha(0.7F), + style = typography.bodyMedium + ) } } } } - ContextCompat.registerReceiver( - context, receiver, IntentFilter(AppInstallerViewModel.ACTION), null, - null, ContextCompat.RECEIVER_EXPORTED - ) - val pi = if(VERSION.SDK_INT >= 34) { - PendingIntent.getBroadcast( - context, 0, Intent(AppInstallerViewModel.ACTION), - PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE - ).intentSender - } else { - PendingIntent.getBroadcast(context, 0, Intent(AppInstallerViewModel.ACTION), PendingIntent.FLAG_MUTABLE).intentSender - } - context.getPackageInstaller().uninstall(packageName, pi) +} + +@Serializable class EditAppGroup(val id: Int?, val name: String, val apps: List) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditAppGroupScreen( + params: EditAppGroup, getAppInfo: (String) -> AppInfo, navigateUp: () -> Unit, + setGroup: (Int?, String, List) -> Unit, deleteGroup: (Int) -> Unit, + onChoosePackage: () -> Unit, chosenPackage: Channel +) { + var name by rememberSaveable { mutableStateOf(params.name) } + val list = rememberSaveable { mutableStateListOf(*params.apps.toTypedArray()) } + val appInfoList = list.map { getAppInfo(it) } + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.edit_app_group)) }, + navigationIcon = { + NavIcon(navigateUp) + }, + actions = { + if (params.id != null) IconButton({ + deleteGroup(params.id) + navigateUp() + }) { + Icon(Icons.Outlined.Delete, null) + } + IconButton( + { + setGroup(params.id, name, list) + navigateUp() + }, + enabled = name.isNotBlank() && list.isNotEmpty() + ) { + Icon(Icons.Default.Check, null) + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + item { + OutlinedTextField( + name, { name = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + label = { Text(stringResource(R.string.name)) } + ) + } + items(appInfoList, { it.name }) { + ApplicationItem(it) { + list -= it.name + } + } + item { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } + Button( + { + list += packageName + packageName = "" + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding).padding(bottom = 10.dp), + packageName.isValidPackageName && packageName !in list + ) { + Text(stringResource(R.string.add)) + } + Spacer(Modifier.height(BottomPadding)) + } + } + } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index a934e9f..b0932b6 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -12,27 +12,28 @@ 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.DrawableRes import androidx.annotation.RequiresApi -import androidx.annotation.StringRes -import androidx.core.content.pm.ShortcutManagerCompat +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.createShortcuts +import com.bintianqi.owndroid.ShortcutUtils import com.rosan.dhizuku.api.Dhizuku import com.rosan.dhizuku.api.DhizukuBinderWrapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import java.io.OutputStream +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject @SuppressLint("PrivateApi") fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager? { @@ -90,342 +91,423 @@ fun Context.getPackageInstaller(): PackageInstaller { val dhizukuErrorStatus = MutableStateFlow(0) data class PermissionItem( - val permission: String, - @StringRes val label: Int, - @DrawableRes val icon: Int, - val profileOwnerRestricted: Boolean = false + val id: String, + val label: Int, + val icon: Int, + val profileOwnerRestricted: Boolean = false, + val requiresApi: Int = 23 ) -fun permissionList(): List{ - val list = mutableListOf() - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.POST_NOTIFICATIONS, R.string.permission_POST_NOTIFICATIONS, R.drawable.notifications_fill0)) - } - list.add(PermissionItem(Manifest.permission.READ_EXTERNAL_STORAGE, R.string.permission_READ_EXTERNAL_STORAGE, R.drawable.folder_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_WRITE_EXTERNAL_STORAGE, R.drawable.folder_fill0)) - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.READ_MEDIA_AUDIO, R.string.permission_READ_MEDIA_AUDIO, R.drawable.music_note_fill0)) - list.add(PermissionItem(Manifest.permission.READ_MEDIA_VIDEO, R.string.permission_READ_MEDIA_VIDEO, R.drawable.movie_fill0)) - list.add(PermissionItem(Manifest.permission.READ_MEDIA_IMAGES, R.string.permission_READ_MEDIA_IMAGES, R.drawable.image_fill0)) - } - list.add(PermissionItem(Manifest.permission.CAMERA, R.string.permission_CAMERA, R.drawable.photo_camera_fill0, true)) - list.add(PermissionItem(Manifest.permission.RECORD_AUDIO, R.string.permission_RECORD_AUDIO, R.drawable.mic_fill0, true)) - list.add(PermissionItem(Manifest.permission.ACCESS_COARSE_LOCATION, R.string.permission_ACCESS_COARSE_LOCATION, R.drawable.location_on_fill0, true)) - list.add(PermissionItem(Manifest.permission.ACCESS_FINE_LOCATION, R.string.permission_ACCESS_FINE_LOCATION, R.drawable.location_on_fill0, true)) - if(VERSION.SDK_INT >= 29) { - list.add(PermissionItem(Manifest.permission.ACCESS_BACKGROUND_LOCATION, R.string.permission_ACCESS_BACKGROUND_LOCATION, R.drawable.location_on_fill0, true)) - } - list.add(PermissionItem(Manifest.permission.READ_CONTACTS, R.string.permission_READ_CONTACTS, R.drawable.contacts_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_CONTACTS, R.string.permission_WRITE_CONTACTS, R.drawable.contacts_fill0)) - list.add(PermissionItem(Manifest.permission.READ_CALENDAR, R.string.permission_READ_CALENDAR, R.drawable.calendar_month_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_CALENDAR, R.string.permission_WRITE_CALENDAR, R.drawable.calendar_month_fill0)) - if(VERSION.SDK_INT >= 31) { - list.add(PermissionItem(Manifest.permission.BLUETOOTH_CONNECT, R.string.permission_BLUETOOTH_CONNECT, R.drawable.bluetooth_fill0)) - list.add(PermissionItem(Manifest.permission.BLUETOOTH_SCAN, R.string.permission_BLUETOOTH_SCAN, R.drawable.bluetooth_searching_fill0)) - list.add(PermissionItem(Manifest.permission.BLUETOOTH_ADVERTISE, R.string.permission_BLUETOOTH_ADVERTISE, R.drawable.bluetooth_fill0)) - } - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.NEARBY_WIFI_DEVICES, R.string.permission_NEARBY_WIFI_DEVICES, R.drawable.wifi_fill0)) - } - list.add(PermissionItem(Manifest.permission.CALL_PHONE, R.string.permission_CALL_PHONE, R.drawable.call_fill0)) - if(VERSION.SDK_INT >= 26) { - list.add(PermissionItem(Manifest.permission.ANSWER_PHONE_CALLS, R.string.permission_ANSWER_PHONE_CALLS, R.drawable.call_fill0)) - list.add(PermissionItem(Manifest.permission.READ_PHONE_NUMBERS, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0)) - } - list.add(PermissionItem(Manifest.permission.READ_PHONE_STATE, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0)) - list.add(PermissionItem(Manifest.permission.USE_SIP, R.string.permission_USE_SIP, R.drawable.call_fill0)) - if(VERSION.SDK_INT >= 31) { - list.add(PermissionItem(Manifest.permission.UWB_RANGING, R.string.permission_UWB_RANGING, R.drawable.cell_tower_fill0)) - } - list.add(PermissionItem(Manifest.permission.READ_SMS, R.string.permission_READ_SMS, R.drawable.sms_fill0)) - list.add(PermissionItem(Manifest.permission.RECEIVE_SMS, R.string.permission_RECEIVE_SMS, R.drawable.sms_fill0)) - list.add(PermissionItem(Manifest.permission.SEND_SMS, R.string.permission_SEND_SMS, R.drawable.sms_fill0)) - list.add(PermissionItem(Manifest.permission.READ_CALL_LOG, R.string.permission_READ_CALL_LOG, R.drawable.call_log_fill0)) - list.add(PermissionItem(Manifest.permission.WRITE_CALL_LOG, R.string.permission_WRITE_CALL_LOG, R.drawable.call_log_fill0)) - list.add(PermissionItem(Manifest.permission.RECEIVE_WAP_PUSH, R.string.permission_RECEIVE_WAP_PUSH, R.drawable.wifi_fill0)) - list.add(PermissionItem(Manifest.permission.BODY_SENSORS, R.string.permission_BODY_SENSORS, R.drawable.sensors_fill0, true)) - if(VERSION.SDK_INT >= 33) { - list.add(PermissionItem(Manifest.permission.BODY_SENSORS_BACKGROUND, R.string.permission_BODY_SENSORS_BACKGROUND, R.drawable.sensors_fill0)) - } - if(VERSION.SDK_INT > 29) { - list.add(PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true)) - } - return list -} +@Suppress("InlinedApi") +val runtimePermissions = listOf( + PermissionItem(Manifest.permission.POST_NOTIFICATIONS, R.string.permission_POST_NOTIFICATIONS, R.drawable.notifications_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_EXTERNAL_STORAGE, R.string.permission_READ_EXTERNAL_STORAGE, R.drawable.folder_fill0), + PermissionItem(Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_WRITE_EXTERNAL_STORAGE, R.drawable.folder_fill0), + PermissionItem(Manifest.permission.READ_MEDIA_AUDIO, R.string.permission_READ_MEDIA_AUDIO, R.drawable.music_note_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_MEDIA_VIDEO, R.string.permission_READ_MEDIA_VIDEO, R.drawable.movie_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_MEDIA_IMAGES, R.string.permission_READ_MEDIA_IMAGES, R.drawable.image_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.CAMERA, R.string.permission_CAMERA, R.drawable.photo_camera_fill0, true), + PermissionItem(Manifest.permission.RECORD_AUDIO, R.string.permission_RECORD_AUDIO, R.drawable.mic_fill0, true), + PermissionItem(Manifest.permission.ACCESS_COARSE_LOCATION, R.string.permission_ACCESS_COARSE_LOCATION, R.drawable.location_on_fill0, true), + PermissionItem(Manifest.permission.ACCESS_FINE_LOCATION, R.string.permission_ACCESS_FINE_LOCATION, R.drawable.location_on_fill0, true), + PermissionItem(Manifest.permission.ACCESS_BACKGROUND_LOCATION, R.string.permission_ACCESS_BACKGROUND_LOCATION, R.drawable.location_on_fill0, true, 29), + PermissionItem(Manifest.permission.READ_CONTACTS, R.string.permission_READ_CONTACTS, R.drawable.contacts_fill0), + PermissionItem(Manifest.permission.WRITE_CONTACTS, R.string.permission_WRITE_CONTACTS, R.drawable.contacts_fill0), + PermissionItem(Manifest.permission.READ_CALENDAR, R.string.permission_READ_CALENDAR, R.drawable.calendar_month_fill0), + PermissionItem(Manifest.permission.WRITE_CALENDAR, R.string.permission_WRITE_CALENDAR, R.drawable.calendar_month_fill0), + PermissionItem(Manifest.permission.BLUETOOTH_CONNECT, R.string.permission_BLUETOOTH_CONNECT, R.drawable.bluetooth_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.BLUETOOTH_SCAN, R.string.permission_BLUETOOTH_SCAN, R.drawable.bluetooth_searching_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.BLUETOOTH_ADVERTISE, R.string.permission_BLUETOOTH_ADVERTISE, R.drawable.bluetooth_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.NEARBY_WIFI_DEVICES, R.string.permission_NEARBY_WIFI_DEVICES, R.drawable.wifi_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.CALL_PHONE, R.string.permission_CALL_PHONE, R.drawable.call_fill0), + PermissionItem(Manifest.permission.ANSWER_PHONE_CALLS, R.string.permission_ANSWER_PHONE_CALLS, R.drawable.call_fill0, requiresApi = 26), + PermissionItem(Manifest.permission.READ_PHONE_NUMBERS, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0, requiresApi = 26), + PermissionItem(Manifest.permission.READ_PHONE_STATE, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0), + PermissionItem(Manifest.permission.USE_SIP, R.string.permission_USE_SIP, R.drawable.call_fill0), + PermissionItem(Manifest.permission.UWB_RANGING, R.string.permission_UWB_RANGING, R.drawable.cell_tower_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.READ_SMS, R.string.permission_READ_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.RECEIVE_SMS, R.string.permission_RECEIVE_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.SEND_SMS, R.string.permission_SEND_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.READ_CALL_LOG, R.string.permission_READ_CALL_LOG, R.drawable.call_log_fill0), + PermissionItem(Manifest.permission.WRITE_CALL_LOG, R.string.permission_WRITE_CALL_LOG, R.drawable.call_log_fill0), + PermissionItem(Manifest.permission.RECEIVE_WAP_PUSH, R.string.permission_RECEIVE_WAP_PUSH, R.drawable.wifi_fill0), + PermissionItem(Manifest.permission.BODY_SENSORS, R.string.permission_BODY_SENSORS, R.drawable.sensors_fill0, true), + PermissionItem(Manifest.permission.BODY_SENSORS_BACKGROUND, R.string.permission_BODY_SENSORS_BACKGROUND, R.drawable.sensors_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true, 29) +).filter { VERSION.SDK_INT >= it.requiresApi } + +@Serializable +class NetworkLog( + val id: Long?, @SerialName("package") val packageName: String, val time: Long, val type: String, + val host: String?, val count: Int?, val addresses: List?, + val address: String?, val port: Int? +) @RequiresApi(26) -fun handleNetworkLogs(context: Context, batchToken: Long) { - val networkEvents = Privilege.DPM.retrieveNetworkLogs(Privilege.DAR, batchToken) ?: return - val file = context.filesDir.resolve("NetworkLogs.json") - val fileExist = file.exists() - val json = Json { ignoreUnknownKeys = true; explicitNulls = false } - val buffer = file.bufferedWriter() - networkEvents.forEachIndexed { index, event -> - if(fileExist && index == 0) buffer.write(",") - val item = buildJsonObject { - if(VERSION.SDK_INT >= 28) put("id", event.id) - put("time", event.timestamp) - put("package", event.packageName) - if(event is DnsEvent) { - put("type", "dns") - put("host", event.hostname) - put("count", event.totalResolvedAddressCount) - putJsonArray("addresses") { - event.inetAddresses.forEach { inetAddresses -> - add(inetAddresses.hostAddress) - } - } - } - if(event is ConnectEvent) { - put("type", "connect") - put("address", event.inetAddress.hostAddress) - put("port", event.port) +fun retrieveNetworkLogs(app: MyApplication, token: Long) { + CoroutineScope(Dispatchers.IO).launch { + val logs = 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 } } - buffer.write(json.encodeToString(item)) - if(index < networkEvents.size - 1) buffer.write(",") + if (logs.isNullOrEmpty()) return@launch + app.myRepo.writeNetworkLogs(logs) + NotificationUtils.sendBasicNotification( + app, NotificationType.NetworkLogsCollected, + app.getString(R.string.n_logs_in_total, logs.size) + ) } - buffer.close() } -@RequiresApi(24) -fun processSecurityLogs(securityEvents: List, outputStream: OutputStream) { - val json = Json { ignoreUnknownKeys = true; explicitNulls = false } - val buffer = outputStream.bufferedWriter() - securityEvents.forEachIndexed { index, event -> - val item = buildJsonObject { - put("time", event.timeNanos / 1000) - put("tag", event.tag) - if(VERSION.SDK_INT >= 28) put("level", event.logLevel) - if(VERSION.SDK_INT >= 28) put("id", event.id) - parseSecurityEventData(event).let { if(it != null) put("data", it) } - } - buffer.write(json.encodeToString(item)) - if(index < securityEvents.size - 1) buffer.write(",") - } - buffer.close() +@Serializable +class SecurityEvent( + val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: JsonObject? +) + +@Serializable +class SecurityEventWithData( + val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: SecurityEventData? +) + +@Serializable +sealed class SecurityEventData { + @Serializable + class AdbShellCmd(val command: String): SecurityEventData() + @Serializable + class AppProcessStart( + val name: String, + val time: Long, + val uid: Int, + val pid: Int, + val seinfo: String, + val hash: String + ): SecurityEventData() + @Serializable + class BackupServiceToggled( + val admin: String, + val user: Int, + val state: Int + ): SecurityEventData() + @Serializable + class BluetoothConnection( + val mac: String, + val successful: Int, + @SerialName("failure_reason") val failureReason: String + ): SecurityEventData() + @Serializable + class BluetoothDisconnection( + val mac: String, + val reason: String + ): SecurityEventData() + @Serializable + class CameraPolicySet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val disabled: Int + ): SecurityEventData() + @Serializable + class CaInstalledRemoved( + val result: Int, + val subject: String, + val user: Int + ): SecurityEventData() + @Serializable + class CertValidationFailure(val reason: String): SecurityEventData() + @Serializable + class CryptoSelfTestCompleted(val result: Int): SecurityEventData() + @Serializable + class KeyguardDisabledFeaturesSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val mask: Int + ): SecurityEventData() + @Serializable + class KeyguardDismissAuthAttempt( + val result: Int, + val strength: Int + ): SecurityEventData() + @Serializable + class KeyGeneratedImportDestruction( + val result: Int, + val alias: String, + val uid: Int + ): SecurityEventData() + @Serializable + class KeyIntegrityViolation( + val alias: String, + val uid: Int + ): SecurityEventData() + @Serializable + class MaxPasswordAttemptsSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val value: Int + ): SecurityEventData() + @Serializable + class MaxScreenLockTimeoutSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val timeout: Long + ): SecurityEventData() + @Serializable + class MediaMountUnmount( + @SerialName("mount_point") val mountPoint: String, + val label: String + ): SecurityEventData() + @Serializable + class OsStartup( + @SerialName("verified_boot_state") val verifiedBootState: String, + @SerialName("dm_verity_mode") val dmVerityMode: String + ): SecurityEventData() + @Serializable + class PackageInstalledUninstalledUpdated( + val name: String, + val version: Long, + val user: Int + ): SecurityEventData() + @Serializable + class PasswordChanged( + val complexity: Int, + val user: Int + ): SecurityEventData() + @Serializable + class PasswordComplexityRequired( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val complexity: Int + ): SecurityEventData() + @Serializable + class PasswordComplexitySet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val length: Int, + val quality: Int, + val letters: Int, + @SerialName("non_letters") val nonLetters: Int, + val digits: Int, + val uppercase: Int, + val lowercase: Int, + val symbols: Int + ): SecurityEventData() + @Serializable + class PasswordExpirationSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val expiration: Long + ): SecurityEventData() + @Serializable + class PasswordHistoryLengthSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val length: Int + ): SecurityEventData() + @Serializable + class RemoteLock( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + ): SecurityEventData() + @Serializable + class SyncRecvSendFile(val path: String): SecurityEventData() + @Serializable + class UserRestrictionAddedRemoved( + val admin: String, + val user: Int, + val restriction: String + ): SecurityEventData() + @Serializable + class WifiConnection( + val bssid: String, + val type: String, + @SerialName("failure_reason") val failureReason: String + ): SecurityEventData() + @Serializable + class WifiDisconnection( + val bssid: String, + val reason: String + ): SecurityEventData() } -@RequiresApi(24) -fun parseSecurityEventData(event: SecurityLog.SecurityEvent): JsonElement? { - return when(event.tag) { - SecurityLog.TAG_ADB_SHELL_CMD -> JsonPrimitive(event.data as String) +fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? { + return when(tag) { + SecurityLog.TAG_ADB_SHELL_CMD -> SecurityEventData.AdbShellCmd(payload as String) SecurityLog.TAG_ADB_SHELL_INTERACTIVE -> null SecurityLog.TAG_APP_PROCESS_START -> { - val payload = event.data as Array<*> - buildJsonObject { - put("name", payload[0] as String) - put("time", payload[1] as Long) - put("uid", payload[2] as Int) - put("pid", payload[3] as Int) - put("seinfo", payload[4] as String) - put("apk_hash", payload[5] as String) - } + val data = payload as Array<*> + SecurityEventData.AppProcessStart( + data[0] as String, data[1] as Long, data[2] as Int, data[3] as Int, + data[4] as String, data[5] as String + ) } SecurityLog.TAG_BACKUP_SERVICE_TOGGLED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("state", payload[2] as Int) - } + val data = payload as Array<*> + SecurityEventData.BackupServiceToggled(data[0] as String, data[1] as Int, data[2] as Int) } SecurityLog.TAG_BLUETOOTH_CONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("mac", payload[0] as String) - put("successful", payload[1] as Int) - (payload[2] as String).let { if(it != "") put("failure_reason", it) } - } + val data = payload as Array<*> + SecurityEventData.BluetoothConnection(data[0] as String, data[1] as Int, data[2] as String) } SecurityLog.TAG_BLUETOOTH_DISCONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("mac", payload[0] as String) - (payload[1] as String).let { if(it != "") put("reason", it) } - } + val data = payload as Array<*> + SecurityEventData.BluetoothDisconnection(data[0] as String, data[1] as String) } SecurityLog.TAG_CAMERA_POLICY_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("disabled", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.CameraPolicySet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_CERT_AUTHORITY_INSTALLED, SecurityLog.TAG_CERT_AUTHORITY_REMOVED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("result", payload[0] as Int) - put("subject", payload[1] as String) - if(VERSION.SDK_INT >= 30) put("user", payload[2] as Int) - } + val data = payload as Array<*> + SecurityEventData.CaInstalledRemoved(data[0] as Int, data[1] as String, data[2] as Int) } - SecurityLog.TAG_CERT_VALIDATION_FAILURE -> JsonPrimitive(event.data as String) - SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED -> JsonPrimitive(event.data as Int) + SecurityLog.TAG_CERT_VALIDATION_FAILURE -> + SecurityEventData.CertValidationFailure(payload as String) + SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED -> + SecurityEventData.CryptoSelfTestCompleted(payload as Int) SecurityLog.TAG_KEYGUARD_DISABLED_FEATURES_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("mask", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.KeyguardDisabledFeaturesSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_KEYGUARD_DISMISSED -> null SecurityLog.TAG_KEYGUARD_DISMISS_AUTH_ATTEMPT -> { - val payload = event.data as Array<*> - buildJsonObject { - put("result", payload[0] as Int) - put("strength", payload[1] as Int) - } + val data = payload as Array<*> + SecurityEventData.KeyguardDismissAuthAttempt(data[0] as Int, data[1] as Int) } SecurityLog.TAG_KEYGUARD_SECURED -> null - SecurityLog.TAG_KEY_DESTRUCTION, SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT -> { - val payload = event.data as Array<*> - buildJsonObject { - put("result", payload[0] as Int) - put("alias", payload[1] as String) - put("uid", payload[2] as Int) - } - } - SecurityLog.TAG_KEY_INTEGRITY_VIOLATION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("alias", payload[0] as String) - put("uid", payload[1] as Int) - } + SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT, SecurityLog.TAG_KEY_DESTRUCTION -> { + val data = payload as Array<*> + SecurityEventData.KeyGeneratedImportDestruction( + data[0] as Int, data[1] as String, data[2] as Int + ) } SecurityLog.TAG_LOGGING_STARTED, SecurityLog.TAG_LOGGING_STOPPED -> null SecurityLog.TAG_LOG_BUFFER_SIZE_CRITICAL -> null SecurityLog.TAG_MAX_PASSWORD_ATTEMPTS_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("value", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.MaxPasswordAttemptsSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_MAX_SCREEN_LOCK_TIMEOUT_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("timeout", payload[3] as Long) - } + val data = payload as Array<*> + SecurityEventData.MaxScreenLockTimeoutSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long + ) } SecurityLog.TAG_MEDIA_MOUNT, SecurityLog.TAG_MEDIA_UNMOUNT -> { - val payload = event.data as Array<*> - buildJsonObject { - put("mount_point", payload[0] as String) - put("volume_label", payload[1] as String) - } + val data = payload as Array<*> + SecurityEventData.MediaMountUnmount(data[0] as String, data[1] as String) } + SecurityLog.TAG_NFC_ENABLED, SecurityLog.TAG_NFC_DISABLED -> null SecurityLog.TAG_OS_SHUTDOWN -> null SecurityLog.TAG_OS_STARTUP -> { - val payload = event.data as Array<*> - buildJsonObject { - put("verified_boot_state", payload[0] as String) - put("dm_verify_state", payload[1] as String) - } + val data = payload as Array<*> + SecurityEventData.OsStartup(data[0] as String, data[1] as String) } - SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UNINSTALLED, SecurityLog.TAG_PACKAGE_UPDATED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("name", payload[0] as String) - put("version", payload[1] as Long) - put("user_id", payload[2] as Int) - } + SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UPDATED, + SecurityLog.TAG_PACKAGE_UNINSTALLED -> { + val data = payload as Array<*> + SecurityEventData.PackageInstalledUninstalledUpdated( + data[0] as String, data[1] as Long, data[2] as Int + ) } SecurityLog.TAG_PASSWORD_CHANGED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("complexity", payload[0] as Int) - put("user_id", payload[1] as Int) - } + val data = payload as Array<*> + SecurityEventData.PasswordChanged(data[0] as Int, data[1] as Int) } - SecurityLog. TAG_PASSWORD_COMPLEXITY_REQUIRED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("complexity", payload[3] as Int) - } + SecurityLog.TAG_PASSWORD_COMPLEXITY_REQUIRED -> { + val data = payload as Array<*> + SecurityEventData.PasswordComplexityRequired( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) + } + SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> { + val data = payload as Array<*> + SecurityEventData.PasswordComplexitySet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int, data[4] as Int, + data[5] as Int, data[6] as Int, data[7] as Int, data[8] as Int, data[9] as Int, + data[10] as Int + ) } - SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> null //Deprecated SecurityLog.TAG_PASSWORD_EXPIRATION_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("timeout", payload[3] as Long) - } + val data = payload as Array<*> + SecurityEventData.PasswordExpirationSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long + ) } SecurityLog.TAG_PASSWORD_HISTORY_LENGTH_SET -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - put("length", payload[3] as Int) - } + val data = payload as Array<*> + SecurityEventData.PasswordHistoryLengthSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) } SecurityLog.TAG_REMOTE_LOCK -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("target_user_id", payload[2] as Int) - } + val data = payload as Array<*> + SecurityEventData.RemoteLock(data[0] as String, data[1] as Int, data[2] as Int) } - SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE -> JsonPrimitive(event.data as String) + SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE -> + SecurityEventData.SyncRecvSendFile(payload as String) SecurityLog.TAG_USER_RESTRICTION_ADDED, SecurityLog.TAG_USER_RESTRICTION_REMOVED -> { - val payload = event.data as Array<*> - buildJsonObject { - put("admin", payload[0] as String) - put("admin_user_id", payload[1] as Int) - put("restriction", payload[2] as String) - } + val data = payload as Array<*> + SecurityEventData.UserRestrictionAddedRemoved( + data[0] as String, data[1] as Int, data[2] as String + ) } SecurityLog.TAG_WIFI_CONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("bssid", payload[0] as String) - put("type", payload[1] as String) - (payload[2] as String).let { if(it != "") put("failure_reason", it) } - } + val data = payload as Array<*> + SecurityEventData.WifiConnection(data[0] as String, data[1] as String, data[2] as String) } SecurityLog.TAG_WIFI_DISCONNECTION -> { - val payload = event.data as Array<*> - buildJsonObject { - put("bssid", payload[0] as String) - (payload[1] as String).let { if(it != "") put("reason", it) } - } + val data = payload as Array<*> + SecurityEventData.WifiDisconnection(data[0] as String, data[1] as String) } SecurityLog.TAG_WIPE_FAILURE -> null else -> null } } +@RequiresApi(24) +fun retrieveSecurityLogs(app: MyApplication) { + CoroutineScope(Dispatchers.IO).launch { + val logs = 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 - val privilege = Privilege.status.value + if (VERSION.SDK_INT < 26) return if(!SP.isDefaultAffiliationIdSet) { try { - if(privilege.device || (!privilege.primary && privilege.profile)) { - val affiliationIDs = Privilege.DPM.getAffiliationIds(Privilege.DAR) - if(affiliationIDs.isEmpty()) { - Privilege.DPM.setAffiliationIds(Privilege.DAR, setOf("OwnDroid_default_affiliation_id")) - SP.isDefaultAffiliationIdSet = true - Log.d("DPM", "Default affiliation id set") - } - } - } catch(e: Exception) { + DPM.setAffiliationIds(DAR, setOf("OwnDroid_default_affiliation_id")) + SP.isDefaultAffiliationIdSet = true + Log.d("DPM", "Default affiliation id set") + } catch (e: Exception) { e.printStackTrace() } } @@ -469,16 +551,41 @@ fun parsePackageInstallerMessage(context: Context, result: Intent): String { fun handlePrivilegeChange(context: Context) { val privilege = Privilege.status.value SP.dhizukuServer = false + SP.shortcuts = privilege.activated if (privilege.activated) { - createShortcuts(context) + ShortcutUtils.setAllShortcuts(context, true) if (!privilege.dhizuku) { setDefaultAffiliationID() } } else { SP.isDefaultAffiliationIdSet = false - if(VERSION.SDK_INT >= 25) { - ShortcutManagerCompat.removeAllDynamicShortcuts(context) - } - SP.isApiEnabled = 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) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 965ffa1..5e1abd2 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -2,59 +2,40 @@ package com.bintianqi.owndroid.dpm import android.Manifest import android.annotation.SuppressLint -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_OFF -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_OPPORTUNISTIC -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_MODE_UNKNOWN -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_SET_ERROR_FAILURE_SETTING -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_SET_ERROR_HOST_NOT_SERVING -import android.app.admin.DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR +import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_192 import android.app.admin.DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_EAP import android.app.admin.DevicePolicyManager.WIFI_SECURITY_OPEN import android.app.admin.DevicePolicyManager.WIFI_SECURITY_PERSONAL -import android.app.admin.PreferentialNetworkServiceConfig import android.app.admin.WifiSsidPolicy -import android.app.admin.WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_ALLOWLIST -import android.app.admin.WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_DENYLIST import android.app.usage.NetworkStats -import android.app.usage.NetworkStatsManager -import android.content.Context -import android.content.pm.PackageManager.NameNotFoundException import android.net.ConnectivityManager -import android.net.IpConfiguration -import android.net.LinkAddress -import android.net.ProxyInfo -import android.net.StaticIpConfiguration +import android.net.Uri import android.net.wifi.WifiConfiguration -import android.net.wifi.WifiManager -import android.net.wifi.WifiSsid import android.os.Build.VERSION -import android.os.Bundle +import android.provider.Telephony +import android.telephony.TelephonyManager import android.telephony.data.ApnSetting import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.Keep import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.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 @@ -67,30 +48,29 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.FilterChip +import androidx.compose.material3.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.MaterialTheme.typography import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -100,6 +80,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -112,10 +93,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource @@ -124,21 +106,21 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.core.os.bundleOf import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ChoosePackageContract +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.clickableTextField import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.humanReadableDate +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.ErrorDialog -import com.bintianqi.owndroid.ui.ExpandExposedTextFieldIcon +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.ListItem @@ -146,21 +128,19 @@ import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.MySmallTitleScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes -import com.bintianqi.owndroid.ui.RadioButtonItem import com.bintianqi.owndroid.ui.SwitchItem import com.bintianqi.owndroid.writeClipBoard +import com.bintianqi.owndroid.yesOrNo import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import java.net.InetAddress import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import kotlin.reflect.jvm.jvmErasure @Serializable object Network @@ -172,7 +152,7 @@ fun NetworkScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(VERSION.SDK_INT >= 30) { FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(NetworkOptions) } } - if(VERSION.SDK_INT >= 23 && !privilege.dhizuku && (privilege.device || privilege.profile)) + if (VERSION.SDK_INT >= 23 && !privilege.dhizuku) FunctionItem(R.string.network_stats, icon = R.drawable.query_stats_fill0) { onNavigate(QueryNetworkStats) } if(VERSION.SDK_INT >= 29 && privilege.device) { FunctionItem(R.string.private_dns, icon = R.drawable.dns_fill0) { onNavigate(PrivateDns) } @@ -186,9 +166,9 @@ fun NetworkScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(VERSION.SDK_INT >= 26 && !privilege.dhizuku && (privilege.device || privilege.work)) { FunctionItem(R.string.network_logging, icon = R.drawable.description_fill0) { onNavigate(NetworkLogging) } } - if(VERSION.SDK_INT >= 31) { + /*if(VERSION.SDK_INT >= 31) { FunctionItem(R.string.wifi_auth_keypair, icon = R.drawable.key_fill0) { onNavigate(WifiAuthKeypair) } - } + }*/ if (VERSION.SDK_INT >= 33 && (privilege.work || privilege.device)) { FunctionItem(R.string.preferential_network_service, icon = R.drawable.globe_fill0) { onNavigate(PreferentialNetworkService) } } @@ -201,14 +181,20 @@ fun NetworkScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { @Serializable object NetworkOptions @Composable -fun NetworkOptionsScreen(onNavigateUp: () -> Unit) { +fun NetworkOptionsScreen( + getLanEnabled: () -> Boolean, setLanEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } + var lanEnabled by rememberSaveable { mutableStateOf(getLanEnabled()) } MyScaffold(R.string.options, onNavigateUp, 0.dp) { if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { SwitchItem(R.string.lockdown_admin_configured_network, icon = R.drawable.wifi_password_fill0, - getState = { Privilege.DPM.hasLockdownAdminConfiguredNetworks(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setConfiguredNetworksLockdownState(Privilege.DAR, it) }, + state = lanEnabled, + onCheckedChange = { + setLanEnabled(it) + lanEnabled = it + }, onClickBlank = { dialog = 1 } ) } @@ -226,8 +212,10 @@ fun NetworkOptionsScreen(onNavigateUp: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun WifiScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit, onNavigateToUpdateNetwork: (Bundle) -> Unit) { - val context = LocalContext.current +fun WifiScreen( + vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit, + editNetwork: (Int) -> Unit +) { val coroutine = rememberCoroutineScope() val pagerState = rememberPagerState { 3 } var tabIndex by rememberSaveable { mutableIntStateOf(0) } @@ -240,135 +228,189 @@ fun WifiScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit, onNavigateTo colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> - var wifiMacDialog by remember { mutableStateOf(false) } Column( modifier = Modifier.fillMaxSize().padding(paddingValues) ) { TabRow(tabIndex) { Tab( - selected = tabIndex == 0, onClick = { tabIndex = 0; coroutine.launch { pagerState.animateScrollToPage(tabIndex) } }, + tabIndex == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, text = { Text(stringResource(R.string.overview)) } ) Tab( - selected = tabIndex == 1, onClick = { tabIndex = 1; coroutine.launch { pagerState.animateScrollToPage(tabIndex) } }, + tabIndex == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, text = { Text(stringResource(R.string.saved_networks)) } ) Tab( - selected = tabIndex == 2, onClick = { tabIndex = 2; coroutine.launch { pagerState.animateScrollToPage(tabIndex) } }, + tabIndex == 2, { coroutine.launch { pagerState.animateScrollToPage(2) } }, text = { Text(stringResource(R.string.add_network)) } ) } HorizontalPager(state = pagerState, verticalAlignment = Alignment.Top) { page -> - if(page == 0) { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val privilege by Privilege.status.collectAsStateWithLifecycle() - @Suppress("DEPRECATION") Column( - modifier = Modifier.fillMaxSize().padding(top = 12.dp) - ) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Button( - onClick = { context.showOperationResultToast(wm.setWifiEnabled(true)) }, - modifier = Modifier.padding(end = 8.dp) - ) { - Text(stringResource(R.string.enable)) - } - Button(onClick = { context.showOperationResultToast(wm.setWifiEnabled(false)) }) { - Text(stringResource(R.string.disable)) - } - } - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) - ) { - Button( - onClick = { context.showOperationResultToast(wm.disconnect()) }, - modifier = Modifier.padding(end = 8.dp) - ) { - Text(stringResource(R.string.disconnect)) - } - Button(onClick = { context.showOperationResultToast(wm.reconnect()) }) { - Text(stringResource(R.string.reconnect)) - } - } - if(VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { - FunctionItem(R.string.wifi_mac_address) { wifiMacDialog = true } - } - if(VERSION.SDK_INT >= 33 && (privilege.device || privilege.org)) { - FunctionItem(R.string.min_wifi_security_level) { onNavigate(WifiSecurityLevel) } - FunctionItem(R.string.wifi_ssid_policy) { onNavigate(WifiSsidPolicyScreen) } - } + @Suppress("NewApi") + when (page) { + 0 -> WifiOverviewScreen(vm::setWifiEnabled, vm::disconnectWifi, + vm::reconnectWifi, vm::getWifiMac, onNavigate) + 1 -> SavedNetworks(vm.configuredNetworks, vm::getConfiguredNetworks, + vm::enableNetwork, vm::disableNetwork, vm::removeNetwork, editNetwork) + 2 -> AddNetworkScreen(null, vm::setWifi) { + coroutine.launch { pagerState.animateScrollToPage(1) } } - } else if(page == 1) { - SavedNetworks(onNavigateToUpdateNetwork) - } else { - AddNetworkScreen(null) {} } } } - if(wifiMacDialog && VERSION.SDK_INT >= 24) { - AlertDialog( - onDismissRequest = { wifiMacDialog = false }, - confirmButton = { TextButton(onClick = { wifiMacDialog = false }) { Text(stringResource(R.string.confirm)) } }, - text = { - val mac = Privilege.DPM.getWifiMacAddress(Privilege.DAR) - OutlinedTextField( - value = mac ?: stringResource(R.string.none), label = { Text(stringResource(R.string.wifi_mac_address)) }, - onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), textStyle = typography.bodyLarge, - trailingIcon = { - if(mac != null) IconButton(onClick = { writeClipBoard(context, mac) }) { - Icon(painter = painterResource(R.drawable.content_copy_fill0), contentDescription = stringResource(R.string.copy)) - } - } - ) - }, - modifier = Modifier.fillMaxWidth() - ) + } +} + +@Composable +fun WifiOverviewScreen( + setWifiEnabled: (Boolean) -> Boolean, disconnect: () -> Boolean, reconnect: () -> Boolean, + getMac: () -> String?, navigate: (Any) -> Unit +) { + val context = LocalContext.current + val privilege by Privilege.status.collectAsStateWithLifecycle() + var macDialog by rememberSaveable { mutableStateOf(false) } + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(10.dp)) + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { context.showOperationResultToast(setWifiEnabled(true)) }, + modifier = Modifier.padding(end = 8.dp) + ) { + Text(stringResource(R.string.enable)) + } + Button(onClick = { context.showOperationResultToast(setWifiEnabled(false)) }) { + Text(stringResource(R.string.disable)) + } + } + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Button( + onClick = { context.showOperationResultToast(disconnect()) }, + modifier = Modifier.padding(end = 8.dp) + ) { + Text(stringResource(R.string.disconnect)) + } + Button(onClick = { context.showOperationResultToast(reconnect()) }) { + Text(stringResource(R.string.reconnect)) + } + } + if(VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { + FunctionItem(R.string.wifi_mac_address) { macDialog = true } + } + if(VERSION.SDK_INT >= 33 && (privilege.device || privilege.org)) { + FunctionItem(R.string.min_wifi_security_level) { navigate(WifiSecurityLevel) } + FunctionItem(R.string.wifi_ssid_policy) { navigate(WifiSsidPolicyScreen) } } } + if (macDialog && VERSION.SDK_INT >= 24) { + AlertDialog( + title = { Text(stringResource(R.string.wifi_mac_address)) }, + text = { + val mac = getMac() + OutlinedTextField( + value = mac ?: stringResource(R.string.none), onValueChange = {}, + readOnly = true, modifier = Modifier.fillMaxWidth(), textStyle = MaterialTheme.typography.bodyLarge, + trailingIcon = { + if (mac != null) IconButton({ writeClipBoard(context, mac) }) { + Icon(painterResource(R.drawable.content_copy_fill0), null) + } + } + ) + }, + onDismissRequest = { macDialog = false }, + confirmButton = { + TextButton({ macDialog = false }) { Text(stringResource(R.string.confirm)) } + } + ) + } +} + +@Serializable +data class WifiInfo( + val id: Int, val ssid: String, val hiddenSsid: Boolean?, val bssid: String, + val macRandomization: WifiMacRandomization?, val status: WifiStatus, + val security: WifiSecurity?, val password: String, val ipMode: IpMode?, val ipConf: IpConf?, + val proxyMode: ProxyMode?, val proxyConf: ProxyConf? +) + +@Keep +@Suppress("InlinedApi", "DEPRECATION") +enum class WifiMacRandomization(val id: Int, val text: Int) { + None(WifiConfiguration.RANDOMIZATION_NONE, R.string.none), + Persistent(WifiConfiguration.RANDOMIZATION_PERSISTENT, R.string.persistent), + NonPersistent(WifiConfiguration.RANDOMIZATION_NON_PERSISTENT, R.string.non_persistent), + Auto(WifiConfiguration.RANDOMIZATION_AUTO, R.string.auto) +} + +@Keep +@Suppress("InlinedApi", "DEPRECATION") +enum class WifiSecurity(val id: Int, val text: Int) { + Open(WifiConfiguration.SECURITY_TYPE_OPEN, R.string.wifi_security_open), + Psk(WifiConfiguration.SECURITY_TYPE_PSK, R.string.wifi_security_psk) +} + +@Keep +@Suppress("DEPRECATION") +enum class WifiStatus(val id: Int, val text: Int) { + Current(WifiConfiguration.Status.CURRENT, R.string.current), + Enabled(WifiConfiguration.Status.ENABLED, R.string.enabled), + Disabled(WifiConfiguration.Status.DISABLED, R.string.disabled) +} + +@Serializable +data class IpConf(val address: String, val gateway: String, val dns: List) + +@Serializable +data class ProxyConf(val host: String, val port: Int, val exclude: List) + +@Keep +enum class IpMode(val text: Int) { + Dhcp(R.string.wifi_mode_dhcp), Static(R.string.static_str) +} +@Keep +enum class ProxyMode(val text: Int) { + None(R.string.none), Http(R.string.http) } @Suppress("DEPRECATION") @OptIn(ExperimentalPermissionsApi::class) @Composable -private fun SavedNetworks(onNavigateToUpdateNetwork: (Bundle) -> Unit) { +private fun SavedNetworks( + configuredNetworks: StateFlow>, getConfiguredNetworks: () -> Unit, + enableNetwork: (Int) -> Boolean, disableNetwork: (Int) -> Boolean, + removeNetwork: (Int) -> Boolean, editNetwork: (Int) -> Unit +) { val context = LocalContext.current - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val configuredNetworks = remember { mutableStateListOf() } - var networkDetailsDialog by remember { mutableIntStateOf(-1) } // -1:Hidden, 0+:Index of configuredNetworks - val coroutine = rememberCoroutineScope() - fun refresh() { - configuredNetworks.clear() - coroutine.launch(Dispatchers.IO) { - val list = wm.configuredNetworks.distinctBy { it.networkId } - withContext(Dispatchers.Main) { configuredNetworks.addAll(list) } - } + var dialog by rememberSaveable { mutableIntStateOf(-1) } + val list by configuredNetworks.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + getConfiguredNetworks() } - LaunchedEffect(Unit) { refresh() } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 8.dp, end = 8.dp, bottom = 60.dp) - ) { - val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) - val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - if(it) refresh() - } - if(!locationPermission.status.isGranted) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier + val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) getConfiguredNetworks() + } + LazyColumn { + item { + if (!locationPermission.status.isGranted) Row( + Modifier + .padding(10.dp) .fillMaxWidth() .padding(12.dp) .clip(RoundedCornerShape(15)) .background(MaterialTheme.colorScheme.primaryContainer) - .clickable { requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } + .clickable { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }, + Arrangement.SpaceBetween, Alignment.CenterVertically ) { Icon( - imageVector = Icons.Outlined.LocationOn, contentDescription = null, + Icons.Outlined.LocationOn, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.padding(start = 8.dp, end = 4.dp)) Text( @@ -378,253 +420,305 @@ private fun SavedNetworks(onNavigateToUpdateNetwork: (Bundle) -> Unit) { ) } } - configuredNetworks.forEachIndexed { index, network -> + itemsIndexed(list) { index, network -> Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(start = 8.dp, top = 8.dp) + modifier = Modifier.fillMaxWidth().padding(12.dp, 4.dp) ) { - Text(text = network.SSID.removeSurrounding("\""), style = typography.titleLarge) - IconButton(onClick = { networkDetailsDialog = index }) { - Icon(painter = painterResource(R.drawable.more_horiz_fill0), contentDescription = null) + Text(network.ssid) + IconButton({ dialog = index }) { + Icon(painterResource(R.drawable.more_horiz_fill0), null) } } } } - if(networkDetailsDialog != -1) AlertDialog( + if (dialog != -1) AlertDialog( text = { - val network = configuredNetworks[networkDetailsDialog] - val statusText = when(network.status) { - WifiConfiguration.Status.CURRENT -> R.string.current - WifiConfiguration.Status.DISABLED -> R.string.disabled - WifiConfiguration.Status.ENABLED -> R.string.enabled - else -> R.string.place_holder - } + val network = list[dialog] Column { - Text(stringResource(R.string.network_id) + ": " + network.networkId.toString()) + Text(stringResource(R.string.network_id) + ": " + network.id.toString()) + Spacer(Modifier.height(4.dp)) + Text("SSID", style = MaterialTheme.typography.titleMedium) SelectionContainer { - Text("SSID: " + network.SSID) - if(network.BSSID != null) Text("BSSID: " + network.BSSID) + Text(network.ssid) + } + Spacer(Modifier.height(4.dp)) + if (network.bssid.isNotEmpty()) { + Text("BSSID", style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text(network.bssid) + } + Spacer(Modifier.height(4.dp)) + } + Text(stringResource(R.string.status), style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text(stringResource(network.status.text)) } - Text(stringResource(R.string.status) + ": " + stringResource(statusText)) Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp) + Modifier.fillMaxWidth().padding(top = 8.dp), Arrangement.SpaceBetween ) { - Button( - onClick = { - context.showOperationResultToast(wm.enableNetwork(network.networkId, false)) - networkDetailsDialog = -1 - refresh() - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.enable)) + FilledTonalButton({ + val result = if (network.status == WifiStatus.Disabled) { + enableNetwork(network.id) + } else { + disableNetwork(network.id) + } + context.showOperationResultToast(result) + dialog = -1 + getConfiguredNetworks() + }) { + if (network.status == WifiStatus.Disabled) { + Text(stringResource(R.string.enable)) + } else { + Text(stringResource(R.string.disable)) + } } - Button( - onClick = { - context.showOperationResultToast(wm.disableNetwork(network.networkId)) - networkDetailsDialog = -1 - refresh() - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.disable)) + Row { + FilledTonalIconButton({ + editNetwork(dialog) + dialog = -1 + }) { + Icon(Icons.Outlined.Edit, stringResource(R.string.edit)) + } + FilledTonalIconButton({ + val result = removeNetwork(network.id) + context.showOperationResultToast(result) + if (result) { + dialog = -1 + getConfiguredNetworks() + } + }) { + Icon(Icons.Outlined.Delete, stringResource(R.string.delete)) + } } } - Button( - onClick = { - networkDetailsDialog = -1 - onNavigateToUpdateNetwork(bundleOf("wifi_configuration" to network)) - }, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Edit, null) - Text(stringResource(R.string.edit)) - } - TextButton( - onClick = { - context.showOperationResultToast(wm.removeNetwork(network.networkId)) - networkDetailsDialog = -1 - refresh() - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Outlined.Delete, null) - Text(stringResource(R.string.remove)) - } } }, confirmButton = { - TextButton(onClick = { networkDetailsDialog = -1 }) { + TextButton({ dialog = -1 }) { Text(stringResource(R.string.confirm)) } }, - onDismissRequest = { networkDetailsDialog = -1 } + onDismissRequest = { dialog = -1 } ) } @Serializable -object AddNetwork +data class UpdateNetwork(val index: Int) +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AddNetworkScreen(data: Bundle, onNavigateUp: () -> Unit) { - MySmallTitleScaffold(R.string.update_network, onNavigateUp, 0.dp) { - AddNetworkScreen(data.getParcelable("wifi_configuration"), onNavigateUp) +fun UpdateNetworkScreen(info: WifiInfo, setNetwork: (WifiInfo) -> Boolean, onNavigateUp: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.update_network)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + modifier = Modifier.fillMaxSize().padding(paddingValues) + ) { + AddNetworkScreen(info, setNetwork, onNavigateUp) + } } } -@Suppress("DEPRECATION") +@Composable +fun UnchangedMenuItem(onClick: () -> Unit) { + DropdownMenuItem({ Text(stringResource(R.string.unchanged)) }, onClick) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AddNetworkScreen(wifiConfig: WifiConfiguration? = null, onNavigateUp: () -> Unit) { +private fun AddNetworkScreen( + wifiInfo: WifiInfo?, setNetwork: (WifiInfo) -> Boolean, onNavigateUp: () -> Unit +) { + val updating = wifiInfo != null val context = LocalContext.current val fm = LocalFocusManager.current - var resultDialog by remember { mutableStateOf(false) } - var createdNetworkId by remember { mutableIntStateOf(-1) } - var createNetworkResult by remember { mutableIntStateOf(0) } - var dropdownMenu by remember { mutableIntStateOf(0) } // 0: None, 1:Status, 2:Security, 3:MAC randomization, 4:Static IP, 5:Proxy - var status by remember { mutableIntStateOf(WifiConfiguration.Status.ENABLED) } - var ssid by remember { mutableStateOf("") } - var hiddenSsid by remember { mutableStateOf(false) } - var securityType by remember { mutableIntStateOf(WifiConfiguration.SECURITY_TYPE_OPEN) } - var password by remember { mutableStateOf("") } - var macRandomizationSetting by remember { mutableIntStateOf(WifiConfiguration.RANDOMIZATION_AUTO) } - var useStaticIp by remember { mutableStateOf(false) } - var ipAddress by remember { mutableStateOf("") } - var gatewayAddress by remember { mutableStateOf("") } - var dnsServers by remember { mutableStateOf("") } - var useHttpProxy by remember { mutableStateOf(false) } - var httpProxyHost by remember { mutableStateOf("") } - var httpProxyPort by remember { mutableStateOf("") } - var httpProxyExclList by remember { mutableStateOf("") } + /** 0: None, 1:Status, 2:Security, 3:MAC randomization, 4:Static IP, 5:Proxy, 6:Hidden SSID */ + var menu by rememberSaveable { mutableIntStateOf(0) } + var status by rememberSaveable { mutableStateOf(WifiStatus.Enabled) } + var ssid by rememberSaveable { mutableStateOf("") } + var hiddenSsid by rememberSaveable { mutableStateOf(false) } + var security by rememberSaveable { mutableStateOf(WifiSecurity.Open) } + var password by rememberSaveable { mutableStateOf("") } + var macRandomization by rememberSaveable { mutableStateOf(WifiMacRandomization.None) } + var ipMode by rememberSaveable { mutableStateOf(IpMode.Dhcp) } + var ipAddress by rememberSaveable { mutableStateOf("") } + var gatewayAddress by rememberSaveable { mutableStateOf("") } + var dnsServers by rememberSaveable { mutableStateOf("") } + var proxyMode by rememberSaveable { mutableStateOf(ProxyMode.None) } + var httpProxyHost by rememberSaveable { mutableStateOf("") } + var httpProxyPort by rememberSaveable { mutableStateOf("") } + var httpProxyExclList by rememberSaveable { mutableStateOf("") } LaunchedEffect(Unit) { - if(wifiConfig != null) { - status = wifiConfig.status - if(wifiConfig.status == WifiConfiguration.Status.CURRENT) status = WifiConfiguration.Status.ENABLED - ssid = wifiConfig.SSID.removeSurrounding("\"") + if (updating) { + hiddenSsid = null + security = null + macRandomization = null + ipMode = null + proxyMode = null + status = wifiInfo.status + ssid = wifiInfo.ssid } } - var errorMessage by remember { mutableStateOf(null) } Column( - modifier = (if(wifiConfig == null) Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 60.dp) else Modifier) - .padding(start = 8.dp, end = 8.dp, top = 12.dp) + Modifier.verticalScroll(rememberScrollState()).padding(horizontal = HorizontalPadding) ) { - ExposedDropdownMenuBox(dropdownMenu == 1, { dropdownMenu = if(it) 1 else 0 }) { - val statusText = when(status) { - WifiConfiguration.Status.DISABLED -> R.string.disabled - WifiConfiguration.Status.ENABLED -> R.string.enabled - else -> R.string.place_holder - } + Spacer(Modifier.height(4.dp)) + ExposedDropdownMenuBox( + menu == 1, { menu = if(it) 1 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = stringResource(statusText), onValueChange = {}, readOnly = true, + stringResource(status.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.status)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 1) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 16.dp) + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 1) }, ) - ExposedDropdownMenu(dropdownMenu == 1, { dropdownMenu = 0 }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.disabled)) }, - onClick = { - status = WifiConfiguration.Status.DISABLED - dropdownMenu = 0 - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.enabled)) }, - onClick = { - status = WifiConfiguration.Status.ENABLED - dropdownMenu = 0 - } - ) + ExposedDropdownMenu(menu == 1, { menu = 0 }) { + WifiStatus.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + status = it + menu = 0 + } + ) + } } } OutlinedTextField( - value = ssid, onValueChange = { ssid = it }, label = { Text("SSID") }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + ssid, { ssid = it }, Modifier.fillMaxWidth().padding(bottom = 8.dp), + label = { Text("SSID") } ) - CheckBoxItem(R.string.hidden_ssid, hiddenSsid) { hiddenSsid = it } - if(VERSION.SDK_INT >= 30) { - // TODO: more protocols - val securityTypeTextMap = mutableMapOf( - WifiConfiguration.SECURITY_TYPE_OPEN to stringResource(R.string.wifi_security_open), - WifiConfiguration.SECURITY_TYPE_PSK to "PSK" + ExposedDropdownMenuBox( + menu == 6, { menu = if (it) 6 else 0 }, Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(hiddenSsid?.yesOrNo ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.hidden_ssid)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 1) } ) - ExposedDropdownMenuBox(dropdownMenu == 2, { dropdownMenu = if(it) 2 else 0 }) { - OutlinedTextField( - value = securityTypeTextMap[securityType] ?: "", onValueChange = {}, label = { Text(stringResource(R.string.security)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 1) }, readOnly = true, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(vertical = 4.dp) - ) - ExposedDropdownMenu(dropdownMenu == 2, { dropdownMenu = 0 }) { - securityTypeTextMap.forEach { - DropdownMenuItem(text = { Text(it.value) }, onClick = { securityType = it.key; dropdownMenu = 0 }) + DropdownMenu(menu == 6, { menu = 0 }) { + if (updating) DropdownMenuItem( + { Text(stringResource(R.string.unchanged)) }, + { + hiddenSsid = null + menu = 0 + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.yes)) }, + { + hiddenSsid = true + menu = 0 + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.no)) }, + { + hiddenSsid = false + menu = 0 } - } - } - AnimatedVisibility(securityType == WifiConfiguration.SECURITY_TYPE_PSK) { - OutlinedTextField( - value = password, onValueChange = { password = it }, label = { Text(stringResource(R.string.password)) }, - modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) ) } } - if(VERSION.SDK_INT >= 33) { - val macRandomizationSettingTextMap = mapOf( - WifiConfiguration.RANDOMIZATION_NONE to R.string.none, - WifiConfiguration.RANDOMIZATION_PERSISTENT to R.string.persistent, - WifiConfiguration.RANDOMIZATION_NON_PERSISTENT to R.string.non_persistent, - WifiConfiguration.RANDOMIZATION_AUTO to R.string.auto + ExposedDropdownMenuBox( + menu == 2, { menu = if(it) 2 else 0 }, Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(security?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.security)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 1) } ) - ExposedDropdownMenuBox(dropdownMenu == 3, { dropdownMenu = if(it) 3 else 0 }) { + ExposedDropdownMenu(menu == 2, { menu = 0 }) { + if (updating) UnchangedMenuItem { security = null } + WifiSecurity.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + security = it + menu = 0 + } + ) + } + } + } + AnimatedVisibility(security == WifiSecurity.Psk) { + OutlinedTextField( + password, { password = it }, Modifier.fillMaxWidth().padding(bottom = 8.dp), + label = { Text(stringResource(R.string.password)) } + ) + } + if (VERSION.SDK_INT >= 33) { + ExposedDropdownMenuBox( + menu == 3, { menu = if(it) 3 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = stringResource(macRandomizationSettingTextMap[macRandomizationSetting] ?: R.string.place_holder), - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.mac_randomization)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 3) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 8.dp) + stringResource(macRandomization?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.mac_randomization)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 3) }, ) - ExposedDropdownMenu(dropdownMenu == 3, { dropdownMenu = 0 }) { - macRandomizationSettingTextMap.forEach { + ExposedDropdownMenu(menu == 3, { menu = 0 }) { + if (updating) UnchangedMenuItem { macRandomization = null } + WifiMacRandomization.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.value)) }, - onClick = { - macRandomizationSetting = it.key - dropdownMenu = 0 + { Text(stringResource(it.text)) }, + { + macRandomization = it + menu = 0 } ) } } } } - if(VERSION.SDK_INT >= 33) { - ExposedDropdownMenuBox(dropdownMenu == 4, { dropdownMenu = if(it) 4 else 0 }) { + if (VERSION.SDK_INT >= 33) { + ExposedDropdownMenuBox( + menu == 4, { menu = if(it) 4 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = if(useStaticIp) stringResource(R.string.static_str) else "DHCP", - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.ip_settings)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 4) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(ipMode?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.ip_settings)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 4) }, ) - ExposedDropdownMenu(dropdownMenu == 4, { dropdownMenu = 0 }) { - DropdownMenuItem(text = { Text("DHCP") }, onClick = { useStaticIp = false; dropdownMenu = 0 }) - DropdownMenuItem(text = { Text(stringResource(R.string.static_str)) }, onClick = { useStaticIp = true; dropdownMenu = 0 }) + ExposedDropdownMenu(menu == 4, { menu = 0 }) { + if (updating) UnchangedMenuItem { ipMode = null } + IpMode.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + ipMode = it + menu = 0 + } + ) + } } } - AnimatedVisibility(visible = useStaticIp, modifier = Modifier.padding(bottom = 8.dp)) { + AnimatedVisibility(ipMode == IpMode.Static) { val gatewayFr = FocusRequester() val dnsFr = FocusRequester() Column { OutlinedTextField( value = ipAddress, onValueChange = { ipAddress = it }, - placeholder = { Text("192.168.1.2/24") }, label = { Text(stringResource(R.string.ip_address)) }, + label = { Text(stringResource(R.string.ip_address)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { gatewayFr.requestFocus() }, + //keyboardActions = KeyboardActions { gatewayFr.requestFocus() }, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) ) OutlinedTextField( value = gatewayAddress, onValueChange = { gatewayAddress = it }, - placeholder = { Text("192.168.1.1") }, label = { Text(stringResource(R.string.gateway_address)) }, + label = { Text(stringResource(R.string.gateway_address)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions { dnsFr.requestFocus() }, modifier = Modifier.focusRequester(gatewayFr).fillMaxWidth().padding(bottom = 4.dp) @@ -640,138 +734,105 @@ private fun AddNetworkScreen(wifiConfig: WifiConfiguration? = null, onNavigateUp } } if(VERSION.SDK_INT >= 26) { - ExposedDropdownMenuBox(dropdownMenu == 5, { dropdownMenu = if(it) 5 else 0 }) { + ExposedDropdownMenuBox( + menu == 5, { menu = if(it) 5 else 0 }, Modifier.padding(bottom = 8.dp) + ) { OutlinedTextField( - value = if(useHttpProxy) "HTTP" else stringResource(R.string.none), - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.proxy)) }, - trailingIcon = { ExpandExposedTextFieldIcon(dropdownMenu == 5) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(proxyMode?.text ?: R.string.unchanged), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.proxy)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 5) }, ) - ExposedDropdownMenu(dropdownMenu == 5, { dropdownMenu = 0 }) { - DropdownMenuItem(text = { Text(stringResource(R.string.none)) }, onClick = { useHttpProxy = false; dropdownMenu = 0 }) - DropdownMenuItem(text = { Text("HTTP") }, onClick = { useHttpProxy = true; dropdownMenu = 0 }) + ExposedDropdownMenu(menu == 5, { menu = 0 }) { + if (updating) UnchangedMenuItem { proxyMode = null } + ProxyMode.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + proxyMode = it + menu = 0 + } + ) + } } } - AnimatedVisibility(visible = useHttpProxy, modifier = Modifier.padding(bottom = 8.dp)) { + AnimatedVisibility(proxyMode == ProxyMode.Http) { val portFr = FocusRequester() val exclListFr = FocusRequester() Column { OutlinedTextField( - value = httpProxyHost, onValueChange = { httpProxyHost = it }, label = { Text(stringResource(R.string.host)) }, + httpProxyHost, { httpProxyHost = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.host)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { portFr.requestFocus() }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + keyboardActions = KeyboardActions { portFr.requestFocus() } ) OutlinedTextField( - value = httpProxyPort, onValueChange = { httpProxyPort = it }, label = { Text(stringResource(R.string.port)) }, + httpProxyPort, { httpProxyPort = it }, + Modifier.focusRequester(portFr).fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.port)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), - keyboardActions = KeyboardActions { exclListFr.requestFocus() }, - modifier = Modifier.focusRequester(portFr).fillMaxWidth().padding(bottom = 4.dp) + keyboardActions = KeyboardActions { exclListFr.requestFocus() } ) OutlinedTextField( - value = httpProxyExclList, onValueChange = { httpProxyExclList = it }, label = { Text(stringResource(R.string.excluded_hosts)) }, - minLines = 2, placeholder = { Text("example.com\n*.example.com") }, + httpProxyExclList, { httpProxyExclList = it }, + Modifier.focusRequester(exclListFr).fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.excluded_hosts)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions { fm.clearFocus() }, - modifier = Modifier.focusRequester(exclListFr).fillMaxWidth().padding(bottom = 4.dp) + minLines = 2 ) } } } Button( onClick = { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - try { - val config = WifiConfiguration() - config.status = status - config.SSID = '"' + ssid + '"' - config.hiddenSSID = hiddenSsid - if(VERSION.SDK_INT >= 30) config.setSecurityParams(securityType) - if(securityType == WifiConfiguration.SECURITY_TYPE_PSK) config.preSharedKey = '"' + password + '"' - if(VERSION.SDK_INT >= 33) config.macRandomizationSetting = macRandomizationSetting - if(VERSION.SDK_INT >= 33 && useStaticIp) { - val ipConf = IpConfiguration.Builder() - val staticIpConf = StaticIpConfiguration.Builder() - val la: LinkAddress - val con = LinkAddress::class.constructors.find { it.parameters.size == 1 && it.parameters[0].type.jvmErasure == String::class } - la = con!!.call(ipAddress) - staticIpConf.setIpAddress(la) - staticIpConf.setGateway(InetAddress.getByName(gatewayAddress)) - staticIpConf.setDnsServers(dnsServers.lines().map { InetAddress.getByName(it) }) - ipConf.setStaticIpConfiguration(staticIpConf.build()) - config.setIpConfiguration(ipConf.build()) - } - if(VERSION.SDK_INT >= 26 && useHttpProxy) { - config.httpProxy = ProxyInfo.buildDirectProxy(httpProxyHost, httpProxyPort.toInt(), httpProxyExclList.lines()) - } - if(wifiConfig != null) { - config.networkId = wifiConfig.networkId - createdNetworkId = wm.updateNetwork(config) - } else { - if(VERSION.SDK_INT >= 31) { - val result = wm.addNetworkPrivileged(config) - createdNetworkId = result.networkId - createNetworkResult = result.statusCode - } else { - createdNetworkId = wm.addNetwork(config) - } - } - resultDialog = true - } catch(e: Exception) { - e.printStackTrace() - errorMessage = e.message - } + val proxyConf = if (proxyMode == ProxyMode.Http) { + ProxyConf( + httpProxyHost, httpProxyPort.toInt(), + httpProxyExclList.lines().filter { it.isNotBlank() } + ) + } else null + val ipConf = if (ipMode == IpMode.Static) { + IpConf(ipAddress, gatewayAddress, dnsServers.lines().filter { it.isNotBlank() }) + } else null + val result = setNetwork(WifiInfo( + -1, ssid, hiddenSsid, "", macRandomization, status, security, password, ipMode, + ipConf, proxyMode, proxyConf + )) + context.showOperationResultToast(result) + if (result) onNavigateUp() }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + enabled = (proxyMode != ProxyMode.Http || + (httpProxyPort.toIntOrNull() != null && httpProxyHost.isNotBlank())) ) { - Text(stringResource(if(wifiConfig != null) R.string.update else R.string.add)) + Text(stringResource(if (updating) R.string.update else R.string.add)) } - if(resultDialog) AlertDialog( - text = { - val statusText = when(createNetworkResult) { - WifiManager.AddNetworkResult.STATUS_SUCCESS -> R.string.success - //WifiManager.AddNetworkResult.STATUS_ADD_WIFI_CONFIG_FAILURE -> R.string.failed - WifiManager.AddNetworkResult.STATUS_INVALID_CONFIGURATION -> R.string.add_network_result_invalid_configuration - else -> R.string.failed - } - Text(stringResource(statusText) + "\n" + stringResource(R.string.network_id) + ": " + createdNetworkId) - }, - confirmButton = { - TextButton( - onClick = { - resultDialog = false - if(createdNetworkId != -1) onNavigateUp() - } - ) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { resultDialog = false } - ) + Spacer(Modifier.height(BottomPadding)) } - ErrorDialog(errorMessage) { errorMessage = null } } @Serializable object WifiSecurityLevel @RequiresApi(33) @Composable -fun WifiSecurityLevelScreen(onNavigateUp: () -> Unit) { +fun WifiSecurityLevelScreen( + getLevel: () -> Int, setLevel: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var selectedWifiSecLevel by remember { mutableIntStateOf(0) } - LaunchedEffect(Unit) { selectedWifiSecLevel = Privilege.DPM.minimumRequiredWifiSecurityLevel } + var level by rememberSaveable { mutableIntStateOf(getLevel()) } MyScaffold(R.string.min_wifi_security_level, onNavigateUp, 0.dp) { - FullWidthRadioButtonItem(R.string.wifi_security_open, selectedWifiSecLevel == WIFI_SECURITY_OPEN) { selectedWifiSecLevel = WIFI_SECURITY_OPEN } - FullWidthRadioButtonItem("WEP, WPA(2)-PSK", selectedWifiSecLevel == WIFI_SECURITY_PERSONAL) { selectedWifiSecLevel = WIFI_SECURITY_PERSONAL } - FullWidthRadioButtonItem("WPA-EAP", selectedWifiSecLevel == WIFI_SECURITY_ENTERPRISE_EAP) { selectedWifiSecLevel = WIFI_SECURITY_ENTERPRISE_EAP } - FullWidthRadioButtonItem("WPA3-192bit", selectedWifiSecLevel == WIFI_SECURITY_ENTERPRISE_192) { selectedWifiSecLevel = WIFI_SECURITY_ENTERPRISE_192 } + FullWidthRadioButtonItem(R.string.wifi_security_open, level == WIFI_SECURITY_OPEN) { level = WIFI_SECURITY_OPEN } + FullWidthRadioButtonItem("WEP, WPA(2)-PSK", level == WIFI_SECURITY_PERSONAL) { level = WIFI_SECURITY_PERSONAL } + FullWidthRadioButtonItem("WPA-EAP", level == WIFI_SECURITY_ENTERPRISE_EAP) { level = WIFI_SECURITY_ENTERPRISE_EAP } + FullWidthRadioButtonItem("WPA3-192bit", level == WIFI_SECURITY_ENTERPRISE_192) { level = WIFI_SECURITY_ENTERPRISE_192 } Button( onClick = { - Privilege.DPM.minimumRequiredWifiSecurityLevel = selectedWifiSecLevel + setLevel(level) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) ) { Text(stringResource(R.string.apply)) } @@ -779,312 +840,281 @@ fun WifiSecurityLevelScreen(onNavigateUp: () -> Unit) { } } +data class SsidPolicy(val type: SsidPolicyType, val list: List) + +@Suppress("InlinedApi") +enum class SsidPolicyType(val id: Int, val text: Int) { + None(-1, R.string.none), + Whitelist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_ALLOWLIST, R.string.whitelist), + Blacklist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_DENYLIST, R.string.blacklist) +} + @Serializable object WifiSsidPolicyScreen @RequiresApi(33) @Composable -fun WifiSsidPolicyScreen(onNavigateUp: () -> Unit) { +fun WifiSsidPolicyScreen( + getPolicy: () -> SsidPolicy, setPolicy: (SsidPolicy) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current MyScaffold(R.string.wifi_ssid_policy, onNavigateUp, 0.dp) { - var selectedPolicyType by remember { mutableIntStateOf(-1) } - val ssidList = remember { mutableStateListOf() } - fun refreshPolicy() { - val policy = Privilege.DPM.wifiSsidPolicy - ssidList.clear() - selectedPolicyType = policy?.policyType ?: -1 - ssidList.addAll(policy?.ssids ?: mutableSetOf()) + var type by rememberSaveable { mutableStateOf(SsidPolicyType.None) } + val list = rememberSaveable { mutableStateListOf() } + LaunchedEffect(Unit) { + getPolicy().let { + type = it.type + list.addAll(it.list) + } } - LaunchedEffect(Unit) { refreshPolicy() } - FullWidthRadioButtonItem(R.string.none, selectedPolicyType == -1) { selectedPolicyType = -1 } - FullWidthRadioButtonItem(R.string.whitelist, selectedPolicyType == WIFI_SSID_POLICY_TYPE_ALLOWLIST) { - selectedPolicyType = WIFI_SSID_POLICY_TYPE_ALLOWLIST + SsidPolicyType.entries.forEach { + FullWidthRadioButtonItem(it.text, type == it) { type = it } } - FullWidthRadioButtonItem(R.string.blacklist, selectedPolicyType == WIFI_SSID_POLICY_TYPE_DENYLIST) { - selectedPolicyType = WIFI_SSID_POLICY_TYPE_DENYLIST - } - AnimatedVisibility(selectedPolicyType != -1) { + AnimatedVisibility(type != SsidPolicyType.None) { var inputSsid by remember { mutableStateOf("") } Column(Modifier.padding(horizontal = HorizontalPadding)) { Column(modifier = Modifier.animateContentSize()) { - for(i in ssidList) { - ListItem(i.bytes.decodeToString()) { ssidList -= i } + for(i in list) { + ListItem(i) { list -= i } } } Spacer(Modifier.padding(vertical = 5.dp)) OutlinedTextField( - value = inputSsid, + inputSsid, { inputSsid = it }, Modifier.fillMaxWidth(), label = { Text("SSID") }, - onValueChange = { inputSsid = it }, trailingIcon = { IconButton( onClick = { - ssidList += WifiSsid.fromBytes(inputSsid.encodeToByteArray()) + list += inputSsid inputSsid = "" }, - enabled = inputSsid != "" + enabled = inputSsid.isNotEmpty() ) { - Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add)) + Icon(Icons.Default.Add, stringResource(R.string.add)) } }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) } } Button( onClick = { focusMgr.clearFocus() - Privilege.DPM.wifiSsidPolicy = if(selectedPolicyType == -1 || ssidList.isEmpty()) { - null - } else { - WifiSsidPolicy(selectedPolicyType, ssidList.toSet()) - } - refreshPolicy() + setPolicy(SsidPolicy(type, list)) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + enabled = type == SsidPolicyType.None || list.isNotEmpty() ) { Text(stringResource(R.string.apply)) } } } -private enum class NetworkStatsActiveTextField { None, Type, Target, NetworkType, SubscriberId, StartTime, EndTime, Uid, Tag, State } +private enum class NetworkStatsMenu { + None, Type, Target, NetworkType, StartTime, EndTime, Uid, Tag, State +} +enum class NetworkStatsType(val text: Int) { Summary(R.string.summary), Details(R.string.details) } @Suppress("DEPRECATION") -private enum class NetworkType(val type: Int, @StringRes val strRes: Int) { +enum class NetworkType(val type: Int, val text: Int) { Mobile(ConnectivityManager.TYPE_MOBILE, R.string.mobile), Wifi(ConnectivityManager.TYPE_WIFI, R.string.wifi), Bluetooth(ConnectivityManager.TYPE_BLUETOOTH, R.string.bluetooth), Ethernet(ConnectivityManager.TYPE_ETHERNET, R.string.ethernet), Vpn(ConnectivityManager.TYPE_VPN, R.string.vpn), } -private enum class NetworkStatsTarget(@StringRes val strRes: Int, val minApi: Int) { - Device(R.string.device, 23), User(R.string.user, 23), - Uid(R.string.uid, 23), UidTag(R.string.uid_tag, 24), UidTagState(R.string.uid_tag_state, 28) +enum class NetworkStatsTarget(val text: Int, val type: NetworkStatsType, val minApi: Int = 23) { + Device(R.string.device, NetworkStatsType.Summary), + User(R.string.user, NetworkStatsType.Summary), + Uid(R.string.uid, NetworkStatsType.Details), + UidTag(R.string.uid_tag, NetworkStatsType.Details, 24), + UidTagState(R.string.uid_tag_state, NetworkStatsType.Details, 28) +} +@Suppress("InlinedApi") +enum class NetworkStatsState(val id: Int, val text: Int) { + All(NetworkStats.Bucket.STATE_ALL, R.string.all), + Default(NetworkStats.Bucket.STATE_DEFAULT, R.string.default_str), + Foreground(NetworkStats.Bucket.STATE_FOREGROUND, R.string.foreground) } @RequiresApi(23) -private enum class NetworkStatsUID(val uid: Int, @StringRes val strRes: Int) { +enum class NetworkStatsUID(val uid: Int, val text: Int) { All(NetworkStats.Bucket.UID_ALL, R.string.all), Removed(NetworkStats.Bucket.UID_REMOVED, R.string.uninstalled), Tethering(NetworkStats.Bucket.UID_TETHERING, R.string.tethering) } -@RequiresApi(23) -fun NetworkStats.toBucketList(): List { - val list = mutableListOf() - while(hasNextBucket()) { - val bucket = NetworkStats.Bucket() - if(getNextBucket(bucket)) list += bucket - } - close() - return list -} + +data class QueryNetworkStatsParams( + val type: NetworkStatsType, val target: NetworkStatsTarget, val networkType: NetworkType, + val startTime: Long, val endTime: Long, val uid: Int, val tag: Int, val state: NetworkStatsState +) @Serializable object QueryNetworkStats @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(23) @Composable -fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkStatsViewer) -> Unit) { +fun NetworkStatsScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, getUid: (String) -> Int, + queryStats: (QueryNetworkStatsParams, (String?) -> Unit) -> Unit, onNavigateUp: () -> Unit, + onNavigateToViewer: () -> Unit +) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - val fm = LocalFocusManager.current - val nsm = context.getSystemService(NetworkStatsManager::class.java) - val coroutine = rememberCoroutineScope() - var activeTextField by remember { mutableStateOf(NetworkStatsActiveTextField.None) } //0:None, 1:Network type, 2:Start time, 3:End time - var queryType by rememberSaveable { mutableIntStateOf(1) } //1:Summary, 2:Details - var target by rememberSaveable { mutableStateOf(NetworkStatsTarget.Device) } + fun getDefaultSummaryTarget(): NetworkStatsTarget { + return if (privilege.device) NetworkStatsTarget.Device else NetworkStatsTarget.User + } + var menu by remember { mutableStateOf(NetworkStatsMenu.None) } + var type by rememberSaveable { mutableStateOf(NetworkStatsType.Summary) } + var target by rememberSaveable { mutableStateOf(getDefaultSummaryTarget()) } var networkType by rememberSaveable { mutableStateOf(NetworkType.Mobile) } - var subscriberId by rememberSaveable { mutableStateOf(null) } var startTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis() - 7*24*60*60*1000) } var endTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) } var uid by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.UID_ALL) } var tag by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.TAG_NONE) } - var state by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.STATE_ALL) } - val startTimeTextFieldInteractionSource = remember { MutableInteractionSource() } - val endTimeTextFieldInteractionSource = remember { MutableInteractionSource() } - if(startTimeTextFieldInteractionSource.collectIsPressedAsState().value) activeTextField = NetworkStatsActiveTextField.StartTime - if(endTimeTextFieldInteractionSource.collectIsPressedAsState().value) activeTextField = NetworkStatsActiveTextField.EndTime - var errorMessage by remember { mutableStateOf(null) } + var state by rememberSaveable { mutableStateOf(NetworkStatsState.All) } + var errorMessage by rememberSaveable { mutableStateOf(null) } MyScaffold(R.string.network_stats, onNavigateUp) { ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Type, - { activeTextField = if(it) NetworkStatsActiveTextField.Type else NetworkStatsActiveTextField.Type }, + menu == NetworkStatsMenu.Type, + { menu = if (it) NetworkStatsMenu.Type else NetworkStatsMenu.None }, Modifier.padding(top = 8.dp, bottom = 4.dp) ) { - val typeTextMap = mapOf( - 1 to R.string.summary, - 2 to R.string.details - ) OutlinedTextField( - value = stringResource(typeTextMap[queryType]!!), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.type)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Type) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth() + stringResource(type.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Type) + } ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Type, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Type, { menu = NetworkStatsMenu.None } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.summary)) }, - onClick = { - queryType = 1 - target = NetworkStatsTarget.Device - activeTextField = NetworkStatsActiveTextField.None - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.details)) }, - onClick = { - queryType = 2 - target = NetworkStatsTarget.Uid - activeTextField = NetworkStatsActiveTextField.None - } - ) - } - } - ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Target, - { activeTextField = if(it) NetworkStatsActiveTextField.Target else NetworkStatsActiveTextField.None } - ) { - OutlinedTextField( - value = stringResource(target.strRes), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.target)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Target) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) - ) - ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Target, { activeTextField = NetworkStatsActiveTextField.None } - ) { - NetworkStatsTarget.entries.forEach { - if( - VERSION.SDK_INT >= it.minApi && - (privilege.device || it != NetworkStatsTarget.Device) && - ((queryType == 1 && (it == NetworkStatsTarget.Device || it == NetworkStatsTarget.User)) || - (queryType == 2 && (it == NetworkStatsTarget.Uid || it == NetworkStatsTarget.UidTag || it == NetworkStatsTarget.UidTagState))) - ) DropdownMenuItem( - text = { Text(stringResource(it.strRes)) }, - onClick = { - target = it - activeTextField = NetworkStatsActiveTextField.None + NetworkStatsType.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + type = it + target = if (it == NetworkStatsType.Summary) getDefaultSummaryTarget() + else NetworkStatsTarget.Uid + menu = NetworkStatsMenu.None } ) } } } ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.NetworkType, - { activeTextField = if(it) NetworkStatsActiveTextField.NetworkType else NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Target, + { menu = if(it) NetworkStatsMenu.Target else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) ) { OutlinedTextField( - value = stringResource(networkType.strRes), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.network_type)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.NetworkType) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(target.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.target)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Target) + } ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.NetworkType, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Target, { menu = NetworkStatsMenu.None } + ) { + NetworkStatsTarget.entries.filter { + VERSION.SDK_INT >= it.minApi && type == it.type + }.forEach { + DropdownMenuItem( + text = { Text(stringResource(it.text)) }, + onClick = { + target = it + menu = NetworkStatsMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.NetworkType, + { menu = if(it) NetworkStatsMenu.NetworkType else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) + ) { + OutlinedTextField( + stringResource(networkType.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.network_type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.NetworkType) + } + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.NetworkType, { menu = NetworkStatsMenu.None } ) { NetworkType.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.strRes)) }, + text = { Text(stringResource(it.text)) }, onClick = { networkType = it - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) } } } - ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.SubscriberId, - { activeTextField = if(it) NetworkStatsActiveTextField.SubscriberId else NetworkStatsActiveTextField.None } - ) { - var readOnly by rememberSaveable { mutableStateOf(true) } - OutlinedTextField( - value = subscriberId ?: "null", onValueChange = { if(!readOnly) subscriberId = it }, readOnly = readOnly, - label = { Text(stringResource(R.string.subscriber_id)) }, - isError = !readOnly && subscriberId.isNullOrBlank(), - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.SubscriberId) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) - ) - ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.SubscriberId, { activeTextField = NetworkStatsActiveTextField.None } - ) { - DropdownMenuItem( - text = { Text("null") }, - onClick = { - readOnly = true - subscriberId = null - activeTextField = NetworkStatsActiveTextField.None - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.input)) }, - onClick = { - readOnly = false - subscriberId = "" - activeTextField = NetworkStatsActiveTextField.None - } - ) - } - } OutlinedTextField( - value = startTime.let { if(it == -1L) "" else it.humanReadableDate }, onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.start_time)) }, - interactionSource = startTimeTextFieldInteractionSource, - isError = startTime >= endTime, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + formatDate(startTime), {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = NetworkStatsMenu.StartTime } + .padding(bottom = 4.dp), + readOnly = true, label = { Text(stringResource(R.string.start_time)) }, + isError = startTime >= endTime ) OutlinedTextField( - value = endTime.humanReadableDate, onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.end_time)) }, - interactionSource = endTimeTextFieldInteractionSource, - isError = startTime >= endTime, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + formatDate(endTime), {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = NetworkStatsMenu.EndTime } + .padding(bottom = 4.dp), + readOnly = true, label = { Text(stringResource(R.string.end_time)) }, + isError = startTime >= endTime ) if(target == NetworkStatsTarget.Uid || target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState) ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Uid, - { activeTextField = if(it) NetworkStatsActiveTextField.Uid else NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Uid, + { menu = if(it) NetworkStatsMenu.Uid else NetworkStatsMenu.None } ) { - var uidText by rememberSaveable { mutableStateOf(context.getString(NetworkStatsUID.All.strRes)) } + var uidText by rememberSaveable { mutableStateOf(context.getString(NetworkStatsUID.All.text)) } var readOnly by rememberSaveable { mutableStateOf(true) } - if(!readOnly && uidText.toIntOrNull() != null) uid = uidText.toInt() - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { - it ?: return@rememberLauncherForActivityResult - if(VERSION.SDK_INT >= 24 && readOnly) { - try { - uid = context.packageManager.getPackageUid(it, 0) - uidText = "$it ($uid)" - } catch(_: NameNotFoundException) { - context.showOperationResultToast(false) - } - } + if (VERSION.SDK_INT >= 24) LaunchedEffect(Unit) { + val pkg = chosenPackage.receive() + uid = getUid(pkg) + uidText = "$uid ($pkg)" } OutlinedTextField( - value = uidText, onValueChange = { if(!readOnly) uidText = it }, readOnly = readOnly, - label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Uid) }, + uidText, + { + uidText = it + it.toIntOrNull()?.let { num -> uid = num } + }, + readOnly = readOnly, label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Uid) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = !readOnly && uidText.toIntOrNull() == null, modifier = Modifier .menuAnchor(if(readOnly) MenuAnchorType.PrimaryNotEditable else MenuAnchorType.PrimaryEditable) .fillMaxWidth().padding(bottom = 4.dp) ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Uid, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Uid, { menu = NetworkStatsMenu.None } ) { NetworkStatsUID.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.strRes)) }, + text = { Text(stringResource(it.text)) }, onClick = { uid = it.uid readOnly = true - uidText = context.getString(it.strRes) - activeTextField = NetworkStatsActiveTextField.None + uidText = context.getString(it.text) + menu = NetworkStatsMenu.None } ) } @@ -1092,8 +1122,8 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta text = { Text(stringResource(R.string.choose_an_app)) }, onClick = { readOnly = true - activeTextField = NetworkStatsActiveTextField.None - choosePackage.launch(null) + menu = NetworkStatsMenu.None + onChoosePackage() } ) DropdownMenuItem( @@ -1101,30 +1131,36 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta onClick = { readOnly = false uidText = "" - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) } } - if(VERSION.SDK_INT >= 24 && (target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState)) + if (VERSION.SDK_INT >= 24 && (target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState)) ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.Tag, - { activeTextField = if(it) NetworkStatsActiveTextField.Tag else NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Tag, + { menu = if(it) NetworkStatsMenu.Tag else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) ) { var tagText by rememberSaveable { mutableStateOf(context.getString(R.string.all)) } var readOnly by rememberSaveable { mutableStateOf(true) } - if(!readOnly && tagText.toIntOrNull() != null) tag = tagText.toInt() OutlinedTextField( - value = tagText, onValueChange = { if(!readOnly) tagText = it }, readOnly = readOnly, - label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.Tag) }, + tagText, + { + tagText = it + it.toIntOrNull()?.let { num -> tag = num } + }, + readOnly = readOnly, label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Tag) + }, isError = !readOnly && tagText.toIntOrNull() == null, modifier = Modifier .menuAnchor(if(readOnly) MenuAnchorType.PrimaryNotEditable else MenuAnchorType.PrimaryEditable) .fillMaxWidth().padding(bottom = 4.dp) ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.Tag, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.Tag, { menu = NetworkStatsMenu.None } ) { DropdownMenuItem( text = { Text(stringResource(R.string.all)) }, @@ -1132,7 +1168,7 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta tag = NetworkStats.Bucket.TAG_NONE tagText = context.getString(R.string.all) readOnly = true - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) DropdownMenuItem( @@ -1140,36 +1176,33 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta onClick = { tagText = "" readOnly = false - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None } ) } } - if(VERSION.SDK_INT >= 28 && target == NetworkStatsTarget.UidTagState) - ExposedDropdownMenuBox( - activeTextField == NetworkStatsActiveTextField.State, - { activeTextField = if(it) NetworkStatsActiveTextField.State else NetworkStatsActiveTextField.None } + if (VERSION.SDK_INT >= 28 && target == NetworkStatsTarget.UidTagState) ExposedDropdownMenuBox( + menu == NetworkStatsMenu.State, + { menu = if(it) NetworkStatsMenu.State else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) ) { - val textMap = mapOf( - NetworkStats.Bucket.STATE_ALL to R.string.all, - NetworkStats.Bucket.STATE_DEFAULT to R.string.default_str, - NetworkStats.Bucket.STATE_FOREGROUND to R.string.foreground - ) OutlinedTextField( - value = stringResource(textMap[state]!!), onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExpandExposedTextFieldIcon(activeTextField == NetworkStatsActiveTextField.State) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth().padding(bottom = 4.dp) + stringResource(state.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.State) + } ) ExposedDropdownMenu( - activeTextField == NetworkStatsActiveTextField.State, { activeTextField = NetworkStatsActiveTextField.None } + menu == NetworkStatsMenu.State, { menu = NetworkStatsMenu.None } ) { - textMap.forEach { + NetworkStatsState.entries.forEach { DropdownMenuItem( - text = { Text(stringResource(it.value)) }, - onClick = { - state = it.key - activeTextField = NetworkStatsActiveTextField.None + { Text(stringResource(it.text)) }, + { + state = it + menu = NetworkStatsMenu.None } ) } @@ -1179,47 +1212,12 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta Button( onClick = { querying = true - coroutine.launch(Dispatchers.IO) { - val buckets = try { - @Suppress("NewApi") if(queryType == 1) { - if(target == NetworkStatsTarget.Device) - listOf(nsm.querySummaryForDevice(networkType.type, subscriberId, startTime, endTime)) - else listOf(nsm.querySummaryForUser(networkType.type, subscriberId, startTime, endTime)) - } else { - if(target == NetworkStatsTarget.Uid) - nsm.queryDetailsForUid(networkType.type, subscriberId, startTime, endTime, uid).toBucketList() - else if(target == NetworkStatsTarget.UidTag) - nsm.queryDetailsForUidTag(networkType.type, subscriberId, startTime, endTime, uid, tag).toBucketList() - else nsm.queryDetailsForUidTagState(networkType.type, subscriberId, startTime, endTime, uid, tag, state).toBucketList() - } - } catch(e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - querying = false - errorMessage = e.message - } - return@launch - }.filterNot { it == null } - if(buckets.isEmpty()) { - withContext(Dispatchers.Main) { - querying = false - context.showOperationResultToast(false) - } - } else { - val stats = buckets.map { - NetworkStatsViewer.Data( - it.rxBytes, it.rxPackets, it.txBytes, it.txPackets, - it.uid, it.state, it.startTimeStamp, it.endTimeStamp, - if(VERSION.SDK_INT >= 24) it.tag else null, - if(VERSION.SDK_INT >= 24) it.roaming else null, - if(VERSION.SDK_INT >= 26) it.metered else null - ) - } - withContext(Dispatchers.Main) { - querying = false - onNavigateToViewer(NetworkStatsViewer(stats)) - } - } + queryStats(QueryNetworkStatsParams( + type, target, networkType, startTime, endTime, uid, tag, state + )) { + querying = false + errorMessage = it + if (it == null) onNavigateToViewer() } }, enabled = !querying, @@ -1227,21 +1225,21 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta ) { Text(stringResource(R.string.query)) } - if(activeTextField == NetworkStatsActiveTextField.StartTime || activeTextField == NetworkStatsActiveTextField.EndTime) { - val datePickerState = rememberDatePickerState(if(activeTextField == NetworkStatsActiveTextField.StartTime) startTime else endTime) + if (menu == NetworkStatsMenu.StartTime || menu == NetworkStatsMenu.EndTime) { + val datePickerState = rememberDatePickerState(if (menu == NetworkStatsMenu.StartTime) startTime else endTime) DatePickerDialog( - onDismissRequest = { activeTextField = NetworkStatsActiveTextField.None }, + onDismissRequest = { menu = NetworkStatsMenu.None }, dismissButton = { - TextButton(onClick = { activeTextField = NetworkStatsActiveTextField.None }) { + TextButton(onClick = { menu = NetworkStatsMenu.None }) { Text(stringResource(R.string.cancel)) } }, confirmButton = { TextButton( onClick = { - if(activeTextField == NetworkStatsActiveTextField.StartTime) startTime = datePickerState.selectedDateMillis!! + if (menu == NetworkStatsMenu.StartTime) startTime = datePickerState.selectedDateMillis!! else endTime = datePickerState.selectedDateMillis!! - activeTextField = NetworkStatsActiveTextField.None + menu = NetworkStatsMenu.None }, enabled = datePickerState.selectedDateMillis != null ) { @@ -1256,38 +1254,41 @@ fun NetworkStatsScreen(onNavigateUp: () -> Unit, onNavigateToViewer: (NetworkSta ErrorDialog(errorMessage) { errorMessage = null } } -@Serializable -data class NetworkStatsViewer( - val stats: List -) { - @Serializable - data class Data( - val rxBytes: Long, - val rxPackets: Long, - val txBytes: Long, - val txPackets: Long, - val uid: Int, - val state: Int, - val startTime: Long, - val endTime: Long, - val tag: Int?, - val roaming: Int?, - val metered: Int? - ) -} +data class NetworkStatsData( + val rxBytes: Long, + val rxPackets: Long, + val txBytes: Long, + val txPackets: Long, + val uid: Int, + val state: Int, + val startTime: Long, + val endTime: Long, + val tag: Int?, + val roaming: Int?, + val metered: Int? +) + +@Serializable object NetworkStatsViewer @RequiresApi(23) @Composable -fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) { - var index by remember { mutableIntStateOf(0) } - val size = nsv.stats.size +fun NetworkStatsViewerScreen( + data: List, clearData: () -> Unit, onNavigateUp: () -> Unit +) { + var index by rememberSaveable { mutableIntStateOf(0) } + val size = data.size val ps = rememberPagerState { size } index = ps.currentPage val coroutine = rememberCoroutineScope() + DisposableEffect(Unit) { + onDispose { + clearData() + } + } MySmallTitleScaffold(R.string.network_stats, onNavigateUp, 0.dp) { if(size > 1) Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp) + Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { IconButton( onClick = { @@ -1297,7 +1298,7 @@ fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) }, enabled = index > 0 ) { - Icon(imageVector = Icons.AutoMirrored.Default.KeyboardArrowLeft, contentDescription = null) + Icon(Icons.AutoMirrored.Default.KeyboardArrowLeft, null) } Text("${index + 1} / $size", modifier = Modifier.padding(horizontal = 8.dp)) IconButton( @@ -1308,71 +1309,57 @@ fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) }, enabled = index < size - 1 ) { - Icon(imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, contentDescription = null) + Icon(Icons.AutoMirrored.Default.KeyboardArrowRight, null) } } HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page -> - val data = nsv.stats[page] + val item = data[index] Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { - Row(Modifier.align(Alignment.CenterHorizontally).padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { - SimpleDateFormat("", Locale.getDefault()).format(Date(data.startTime)) - Text( - formatDate("yyyy/MM/dd", data.startTime) + "\n" + formatDate("HH:mm:ss", data.startTime), - textAlign = TextAlign.Center - ) - Text("~", Modifier.padding(horizontal = 8.dp)) - Text( - formatDate("yyyy/MM/dd", data.endTime) + "\n" + formatDate("HH:mm:ss", data.endTime), - textAlign = TextAlign.Center - ) - } - val txBytes = data.txBytes - Text(stringResource(R.string.transmitted), style = typography.titleLarge) + 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 = Modifier.padding(start = 8.dp, bottom = 4.dp)) { Text("$txBytes bytes (${formatFileSize(txBytes)})") - Text(data.txPackets.toString() + " packets") + Text(item.txPackets.toString() + " packets") } - val rxBytes = data.rxBytes - Text(stringResource(R.string.received), style = typography.titleLarge) + val rxBytes = item.rxBytes + Text(stringResource(R.string.received), style = MaterialTheme.typography.titleMedium) Column(modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) { Text("$rxBytes bytes (${formatFileSize(rxBytes)})") - Text(data.rxPackets.toString() + " packets") + Text(item.rxPackets.toString() + " packets") } Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(data.state) { - NetworkStats.Bucket.STATE_ALL -> R.string.all - NetworkStats.Bucket.STATE_DEFAULT -> R.string.default_str - NetworkStats.Bucket.STATE_FOREGROUND -> R.string.foreground - else -> R.string.unknown - } - Text(stringResource(R.string.state), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + val text = NetworkStatsState.entries.find { it.id == item.state }!!.text + Text(stringResource(R.string.state), Modifier.padding(end = 8.dp), style = MaterialTheme.typography.titleMedium) Text(stringResource(text)) } if(VERSION.SDK_INT >= 24) { Row(verticalAlignment = Alignment.CenterVertically) { - val tag = data.tag - Text(stringResource(R.string.tag), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + val tag = item.tag + Text(stringResource(R.string.tag), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) Text(if(tag == NetworkStats.Bucket.TAG_NONE) stringResource(R.string.all) else tag.toString()) } Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(data.roaming) { + val text = when(item.roaming) { NetworkStats.Bucket.ROAMING_ALL -> R.string.all NetworkStats.Bucket.ROAMING_YES -> R.string.yes NetworkStats.Bucket.ROAMING_NO -> R.string.no else -> R.string.unknown } - Text(stringResource(R.string.roaming), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + Text(stringResource(R.string.roaming), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) Text(stringResource(text)) } } if(VERSION.SDK_INT >= 26) Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(data.metered) { + val text = when(item.metered) { NetworkStats.Bucket.METERED_ALL -> R.string.all NetworkStats.Bucket.METERED_YES -> R.string.yes NetworkStats.Bucket.METERED_NO -> R.string.no else -> R.string.unknown } - Text(stringResource(R.string.metered), style = typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) + Text(stringResource(R.string.metered), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) Text(stringResource(text)) } } @@ -1380,76 +1367,52 @@ fun NetworkStatsViewerScreen(nsv: NetworkStatsViewer, onNavigateUp: () -> Unit) } } +@RequiresApi(29) +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) +} + +data class PrivateDnsConfiguration(val mode: PrivateDnsMode?, val host: String) + @Serializable object PrivateDns @RequiresApi(29) @Composable -fun PrivateDnsScreen(onNavigateUp: () -> Unit) { +fun PrivateDnsScreen( + getPrivateDns: () -> PrivateDnsConfiguration, + setPrivateDns: (PrivateDnsConfiguration) -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - MyScaffold(R.string.private_dns, onNavigateUp) { - fun getDnsStatus(code: Int) = when (code) { - PRIVATE_DNS_MODE_UNKNOWN -> R.string.unknown - PRIVATE_DNS_MODE_OFF -> R.string.disabled - PRIVATE_DNS_MODE_OPPORTUNISTIC -> R.string.auto - PRIVATE_DNS_MODE_PROVIDER_HOSTNAME -> R.string.dns_provide_hostname - else -> R.string.place_holder + var mode by remember { mutableStateOf(PrivateDnsMode.Opportunistic) } + var inputHost by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + val conf = getPrivateDns() + mode = conf.mode + inputHost = conf.host + } + MyScaffold(R.string.private_dns, onNavigateUp, 0.dp) { + PrivateDnsMode.entries.forEach { + FullWidthRadioButtonItem(it.text, mode == it) { mode = it } } - fun getOperationResult(code: Int) = when (code) { - PRIVATE_DNS_SET_NO_ERROR -> R.string.success - PRIVATE_DNS_SET_ERROR_HOST_NOT_SERVING -> R.string.host_not_serving_dns_tls - PRIVATE_DNS_SET_ERROR_FAILURE_SETTING -> R.string.failed - else -> R.string.place_holder - } - var dnsMode by remember { mutableIntStateOf(Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR)) } - Spacer(Modifier.padding(vertical = 5.dp)) - Text(stringResource(R.string.current_state, stringResource(getDnsStatus(dnsMode)))) - AnimatedVisibility(Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR) != PRIVATE_DNS_MODE_OPPORTUNISTIC) { - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - val result = Privilege.DPM.setGlobalPrivateDnsModeOpportunistic(Privilege.DAR) - context.popToast(getOperationResult(result)) - dnsMode = Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.set_to_opportunistic)) - } - } - Notes(R.string.info_private_dns_mode_oppertunistic) - Spacer(Modifier.padding(vertical = 10.dp)) - var inputHost by remember { mutableStateOf(Privilege.DPM.getGlobalPrivateDnsHost(Privilege.DAR) ?: "") } - OutlinedTextField( - value = inputHost, - onValueChange = { inputHost=it }, + if (mode == PrivateDnsMode.Host) OutlinedTextField( + inputHost, { inputHost=it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), label = { Text(stringResource(R.string.dns_hostname)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) - Spacer(Modifier.padding(vertical = 3.dp)) Button( onClick = { focusMgr.clearFocus() - try { - val result = Privilege.DPM.setGlobalPrivateDnsModeSpecifiedHost(Privilege.DAR, inputHost) - context.popToast(getOperationResult(result)) - } catch(e: IllegalArgumentException) { - e.printStackTrace() - context.popToast(R.string.invalid_hostname) - } catch(e: SecurityException) { - e.printStackTrace() - context.popToast(R.string.security_exception) - } finally { - dnsMode = Privilege.DPM.getGlobalPrivateDnsMode(Privilege.DAR) - } + val result = setPrivateDns(PrivateDnsConfiguration(mode, inputHost)) + context.showOperationResultToast(result) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + enabled = mode != null ) { - Text(stringResource(R.string.set_dns_host)) + Text(stringResource(R.string.apply)) } - Notes(R.string.info_set_private_dns_host) } } @@ -1457,58 +1420,36 @@ fun PrivateDnsScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable -fun AlwaysOnVpnPackageScreen(onNavigateUp: () -> Unit) { +fun AlwaysOnVpnPackageScreen( + getPackage: () -> String, getLockdown: () -> Boolean, setConf: (String?, Boolean) -> Int, + chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var lockdown by rememberSaveable { mutableStateOf(false) } - var pkgName by rememberSaveable { mutableStateOf("") } - val focusMgr = LocalFocusManager.current - val refresh = { pkgName = Privilege.DPM.getAlwaysOnVpnPackage(Privilege.DAR) ?: "" } - LaunchedEffect(Unit) { refresh() } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { pkgName = it } - } - val setAlwaysOnVpn: (String?, Boolean)->Boolean = { vpnPkg: String?, lockdownEnabled: Boolean -> - try { - Privilege.DPM.setAlwaysOnVpnPackage(Privilege.DAR, vpnPkg, lockdownEnabled) - context.showOperationResultToast(true) - true - } catch(e: UnsupportedOperationException) { - e.printStackTrace() - context.popToast(R.string.unsupported) - false - } catch(e: NameNotFoundException) { - e.printStackTrace() - context.popToast(R.string.not_installed) - false - } + var lockdown by rememberSaveable { mutableStateOf(getLockdown()) } + var pkgName by rememberSaveable { mutableStateOf(getPackage()) } + LaunchedEffect(Unit) { + pkgName = chosenPackage.receive() } MyScaffold(R.string.always_on_vpn, onNavigateUp) { - OutlinedTextField( - value = pkgName, - onValueChange = { pkgName = it }, - label = { Text(stringResource(R.string.package_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - trailingIcon = { - Icon(painter = painterResource(R.drawable.list_fill0), contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { choosePackage.launch(null) } - .padding(3.dp)) - }, - modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) - ) + PackageNameTextField(pkgName, onChoosePackage, + Modifier.padding(vertical = 4.dp)) { pkgName = it } SwitchItem(R.string.enable_lockdown, state = lockdown, onCheckedChange = { lockdown = it }, padding = false) Spacer(Modifier.padding(vertical = 5.dp)) Button( - onClick = { if(setAlwaysOnVpn(pkgName, lockdown)) refresh() }, + onClick = { + context.popToast(setConf(pkgName, lockdown)) + }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.apply)) } Spacer(Modifier.padding(vertical = 5.dp)) Button( - onClick = { if(setAlwaysOnVpn(null, false)) refresh() }, + onClick = { + context.popToast(setConf(null, false)) + pkgName = "" + lockdown = false + }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.clear_current_config)) @@ -1517,100 +1458,86 @@ fun AlwaysOnVpnPackageScreen(onNavigateUp: () -> Unit) { } } +enum class ProxyType(val text: Int) { + Off(R.string.proxy_type_off), Pac(R.string.proxy_type_pac), Direct(R.string.proxy_type_direct) +} + +data class RecommendedProxyConf( + val type: ProxyType, val url: String, val host: String, val specifyPort: Boolean, + val port: Int, val exclude: List +) + @Serializable object RecommendedGlobalProxy @Composable -fun RecommendedGlobalProxyScreen(onNavigateUp: () -> Unit) { +fun RecommendedGlobalProxyScreen( + setProxy: (RecommendedProxyConf) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var proxyType by remember { mutableIntStateOf(0) } - var proxyUri by remember { mutableStateOf("") } - var specifyPort by remember { mutableStateOf(false) } - var proxyPort by remember { mutableStateOf("") } - var exclList by remember { mutableStateOf("") } - MyScaffold(R.string.recommended_global_proxy, onNavigateUp) { - RadioButtonItem(R.string.proxy_type_off, proxyType == 0) { proxyType = 0 } - RadioButtonItem(R.string.proxy_type_pac, proxyType == 1) { proxyType = 1 } - RadioButtonItem(R.string.proxy_type_direct, proxyType == 2) { proxyType = 2 } - AnimatedVisibility(proxyType != 0) { + 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( - value = proxyUri, - onValueChange = { proxyUri = it }, - label = { Text(if(proxyType == 1) "URL" else "Host") }, + pacUrl, { pacUrl = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), + label = { Text("URL") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp) + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) } - AnimatedVisibility(proxyType == 1 && VERSION.SDK_INT >= 30) { - Box(modifier = Modifier.padding(top = 2.dp)) { - CheckBoxItem(R.string.specify_port, specifyPort) { specifyPort = it } - } - } - AnimatedVisibility((proxyType == 1 && specifyPort && VERSION.SDK_INT >= 30) || proxyType == 2) { + AnimatedVisibility(type == ProxyType.Direct) { OutlinedTextField( - value = proxyPort, - onValueChange = { proxyPort = it }, + host, { host = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), + label = { Text("Host") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) + ) + } + AnimatedVisibility(type == ProxyType.Pac && VERSION.SDK_INT >= 30) { + FullWidthCheckBoxItem(R.string.specify_port, specifyPort) { specifyPort = it } + } + AnimatedVisibility((specifyPort && VERSION.SDK_INT >= 30) || type == ProxyType.Direct) { + OutlinedTextField( + port, { port = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), label = { Text(stringResource(R.string.port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp) + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) } - AnimatedVisibility(proxyType == 2) { + AnimatedVisibility(type == ProxyType.Direct) { OutlinedTextField( - value = exclList, - onValueChange = { exclList = it }, + exclList, { exclList = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), label = { Text(stringResource(R.string.excluded_hosts)) }, maxLines = 5, minLines = 2, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusMgr.clearFocus() }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp) + keyboardActions = KeyboardActions { focusMgr.clearFocus() } ) } Button( onClick = { - if(proxyType == 0) { - Privilege.DPM.setRecommendedGlobalProxy(Privilege.DAR, null) - context.showOperationResultToast(true) - return@Button - } - if(proxyUri == "") { - context.popToast(R.string.invalid_config) - return@Button - } - val uri = proxyUri.toUri() - val port: Int - try { - port = proxyPort.toInt() - } catch(e: NumberFormatException) { - e.printStackTrace() - context.popToast(R.string.invalid_config) - return@Button - } - val proxyInfo = - if(proxyType == 1) { - if(specifyPort && VERSION.SDK_INT >= 30) { - ProxyInfo.buildPacProxy(uri, port) - } else { - ProxyInfo.buildPacProxy(uri) - } - } else { - ProxyInfo.buildDirectProxy(proxyUri, port, exclList.lines()) - } - if(VERSION.SDK_INT >= 30 && !proxyInfo.isValid) { - context.popToast(R.string.invalid_config) - return@Button - } - Privilege.DPM.setRecommendedGlobalProxy(Privilege.DAR, proxyInfo) + setProxy(RecommendedProxyConf( + type, pacUrl, host, specifyPort, port.toIntOrNull() ?: 0, + exclList.lines().filter { it.isNotBlank() } + )) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), + enabled = type == ProxyType.Off || + (type == ProxyType.Pac && pacUrl.isNotBlank() && (!specifyPort || port.toIntOrNull() != null)) || + (type == ProxyType.Direct && port.toIntOrNull() != null) ) { Text(stringResource(R.string.apply)) } - Notes(R.string.info_recommended_global_proxy) + Notes(R.string.info_recommended_global_proxy, HorizontalPadding) } } @@ -1618,112 +1545,102 @@ fun RecommendedGlobalProxyScreen(onNavigateUp: () -> Unit) { @RequiresApi(26) @Composable -fun NetworkLoggingScreen(onNavigateUp: () -> Unit) { +fun NetworkLoggingScreen( + getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, getCount: () -> Int, + exportLogs: (Uri, () -> Unit) -> Unit, deleteLogs: () -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val logFile = context.filesDir.resolve("NetworkLogs.json") - var fileSize by remember { mutableLongStateOf(0) } - LaunchedEffect(Unit) { fileSize = logFile.length() } - val exportNetworkLogsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - if(uri != null) context.contentResolver.openOutputStream(uri)?.use { outStream -> - outStream.write("[".encodeToByteArray()) - logFile.inputStream().use { it.copyTo(outStream) } - outStream.write("]".encodeToByteArray()) - context.showOperationResultToast(true) + var enabled by remember { mutableStateOf(false) } + var count by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableStateOf(false) } + var exporting by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + enabled = getEnabled() + count = getCount() + } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + if (uri != null) { + exporting = true + exportLogs(uri) { + exporting = false + context.showOperationResultToast(true) + } } } - MyScaffold(R.string.network_logging, onNavigateUp) { + MyScaffold(R.string.network_logging, onNavigateUp, 0.dp) { SwitchItem( - R.string.enable, - getState = { Privilege.DPM.isNetworkLoggingEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setNetworkLoggingEnabled(Privilege.DAR, it) }, - padding = false + R.string.enable, enabled, { + setEnabled(it) + enabled = it + } ) - Text(stringResource(R.string.log_file_size_is, formatFileSize(fileSize))) - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - exportNetworkLogsLauncher.launch("NetworkLogs.json") - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.export_logs)) - } - Button( - onClick = { - logFile.delete() - fileSize = logFile.length() - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.delete_logs)) - } - } - Notes(R.string.info_network_log) - } -} - -@Serializable object WifiAuthKeypair - -@RequiresApi(31) -@Composable -fun WifiAuthKeypairScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - var keyPair by remember { mutableStateOf("") } - MyScaffold(R.string.wifi_auth_keypair, onNavigateUp) { - OutlinedTextField( - value = keyPair, - label = { Text(stringResource(R.string.alias)) }, - onValueChange = { keyPair = it }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() + Text( + stringResource(R.string.n_logs_in_total, count), + Modifier.padding(HorizontalPadding, 5.dp) ) - Spacer(Modifier.padding(vertical = 5.dp)) - val isExist = try { - Privilege.DPM.isKeyPairGrantedToWifiAuth(keyPair) - } catch(e: java.lang.IllegalArgumentException) { - e.printStackTrace() - false + 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)) } - Text(stringResource(R.string.already_exist)+":$isExist") - Spacer(Modifier.padding(vertical = 5.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { context.showOperationResultToast(Privilege.DPM.grantKeyPairToWifiAuth(keyPair)) }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.grant)) - } - Button( - onClick = { context.showOperationResultToast(Privilege.DPM.revokeKeyPairFromWifiAuth(keyPair)) }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.revoke)) - } + if (count > 0) Button( + { + dialog = true + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + ) { + Text(stringResource(R.string.delete_logs)) } + Spacer(Modifier.height(10.dp)) + Notes(R.string.info_network_log, HorizontalPadding) } + if (exporting) CircularProgressDialog { exporting = false } + if (dialog) AlertDialog( + text = { + Text(stringResource(R.string.delete_logs)) + }, + confirmButton = { + TextButton({ + deleteLogs() + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) } @Serializable object PreferentialNetworkService @RequiresApi(33) @Composable -fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddPreferentialNetworkServiceConfig) -> Unit) { - var masterEnabled by remember { mutableStateOf(false) } - val configs = remember { mutableStateListOf() } - fun refresh() { - masterEnabled = Privilege.DPM.isPreferentialNetworkServiceEnabled - configs.clear() - configs.addAll(Privilege.DPM.preferentialNetworkServiceConfigs) +fun PreferentialNetworkServiceScreen( + getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, + pnsConfigs: StateFlow>, getConfigs: () -> Unit, + onNavigateUp: () -> Unit, onNavigate: (AddPreferentialNetworkServiceConfig) -> Unit +) { + var masterEnabled by rememberSaveable { mutableStateOf(getEnabled()) } + val configs by pnsConfigs.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + getConfigs() } - LaunchedEffect(Unit) { refresh() } MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp, 0.dp) { SwitchItem(R.string.enabled, state = masterEnabled, onCheckedChange = { - Privilege.DPM.isPreferentialNetworkServiceEnabled = it - refresh() + setEnabled(it) + masterEnabled = it }) Spacer(Modifier.padding(vertical = 4.dp)) configs.forEachIndexed { index, config -> @@ -1731,19 +1648,9 @@ fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddP Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Column { - Text(index.toString()) - } + Text(config.id.toString()) IconButton({ - onNavigate(AddPreferentialNetworkServiceConfig( - enabled = config.isEnabled, - id = config.networkId, - allowFallback = config.isFallbackToDefaultConnectionAllowed, - blockNonMatching = if(VERSION.SDK_INT >= 34) config.shouldBlockNonMatchingNetworks() else false, - excludedUids = config.excludedUids.toList(), - includedUids = config.includedUids.toList(), - index = index - )) + onNavigate(AddPreferentialNetworkServiceConfig(index)) }) { Icon(Icons.Default.Edit, stringResource(R.string.edit)) } @@ -1752,7 +1659,7 @@ fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddP Row( Modifier.fillMaxWidth() .padding(top = 4.dp) - .clickable { onNavigate(AddPreferentialNetworkServiceConfig()) } + .clickable { onNavigate(AddPreferentialNetworkServiceConfig(-1)) } .padding(horizontal = 8.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -1762,38 +1669,51 @@ fun PreferentialNetworkServiceScreen(onNavigateUp: () -> Unit, onNavigate: (AddP } } -@Serializable data class AddPreferentialNetworkServiceConfig( +data class PreferentialNetworkServiceInfo( val enabled: Boolean = true, val id: Int = -1, val allowFallback: Boolean = false, val blockNonMatching: Boolean = false, val excludedUids: List = emptyList(), - val includedUids: List = emptyList(), - val index: Int = -1 + val includedUids: List = emptyList() ) +@Serializable +data class AddPreferentialNetworkServiceConfig(val index: Int) + +@OptIn(ExperimentalMaterial3Api::class) @RequiresApi(33) @Composable -fun AddPreferentialNetworkServiceConfigScreen(route: AddPreferentialNetworkServiceConfig,onNavigateUp: () -> Unit) { - val updateMode = route.index != -1 - val context = LocalContext.current - var enabled by remember { mutableStateOf(route.enabled) } - var id by remember { mutableIntStateOf(route.id) } - var allowFallback by remember { mutableStateOf(route.allowFallback) } - var blockNonMatching by remember { mutableStateOf(route.blockNonMatching) } - var excludedUids by remember { mutableStateOf(route.excludedUids.joinToString("\n")) } - var includedUids by remember { mutableStateOf(route.includedUids.joinToString("\n")) } +fun AddPreferentialNetworkServiceConfigScreen( + origin: PreferentialNetworkServiceInfo, + setConfig: (PreferentialNetworkServiceInfo, Boolean) -> Unit, onNavigateUp: () -> Unit +) { + val updateMode = origin.id != -1 + var enabled by rememberSaveable { mutableStateOf(origin.enabled) } + var id by rememberSaveable { mutableIntStateOf(origin.id) } + var allowFallback by rememberSaveable { mutableStateOf(origin.allowFallback) } + var blockNonMatching by rememberSaveable { mutableStateOf(origin.blockNonMatching) } + var excludedUids by rememberSaveable { mutableStateOf(origin.excludedUids.joinToString("\n")) } + var includedUids by rememberSaveable { mutableStateOf(origin.includedUids.joinToString("\n")) } + var dropdown by remember { mutableStateOf(false) } MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp) { SwitchItem(title = R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false) - AnimatedVisibility(enabled) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("ID", Modifier.padding(end = 8.dp), style = typography.titleLarge) - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - for(i in 1..5) { - SegmentedButton(id == i, { id = i }, SegmentedButtonDefaults.itemShape(i - 1, 5)) { - Text(i.toString()) + ExposedDropdownMenuBox(dropdown, { dropdown = it }) { + OutlinedTextField( + if (id == -1) "" else id.toString(), {}, + Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("id") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + for (i in 1..5) { + DropdownMenuItem( + { Text(i.toString()) }, + { + id = i + dropdown = false } - } + ) } } } @@ -1827,39 +1747,22 @@ fun AddPreferentialNetworkServiceConfigScreen(route: AddPreferentialNetworkServi ) Button( onClick = { - try { - val config = PreferentialNetworkServiceConfig.Builder().apply { - setEnabled(enabled) - if(enabled) setNetworkId(id) - setFallbackToDefaultConnectionAllowed(allowFallback) - setExcludedUids(excludedUids.lines().filter { it.isNotBlank() }.map { it.toInt() }.toIntArray()) - setIncludedUids(includedUids.lines().filter { it.isNotBlank() }.map { it.toInt() }.toIntArray()) - if(VERSION.SDK_INT >= 34) setShouldBlockNonMatchingNetworks(blockNonMatching) - }.build() - val configs = Privilege.DPM.preferentialNetworkServiceConfigs - if(updateMode) configs[route.index] = config - else configs += config - Privilege.DPM.preferentialNetworkServiceConfigs = configs - onNavigateUp() - } catch(e: Exception) { - context.showOperationResultToast(false) - e.printStackTrace() - } + setConfig(PreferentialNetworkServiceInfo( + enabled, id, allowFallback, blockNonMatching, + excludedUids.lines().mapNotNull { it.toIntOrNull() }, + includedUids.lines().mapNotNull { it.toIntOrNull() } + ), true) + onNavigateUp() }, - enabled = includedUidsLegal && excludedUidsLegal, + enabled = includedUidsLegal && excludedUidsLegal && id in 1..5, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) ) { Text(stringResource(if(updateMode) R.string.update else R.string.add)) } if(updateMode) Button( onClick = { - try { - Privilege.DPM.preferentialNetworkServiceConfigs = Privilege.DPM.preferentialNetworkServiceConfigs.drop(route.index) - onNavigateUp() - } catch(e: Exception) { - context.showOperationResultToast(false) - e.printStackTrace() - } + setConfig(origin, false) + onNavigateUp() }, colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError), modifier = Modifier.fillMaxWidth() @@ -1873,384 +1776,407 @@ fun AddPreferentialNetworkServiceConfigScreen(route: AddPreferentialNetworkServi @RequiresApi(28) @Composable -fun OverrideApnScreen(onNavigateUp: () -> Unit, onNavigateToAddSetting: (Bundle) -> Unit) { - var enabled by remember { mutableStateOf(false) } - val settings = remember { mutableStateListOf() } - fun refresh() { - enabled = Privilege.DPM.isOverrideApnEnabled(Privilege.DAR) - settings.clear() - settings.addAll(Privilege.DPM.getOverrideApns(Privilege.DAR)) - } - LaunchedEffect(Unit) { refresh() } +fun OverrideApnScreen( + apnConfigs: StateFlow>, getConfigs: () -> Unit, getEnabled: () -> Boolean, + setEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit, onNavigateToAddSetting: (Int) -> Unit +) { + var enabled by rememberSaveable { mutableStateOf(getEnabled()) } + val configs by apnConfigs.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getConfigs() } MyScaffold(R.string.override_apn, onNavigateUp, 0.dp) { SwitchItem( - R.string.enable, state = enabled, - onCheckedChange = { - Privilege.DPM.setOverrideApnsEnabled(Privilege.DAR, it) - refresh() + R.string.enable, enabled, + { + setEnabled(it) + enabled = it } ) - settings.forEach { + configs.forEach { Row( Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Column { - Text(it.id.toString()) - Text(it.apnName.toString(), color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium) - Text(it.entryName.toString(), color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium) + Row { + Text(it.id.toString(), Modifier.padding(end = 8.dp)) + Column { + Text(it.name) + Text(it.apn, Modifier.alpha(0.7F), style = MaterialTheme.typography.bodyMedium) + } } IconButton({ - onNavigateToAddSetting(bundleOf("setting" to it)) + onNavigateToAddSetting(it.id) }) { - Icon(Icons.Default.Edit, null) + Icon(Icons.Outlined.Edit, null) } } } Row( Modifier.fillMaxWidth().clickable { - onNavigateToAddSetting(Bundle()) + onNavigateToAddSetting(-1) }.padding(horizontal = 8.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Default.Add, null, Modifier.padding(horizontal = 8.dp)) - Text(stringResource(R.string.add_config), style = typography.labelLarge) + Text(stringResource(R.string.add_config), style = MaterialTheme.typography.labelLarge) } } } -private data class ApnType(val id: Int, val name: String, val requiresApi: Int = 0) +enum class ApnMenu { + None, ApnType, AuthType, Protocol, RoamingProtocol, NetworkType, MvnoType, OperatorNumeric +} + +data class ApnType(val id: Int, val name: String, val requiresApi: Int = 0) @SuppressLint("InlinedApi") -private val apnTypes = listOf( - ApnType(ApnSetting.TYPE_DEFAULT, "Default"), ApnType(ApnSetting.TYPE_MMS, "MMS"), ApnType(ApnSetting.TYPE_SUPL, "SUPL"), - ApnType(ApnSetting.TYPE_DUN, "DUN"), ApnType(ApnSetting.TYPE_HIPRI, "HiPri"), ApnType(ApnSetting.TYPE_FOTA, "FOTA"), - ApnType(ApnSetting.TYPE_IMS, "IMS"), ApnType(ApnSetting.TYPE_CBS, "CBS"), ApnType(ApnSetting.TYPE_IA, "IA"), - ApnType(ApnSetting.TYPE_EMERGENCY, "Emergency"), ApnType(ApnSetting.TYPE_MCX, "MCX", 29), ApnType(ApnSetting.TYPE_XCAP, "XCAP", 30), - ApnType(ApnSetting.TYPE_BIP, "BIP", 31), ApnType(ApnSetting.TYPE_VSIM, "VSIM", 31), ApnType(ApnSetting.TYPE_ENTERPRISE, "Enterprise", 33), - ApnType(ApnSetting.TYPE_RCS, "RCS", 35) // TODO: Adapt A16 later +val apnTypes = listOf( + ApnType(ApnSetting.TYPE_DEFAULT, "Default"), + ApnType(ApnSetting.TYPE_MMS, "MMS"), + ApnType(ApnSetting.TYPE_SUPL, "SUPL"), + ApnType(ApnSetting.TYPE_DUN, "DUN"), + ApnType(ApnSetting.TYPE_HIPRI, "HiPri"), + ApnType(ApnSetting.TYPE_FOTA, "FOTA"), + ApnType(ApnSetting.TYPE_IMS, "IMS"), + ApnType(ApnSetting.TYPE_CBS, "CBS"), + ApnType(ApnSetting.TYPE_IA, "IA"), + ApnType(ApnSetting.TYPE_EMERGENCY, "Emergency"), + ApnType(ApnSetting.TYPE_MCX, "MCX", 29), + ApnType(ApnSetting.TYPE_XCAP, "XCAP", 30), + ApnType(ApnSetting.TYPE_VSIM, "VSIM", 31), + ApnType(ApnSetting.TYPE_BIP, "BIP", 31), + ApnType(ApnSetting.TYPE_ENTERPRISE, "Enterprise", 33), + ApnType(ApnSetting.TYPE_RCS, "RCS", 35), + ApnType(ApnSetting.TYPE_OEM_PAID, "OEM paid"), + ApnType(ApnSetting.TYPE_OEM_PRIVATE, "OEM private") +).filter { VERSION.SDK_INT >= it.requiresApi } + +@Suppress("InlinedApi") +enum class ApnProtocol(val id: Int, val text: String, val requiresApi: Int = 28) { + Ip(ApnSetting.PROTOCOL_IP, "IPv4"), + Ipv6(ApnSetting.PROTOCOL_IPV6, "IPv6"), + Ipv4v6(ApnSetting.PROTOCOL_IPV4V6, "IPv4/IPv6"), + Ppp(ApnSetting.PROTOCOL_PPP, "PPP"), + NonIp(ApnSetting.PROTOCOL_NON_IP, "Non-IP", 29), + Unstructured(ApnSetting.PROTOCOL_UNSTRUCTURED, "Unstructured", 29) +} + +@Suppress("InlinedApi") +enum class ApnAuthType(val id: Int, val text: String) { + None(ApnSetting.AUTH_TYPE_NONE, "None"), + Pap(ApnSetting.AUTH_TYPE_PAP, "PAP"), + Chap(ApnSetting.AUTH_TYPE_CHAP, "CHAP"), + PapChap(ApnSetting.AUTH_TYPE_PAP_OR_CHAP, "PAP/CHAP") +} + +data class ApnNetworkType(val id: Int, val text: String, val requiresApi: Int = 0) +@Suppress("InlinedApi", "DEPRECATION") +val apnNetworkTypes = listOf( + ApnNetworkType(TelephonyManager.NETWORK_TYPE_LTE, "LTE"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPAP, "HSPA+"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPA, "HSPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSUPA, "HSUPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSDPA, "HSDPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_UMTS, "UMTS"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EDGE, "EDGE"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_GPRS, "GPRS"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EHRPD, "CDMA - eHRPD"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_B, "CDMA - EvDo rev. B"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_A, "CDMA - EvDo rev. A"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_0, "CDMA - EvDo rev. 0"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_1xRTT, "CDMA - 1xRTT"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_CDMA, "CDMA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_NR, "NR", 29) +).filter { VERSION.SDK_INT >= it.requiresApi } + +@Suppress("InlinedApi") +enum class ApnMvnoType(val id: Int, val text: String) { + SPN(ApnSetting.MVNO_TYPE_SPN, "SPN"), + IMSI(ApnSetting.MVNO_TYPE_IMSI, "IMSI"), + GID(ApnSetting.MVNO_TYPE_GID, "GID"), + ICCID(ApnSetting.MVNO_TYPE_ICCID, "ICCID") +} + +data class ApnConfig( + val enabled: Boolean, val name: String, val apn: String, val proxy: String, val port: Int?, + val username: String, val password: String, val apnType: Int, val mmsc: String, + val mmsProxy: String, val mmsPort: Int?, val authType: ApnAuthType, val protocol: ApnProtocol, + val roamingProtocol: ApnProtocol, val networkType: Int, val profileId: Int?, val carrierId: Int?, + val mtuV4: Int?, val mtuV6: Int?, val mvno: ApnMvnoType, val operatorNumeric: String, + val persistent: Boolean, val alwaysOn: Boolean, val id: Int = -1 ) -@Serializable object AddApnSetting +@Serializable data class AddApnSetting(val index: Int) -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @RequiresApi(28) @Composable -fun AddApnSettingScreen(origin: ApnSetting?, onNavigateUp: () -> Unit) { - val fm = LocalFocusManager.current - var dropdown by remember { mutableIntStateOf(0) } // 1:Auth type, 2:MVNO type, 3:Protocol, 4:Roaming protocol - var dialog by remember { mutableIntStateOf(0) } // 1:Proxy, 2:MMS proxy - var enabled by remember { mutableStateOf(true) } - var apnName by remember { mutableStateOf(origin?.apnName ?: "") } - var entryName by remember { mutableStateOf(origin?.entryName ?: "") } - var apnType by remember { mutableIntStateOf(origin?.apnTypeBitmask ?: 0) } - var profileId by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.profileId?.toString() ?: "" else "") } - var carrierId by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.carrierId?.toString() ?: "" else "") } - var authType by remember { mutableIntStateOf(origin?.authType ?: ApnSetting.AUTH_TYPE_NONE) } - var user by remember { mutableStateOf(origin?.user ?: "") } - var password by remember { mutableStateOf(origin?.password ?: "") } - var proxyAddress by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.proxyAddressAsString ?: "" else "") } - var proxyPort by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.proxyPort?.toString() ?: "" else "") } - var mmsProxyAddress by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.mmsProxyAddressAsString ?: "" else "") } - var mmsProxyPort by remember { mutableStateOf(if(VERSION.SDK_INT >= 29) origin?.mmsProxyPort?.toString() ?: "" else "") } - var mmsc by remember { mutableStateOf(origin?.mmsc?.toString() ?: "") } - var mtuV4 by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.mtuV4?.toString() ?: "" else "") } - var mtuV6 by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.mtuV6?.toString() ?: "" else "") } - var mvnoType by remember { mutableIntStateOf(origin?.mvnoType ?: ApnSetting.MVNO_TYPE_SPN) } - var networkTypeBitmask by remember { mutableStateOf(origin?.networkTypeBitmask?.toString() ?: "") } - var operatorNumeric by remember { mutableStateOf(origin?.operatorNumeric ?: "") } - var protocol by remember { mutableIntStateOf(origin?.protocol ?: ApnSetting.PROTOCOL_IP) } - var roamingProtocol by remember { mutableIntStateOf(origin?.roamingProtocol ?: ApnSetting.PROTOCOL_IP) } - var persistent by remember { mutableStateOf(if(VERSION.SDK_INT >= 33) origin?.isPersistent == true else false) } - var alwaysOn by remember { mutableStateOf(VERSION.SDK_INT >= 35 && origin?.isAlwaysOn == true) } - var errorMessage: String? by remember { mutableStateOf(null) } +fun AddApnSettingScreen( + setApn: (ApnConfig) -> Boolean, deleteApn: (Int) -> Boolean, origin: ApnConfig?, + onNavigateUp: () -> Unit +) { + val context = LocalContext.current + var menu by remember { mutableStateOf(ApnMenu.None) } + var enabled by rememberSaveable { mutableStateOf(true) } + var entryName by rememberSaveable { mutableStateOf(origin?.name ?: "") } + var apnName by rememberSaveable { mutableStateOf(origin?.apn ?: "") } + var apnType by rememberSaveable { mutableIntStateOf(origin?.apnType ?: 0) } + var profileId by rememberSaveable { mutableStateOf(origin?.profileId?.toString() ?: "") } + var carrierId by rememberSaveable { mutableStateOf(origin?.carrierId?.toString() ?: "") } + var authType by rememberSaveable { mutableStateOf(ApnAuthType.None) } + var user by rememberSaveable { mutableStateOf(origin?.username ?: "") } + var password by rememberSaveable { mutableStateOf(origin?.password ?: "") } + var proxy by rememberSaveable { mutableStateOf(origin?.proxy ?: "") } + var port by rememberSaveable { mutableStateOf(origin?.port?.toString() ?: "") } + var mmsProxy by rememberSaveable { mutableStateOf(origin?.mmsProxy ?: "") } + var mmsPort by rememberSaveable { mutableStateOf(origin?.mmsPort?.toString() ?: "") } + var mmsc by rememberSaveable { mutableStateOf(origin?.mmsc ?: "") } + var mtuV4 by rememberSaveable { mutableStateOf(origin?.mtuV4?.toString() ?: "") } + var mtuV6 by rememberSaveable { mutableStateOf(origin?.mtuV6?.toString() ?: "") } + var mvnoType by rememberSaveable { mutableStateOf(origin?.mvno ?: ApnMvnoType.SPN) } + var networkType by rememberSaveable { mutableIntStateOf(origin?.networkType ?: 0) } + var operatorNumeric by rememberSaveable { mutableStateOf(origin?.operatorNumeric ?: "") } + var protocol by rememberSaveable { mutableStateOf(origin?.protocol ?: ApnProtocol.Ip) } + var roamingProtocol by rememberSaveable { mutableStateOf(origin?.roamingProtocol ?: ApnProtocol.Ip) } + var persistent by rememberSaveable { mutableStateOf(origin?.persistent == true) } + var alwaysOn by rememberSaveable { mutableStateOf(origin?.alwaysOn == true) } + var errorMessage: String? by rememberSaveable { mutableStateOf(null) } MySmallTitleScaffold(R.string.apn_setting, onNavigateUp) { - val protocolMap = mapOf( - ApnSetting.PROTOCOL_IP to "IPv4", ApnSetting.PROTOCOL_IPV6 to "IPv6", - ApnSetting.PROTOCOL_IPV4V6 to "IPv4/v6", ApnSetting.PROTOCOL_PPP to "PPP" - ).let { - if(VERSION.SDK_INT >= 29) { - it.plus(listOf(ApnSetting.PROTOCOL_NON_IP to "Non-IP", ApnSetting.PROTOCOL_UNSTRUCTURED to "Unstructured")) - } else it - } SwitchItem(R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false) OutlinedTextField( - apnName, { apnName = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.apn_name) + " (*)") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + entryName, { entryName = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + label = { Text("Name") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) OutlinedTextField( - entryName, { entryName = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text(stringResource(R.string.entry_name) + " (*)") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + apnName, { apnName = it }, Modifier.fillMaxWidth(), + label = { Text("APN") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - Text(stringResource(R.string.type) + " (*)", Modifier.padding(vertical = 4.dp), style = typography.titleLarge) - FlowRow(Modifier.padding(bottom = 4.dp)) { - apnTypes.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { - FilterChip( - apnType and it.id == it.id, { - apnType = if(apnType and it.id == it.id) apnType and (apnType xor it.id) else apnType or it.id - }, - { Text(it.name) }, Modifier.padding(horizontal = 4.dp) - ) - } - } - if(VERSION.SDK_INT >= 33) OutlinedTextField( - profileId, { profileId = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.profile_id)) }, isError = profileId.isNotEmpty() && profileId.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + OutlinedTextField( + proxy, { proxy = it }, Modifier.fillMaxWidth(), + label = { Text("Proxy") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - if(VERSION.SDK_INT >= 29) OutlinedTextField( - carrierId, { carrierId = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text(stringResource(R.string.carrier_id)) }, - isError = carrierId.isNotEmpty() && carrierId.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + OutlinedTextField( + port, { port = it }, Modifier.fillMaxWidth(), + label = { Text("Port") }, + isError = port.isNotEmpty() && port.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) ) - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 1) 180F else 0F) - val authTypeMap = mapOf( - ApnSetting.AUTH_TYPE_NONE to stringResource(R.string.none), ApnSetting.AUTH_TYPE_PAP to "PAP", - ApnSetting.AUTH_TYPE_CHAP to "CHAP", ApnSetting.AUTH_TYPE_PAP_OR_CHAP to "PAP/CHAP" - ) - Text(stringResource(R.string.auth_type)) - Row(Modifier.clickable { dropdown = 1 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(authTypeMap[authType]!!, Modifier.padding(2.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 1, { dropdown = 0 }) { - authTypeMap.forEach { - DropdownMenuItem({ Text(it.value) }, { authType = it.key; dropdown = 0 }) - } - } - } - } OutlinedTextField( user, { user = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.user)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + label = { Text("Username") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) OutlinedTextField( password, { password = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.password)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + label = { Text("Password") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - if(VERSION.SDK_INT >= 29) { - Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Column { - Text(stringResource(R.string.proxy), Modifier.padding(end = 8.dp)) - Text( - if(proxyAddress.isEmpty()) stringResource(R.string.none) else "$proxyAddress:$proxyPort", - color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium - ) + Box { + OutlinedTextField( + apnTypes.filter { apnType and it.id == it.id }.joinToString { it.name }, {}, + Modifier.fillMaxWidth(), + readOnly = true, label = { Text("APN type") } + ) + Box( + Modifier.matchParentSize().pointerInput(Unit) { + detectTapGestures(onTap = { menu = ApnMenu.ApnType }) } - TextButton({ dialog = 1 }) { Text(stringResource(R.string.edit)) } - } - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column { - Text(stringResource(R.string.mms_proxy), Modifier.padding(end = 8.dp)) - Text( - if(mmsProxyAddress.isEmpty()) stringResource(R.string.none) else "$mmsProxyAddress:$mmsProxyPort", - color = MaterialTheme.colorScheme.onSurfaceVariant, style = typography.bodyMedium - ) - } - TextButton({ dialog = 2 }) { Text(stringResource(R.string.edit)) } - } + ) } OutlinedTextField( mmsc, { mmsc = it }, Modifier.fillMaxWidth(), label = { Text("MMSC") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - if(VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween) { - val fr = FocusRequester() + OutlinedTextField( + mmsProxy, { mmsProxy = it }, Modifier.fillMaxWidth(), + label = { Text("MMS proxy") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + mmsPort, { mmsPort = it }, Modifier.fillMaxWidth(), + label = { Text("MMS port") }, + isError = mmsPort.isNotEmpty() && mmsPort.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + ) + ExposedDropdownMenuBox( + menu == ApnMenu.AuthType, { menu = if (it) ApnMenu.AuthType else ApnMenu.None } + ) { + OutlinedTextField( + authType.text, {}, Modifier.fillMaxWidth(), + label = { Text("Authentication type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.AuthType) } + ) + ExposedDropdownMenu(menu == ApnMenu.AuthType, { menu = ApnMenu.None }) { + ApnAuthType.entries.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + authType = it + menu = ApnMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == ApnMenu.Protocol, { menu = if (it) ApnMenu.Protocol else ApnMenu.None } + ) { + OutlinedTextField( + protocol.text, {}, Modifier.fillMaxWidth(), + label = { Text("APN protocol") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.Protocol) } + ) + ExposedDropdownMenu(menu == ApnMenu.Protocol, { menu = ApnMenu.None }) { + ApnProtocol.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + protocol = it + menu = ApnMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == ApnMenu.RoamingProtocol, + { menu = if (it) ApnMenu.RoamingProtocol else ApnMenu.None } + ) { + OutlinedTextField( + roamingProtocol.text, {}, Modifier.fillMaxWidth(), + label = { Text("APN roaming protocol") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol) + } + ) + ExposedDropdownMenu(menu == ApnMenu.RoamingProtocol, { menu = ApnMenu.None }) { + ApnProtocol.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + roamingProtocol = it + menu = ApnMenu.None + } + ) + } + } + } + Box { + OutlinedTextField( + apnNetworkTypes.filter { networkType and it.id == it.id }.joinToString { it.text }, {}, + Modifier.fillMaxWidth(), + readOnly = true, label = { Text("Network type") } + ) + Box( + Modifier.matchParentSize().pointerInput(Unit) { + detectTapGestures(onTap = { menu = ApnMenu.NetworkType }) + } + ) + } + if (VERSION.SDK_INT >= 33) OutlinedTextField( + profileId, { profileId = it }, Modifier.fillMaxWidth(), + label = { Text("Profile id") }, + isError = profileId.isNotEmpty() && profileId.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 29) OutlinedTextField( + carrierId, { carrierId = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + label = { Text("Carrier id") }, + isError = carrierId.isNotEmpty() && carrierId.toIntOrNull() == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween) { OutlinedTextField( mtuV4, { mtuV4 = it }, Modifier.fillMaxWidth(0.49F), label = { Text("MTU (IPv4)") }, isError = mtuV4.isNotEmpty() && mtuV4.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() } + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) ) OutlinedTextField( - mtuV6, { mtuV6 = it }, Modifier.focusRequester(fr).fillMaxWidth(0.96F), + mtuV6, { mtuV6 = it }, Modifier.fillMaxWidth(0.96F), label = { Text("MTU (IPv6)") }, isError = mtuV6.isNotEmpty() && mtuV6.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) ) } - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 2) 180F else 0F) - val mvnoTypeMap = mapOf( - ApnSetting.MVNO_TYPE_SPN to "SPM", ApnSetting.MVNO_TYPE_IMSI to "IMSI", - ApnSetting.MVNO_TYPE_GID to "GID", ApnSetting.MVNO_TYPE_ICCID to "ICCID" + ExposedDropdownMenuBox( + menu == ApnMenu.MvnoType, { menu = if (it) ApnMenu.MvnoType else ApnMenu.None } + ) { + OutlinedTextField( + mvnoType.text, {}, + Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("MVNO type") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol) + } ) - Text(stringResource(R.string.mvno_type)) - Row(Modifier.clickable { dropdown = 2 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(mvnoTypeMap[mvnoType]!!, Modifier.padding(4.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 2, { dropdown = 0 }) { - mvnoTypeMap.forEach { - DropdownMenuItem({ Text(it.value) }, { mvnoType = it.key; dropdown = 0 }) - } + ExposedDropdownMenu(menu == ApnMenu.MvnoType, { menu = ApnMenu.None }) { + ApnMvnoType.entries.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + mvnoType = it + menu = ApnMenu.None + } + ) } } } - OutlinedTextField( - networkTypeBitmask, { networkTypeBitmask = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.network_type_bitmask)) }, - isError = networkTypeBitmask.isNotEmpty() && networkTypeBitmask.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) - OutlinedTextField( - operatorNumeric, { operatorNumeric = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text("Numeric operator ID") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 3) 180F else 0F) - Text(stringResource(R.string.protocol)) - Row(Modifier.clickable { dropdown = 3 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(protocolMap[protocol]!!, Modifier.padding(2.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 3, { dropdown = 0 }) { - protocolMap.forEach { - DropdownMenuItem({ Text(it.value) }, { protocol = it.key; dropdown = 0 }) - } + ExposedDropdownMenuBox( + menu == ApnMenu.OperatorNumeric, + { menu = if (it) ApnMenu.OperatorNumeric else ApnMenu.None } + ) { + OutlinedTextField( + operatorNumeric, {}, + Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("Numeric operator ID") } + ) + ExposedDropdownMenu(menu == ApnMenu.OperatorNumeric, { menu = ApnMenu.None }) { + listOf(Telephony.Carriers.MCC, Telephony.Carriers.MNC).forEach { + DropdownMenuItem({ Text(it) }, { + operatorNumeric = it + menu = ApnMenu.None + }) } } } - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - val rotate by animateFloatAsState(if(dropdown == 4) 180F else 0F) - Text(stringResource(R.string.roaming_protocol)) - Row(Modifier.clickable { dropdown = 4 }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(protocolMap[roamingProtocol]!!, Modifier.padding(2.dp)) - Icon(Icons.Default.ArrowDropDown, null, Modifier.padding(start = 4.dp).rotate(rotate)) - DropdownMenu(dropdown == 4, { dropdown = 0 }) { - protocolMap.forEach { - DropdownMenuItem({ Text(it.value) }, { roamingProtocol = it.key; dropdown = 0 }) - } - } - } - } - if(VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.persistent)) + if (VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Persistent") Switch(persistent, { persistent = it }) } Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.always_on)) + Text("Always on") Switch(alwaysOn, { alwaysOn = it }) } Button( { - try { - val setting = ApnSetting.Builder().apply { - setCarrierEnabled(enabled) - setApnName(apnName) - setEntryName(entryName) - setApnTypeBitmask(apnType) - setAuthType(authType) - setUser(user) - setPassword(password) - if(VERSION.SDK_INT >= 33) profileId.toIntOrNull()?.let { setProfileId(it) } - if(VERSION.SDK_INT >= 29) { - carrierId.toIntOrNull()?.let { setCarrierId(it) } - setProxyAddress(proxyAddress) - proxyPort.toIntOrNull()?.let { setProxyPort(it) } - setMmsProxyAddress(mmsProxyAddress) - mmsProxyPort.toIntOrNull()?.let { setMmsProxyPort(it) } - } - setMmsc(mmsc.toUri()) - if(VERSION.SDK_INT >= 33) { - mtuV4.toIntOrNull()?.let { setMtuV4(it) } - mtuV6.toIntOrNull()?.let { setMtuV6(it) } - } - setMvnoType(mvnoType) - networkTypeBitmask.toIntOrNull()?.let { setNetworkTypeBitmask(it) } - setOperatorNumeric(operatorNumeric) - setProtocol(protocol) - setRoamingProtocol(roamingProtocol) - if(VERSION.SDK_INT >= 33) setPersistent(persistent) - if(VERSION.SDK_INT >= 35) setAlwaysOn(alwaysOn) - }.build() - if(origin == null) { - Privilege.DPM.addOverrideApn(Privilege.DAR, setting) - } else { - Privilege.DPM.updateOverrideApn(Privilege.DAR, origin.id, setting) - } - onNavigateUp() - } catch(e: Exception) { - errorMessage = (e::class.qualifiedName ?: "") + "\n" + (e.message ?: "") - } + val result = setApn(ApnConfig( + enabled, entryName, apnName, proxy, port.toIntOrNull(), user, password, apnType, + mmsc, mmsProxy, mmsPort.toIntOrNull(), authType, protocol, roamingProtocol, + networkType, profileId.toIntOrNull(), carrierId.toIntOrNull(), + mtuV4.toIntOrNull(), mtuV6.toIntOrNull(), mvnoType, + operatorNumeric, persistent, alwaysOn + )) + context.showOperationResultToast(result) + if (result) onNavigateUp() }, Modifier.fillMaxWidth().padding(vertical = 4.dp) ) { Text(stringResource(if(origin != null) R.string.update else R.string.add)) } - if(origin != null) Button( + if (origin != null) Button( { - Privilege.DPM.removeOverrideApn(Privilege.DAR, origin.id) - onNavigateUp() + val result = deleteApn(origin.id) + context.showOperationResultToast(result) + if (result) onNavigateUp() }, Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError) ) { Text(stringResource(R.string.delete)) } - if(dialog != 0) { - var address by remember { mutableStateOf((if(dialog == 1) proxyAddress else mmsProxyAddress)) } - var port by remember { mutableStateOf((if(dialog == 1) proxyPort else mmsProxyPort)) } - val fr = FocusRequester() - AlertDialog( - title = { Text(if(dialog == 1) "Proxy" else "MMS proxy") }, - text = { - val focusManager = LocalFocusManager.current - Column { - OutlinedTextField( - address, { address = it }, Modifier.fillMaxWidth().padding(bottom = 4.dp), - textStyle = typography.bodyLarge, - label = { Text(stringResource(R.string.address)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() } - ) - OutlinedTextField( - port, { port = it }, Modifier.fillMaxWidth().focusRequester(fr), - textStyle = typography.bodyLarge, - isError = port.isNotEmpty() && port.toIntOrNull() == null, - label = { Text(stringResource(R.string.port)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusManager.clearFocus() } - ) - } - }, - confirmButton = { - TextButton( - { - if(dialog == 1) { - proxyAddress = address - proxyPort = port - } else { - mmsProxyAddress = address - mmsProxyPort = port - } - dialog = 0 - } - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } - }, - onDismissRequest = { dialog = 0 } - ) - } if(errorMessage != null) AlertDialog( title = { Text(stringResource(R.string.error)) }, text = { Text(errorMessage ?: "") }, @@ -2260,4 +2186,56 @@ fun AddApnSettingScreen(origin: ApnSetting?, onNavigateUp: () -> Unit) { onDismissRequest = { errorMessage = null } ) } + if (menu == ApnMenu.ApnType) AlertDialog( + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + apnTypes.forEach { type -> + val checked = apnType and type.id == type.id + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { + apnType = if (checked) apnType and type.id.inv() + else apnType or type.id + }.padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked, null) + Text(type.name, Modifier.padding(start = 8.dp), style = MaterialTheme.typography.bodyLarge) + } + } + } + }, + confirmButton = { + TextButton({ menu = ApnMenu.None }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { menu = ApnMenu.None } + ) + if (menu == ApnMenu.NetworkType) AlertDialog( + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + apnNetworkTypes.forEach { type -> + val checked = type.id and networkType == type.id + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { + networkType = if (checked) networkType and type.id.inv() + else networkType or type.id + }.padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked, null) + Text(type.text, Modifier.padding(start = 6.dp), style = MaterialTheme.typography.bodyLarge) + } + } + } + }, + confirmButton = { + TextButton({ menu = ApnMenu.None }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { + menu = ApnMenu.None + } + ) } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt index 42d0e78..fea9381 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -1,23 +1,8 @@ package com.bintianqi.owndroid.dpm import android.annotation.SuppressLint -import android.app.KeyguardManager -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_BIOMETRICS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FACE -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_FINGERPRINT -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_IRIS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_SECURE_CAMERA -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_SECURE_NOTIFICATIONS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_SHORTCUTS_ALL -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_TRUST_AGENTS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS -import android.app.admin.DevicePolicyManager.KEYGUARD_DISABLE_WIDGETS_ALL -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_LOW -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM -import android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_NONE +import android.app.Activity +import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK @@ -28,21 +13,25 @@ import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED import android.app.admin.DevicePolicyManager.RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT import android.app.admin.DevicePolicyManager.RESET_PASSWORD_REQUIRE_ENTRY import android.content.Context +import android.content.Intent import android.os.Build.VERSION import android.os.UserManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions 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 @@ -54,21 +43,24 @@ 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.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat.startActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP +import com.bintianqi.owndroid.generateBase64Key import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -86,13 +78,13 @@ import kotlinx.serialization.Serializable @SuppressLint("NewApi") @Composable -fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun PasswordScreen(vm: MyViewModel,onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.password_and_keyguard, onNavigateUp, 0.dp) { FunctionItem(R.string.password_info, icon = R.drawable.info_fill0) { onNavigate(PasswordInfo) } - if(SP.displayDangerousFeatures) { + if (SP.displayDangerousFeatures) { if(VERSION.SDK_INT >= 26) { FunctionItem(R.string.reset_password_token, icon = R.drawable.key_vertical_fill0) { onNavigate(ResetPasswordToken) } } @@ -118,14 +110,14 @@ fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(dialog != 0) { var input by remember { mutableStateOf("") } LaunchedEffect(Unit) { - input = when(dialog) { - 1 -> Privilege.DPM.getMaximumTimeToLock(Privilege.DAR).toString() - 2 -> Privilege.DPM.getRequiredStrongAuthTimeout(Privilege.DAR).toString() - 3 -> Privilege.DPM.getPasswordExpirationTimeout(Privilege.DAR).toString() - 4 -> Privilege.DPM.getMaximumFailedPasswordsForWipe(Privilege.DAR).toString() - 5 -> Privilege.DPM.getPasswordHistoryLength(Privilege.DAR).toString() - else -> "" - } + input = when (dialog) { + 1 -> vm.getMaxTimeToLock() + 2 -> vm.getRequiredStrongAuthTimeout() + 3 -> vm.getPasswordExpirationTimeout() + 4 -> vm.getMaxFailedPasswordsForWipe() + 5 -> vm.getPasswordHistoryLength() + else -> 0 + }.toString() } AlertDialog( title = { @@ -178,14 +170,15 @@ fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { TextButton( onClick = { when(dialog) { - 1 -> Privilege.DPM.setMaximumTimeToLock(Privilege.DAR, input.toLong()) - 2 -> Privilege.DPM.setRequiredStrongAuthTimeout(Privilege.DAR, input.toLong()) - 3 -> Privilege.DPM.setPasswordExpirationTimeout(Privilege.DAR, input.toLong()) - 4 -> Privilege.DPM.setMaximumFailedPasswordsForWipe(Privilege.DAR, input.toInt()) - 5 -> Privilege.DPM.setPasswordHistoryLength(Privilege.DAR, input.toInt()) + 1 -> vm.setMaxTimeToLock(input.toLong()) + 2 -> vm.setRequiredStrongAuthTimeout(input.toLong()) + 3 -> vm.setPasswordExpirationTimeout(input.toLong()) + 4 -> vm.setMaxFailedPasswordsForWipe(input.toInt()) + 5 -> vm.setPasswordHistoryLength(input.toInt()) } dialog = 0 - } + }, + enabled = input.toLongOrNull() != null ) { Text(stringResource(R.string.apply)) } @@ -202,26 +195,30 @@ fun PasswordScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } } +@RequiresApi(29) +enum class PasswordComplexity(val id: Int, val text: Int) { + None(DevicePolicyManager.PASSWORD_COMPLEXITY_NONE, R.string.none), + Low(DevicePolicyManager.PASSWORD_COMPLEXITY_LOW, R.string.low), + Medium(DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM, R.string.medium), + High(DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH, R.string.high) +} + @Serializable object PasswordInfo @Composable -fun PasswordInfoScreen(onNavigateUp: () -> Unit) { +fun PasswordInfoScreen( + getComplexity: () -> PasswordComplexity, isSufficient: () -> Boolean, isUnified: () -> Boolean, + onNavigateUp: () -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } // 0:none, 1:password complexity + var dialog by rememberSaveable { mutableIntStateOf(0) } // 0:none, 1:password complexity MyScaffold(R.string.password_info, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 29) { - val text = when(Privilege.DPM.passwordComplexity) { - PASSWORD_COMPLEXITY_NONE -> R.string.none - PASSWORD_COMPLEXITY_LOW -> R.string.low - PASSWORD_COMPLEXITY_MEDIUM -> R.string.medium - PASSWORD_COMPLEXITY_HIGH -> R.string.high - else -> R.string.unknown - } - InfoItem(R.string.current_password_complexity, text, true) { dialog = 1 } + if (VERSION.SDK_INT >= 31) { + InfoItem(R.string.current_password_complexity, getComplexity().text, true) { dialog = 1 } } - InfoItem(R.string.password_sufficient, Privilege.DPM.isActivePasswordSufficient.yesOrNo) + InfoItem(R.string.password_sufficient, isSufficient().yesOrNo) if(VERSION.SDK_INT >= 28 && privilege.work) { - InfoItem(R.string.unified_password, Privilege.DPM.isUsingUnifiedPassword(Privilege.DAR).yesOrNo) + InfoItem(R.string.unified_password, isUnified().yesOrNo) } } if(dialog != 0) AlertDialog( @@ -235,62 +232,69 @@ fun PasswordInfoScreen(onNavigateUp: () -> Unit) { ) } +data class RpTokenState(val set: Boolean, val active: Boolean) + @Serializable object ResetPasswordToken @RequiresApi(26) @Composable -fun ResetPasswordTokenScreen(onNavigateUp: () -> Unit) { +fun ResetPasswordTokenScreen( + getState: () -> RpTokenState, setToken: (String) -> Boolean, getIntent: () -> Intent?, + clearToken: () -> Boolean, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var token by remember { mutableStateOf("") } - val tokenByteArray = token.toByteArray() - val focusMgr = LocalFocusManager.current + var token by rememberSaveable { mutableStateOf("") } + var state by remember { mutableStateOf(getState()) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + context.popToast(R.string.token_activated) + state = getState() + } + } MyScaffold(R.string.reset_password_token, onNavigateUp) { OutlinedTextField( - value = token, onValueChange = { token = it }, + token, { token = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.token)) }, - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - supportingText = { - AnimatedVisibility(tokenByteArray.size < 32) { - Text(stringResource(R.string.token_must_longer_than_32_byte)) + supportingText = { Text("${token.length}/32") }, + trailingIcon = { + IconButton({ token = generateBase64Key(24) }) { + Icon(painterResource(R.drawable.casino_fill0), null) } - }, - modifier = Modifier.fillMaxWidth() + } ) Button( onClick = { - try { - context.showOperationResultToast(Privilege.DPM.setResetPasswordToken(Privilege.DAR, tokenByteArray)) - } catch(_:SecurityException) { - context.popToast(R.string.security_exception) - } + val result = setToken(token) + context.showOperationResultToast(result) + if (result) state = getState() }, modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - enabled = tokenByteArray.size >= 32 + enabled = token.length >= 32 ) { Text(stringResource(R.string.set)) } - Row( - horizontalArrangement = Arrangement.SpaceBetween, + if (state.set && !state.active) Button( + onClick = { + val intent = getIntent() + if (intent == null) { + context.showOperationResultToast(false) + } else { + launcher.launch(intent) + } + }, modifier = Modifier.fillMaxWidth() ) { - Button( - onClick = { - if(!Privilege.DPM.isResetPasswordTokenActive(Privilege.DAR)) { - try { activateToken(context) } - catch(_:NullPointerException) { context.popToast(R.string.please_set_a_token) } - } else { context.popToast(R.string.token_already_activated) } - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.activate)) - } - Button( - onClick = { context.showOperationResultToast(Privilege.DPM.clearResetPasswordToken(Privilege.DAR)) }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.clear)) - } + Text(stringResource(R.string.activate)) + } + if (state.set) Button( + onClick = { + val result = clearToken() + context.showOperationResultToast(result) + state = getState() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.clear)) } Spacer(Modifier.padding(vertical = 5.dp)) Notes(R.string.activate_token_not_required_when_no_password) @@ -300,147 +304,81 @@ fun ResetPasswordTokenScreen(onNavigateUp: () -> Unit) { @Serializable object ResetPassword @Composable -fun ResetPasswordScreen(onNavigateUp: () -> Unit) { +fun ResetPasswordScreen(resetPassword: (String, String, Int) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current - val focusMgr = LocalFocusManager.current - var password by remember { mutableStateOf("") } - var useToken by remember { mutableStateOf(false) } - var token by remember { mutableStateOf("") } - val tokenByteArray = token.toByteArray() - var flag by remember { mutableIntStateOf(0) } - var confirmDialog by remember { mutableStateOf(false) } + var 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) { + if (VERSION.SDK_INT >= 26) { OutlinedTextField( - value = token, onValueChange = { token = it }, + token, { token = it }, Modifier.fillMaxWidth().padding(bottom = 5.dp), label = { Text(stringResource(R.string.token)) }, - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - modifier = Modifier.fillMaxWidth() + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) ) } OutlinedTextField( - value = password, - onValueChange = { password = it }, + password, { password = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.password)) }, + isError = password.length in 1..3, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + visualTransformation = PasswordVisualTransformation() + ) + OutlinedTextField( + confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + isError = confirmPassword != password, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - supportingText = { Text(stringResource(R.string.reset_pwd_desc)) }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() + visualTransformation = PasswordVisualTransformation() ) Spacer(Modifier.padding(vertical = 5.dp)) if(VERSION.SDK_INT >= 23) { CheckBoxItem( R.string.do_not_ask_credentials_on_boot, - flag and RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT != 0 - ) { flag = flag xor RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT } + flags and RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT != 0 + ) { flags = flags xor RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT } } CheckBoxItem( R.string.reset_password_require_entry, - flag and RESET_PASSWORD_REQUIRE_ENTRY != 0 - ) { flag = flag xor RESET_PASSWORD_REQUIRE_ENTRY } + flags and RESET_PASSWORD_REQUIRE_ENTRY != 0 + ) { flags = flags xor RESET_PASSWORD_REQUIRE_ENTRY } Spacer(Modifier.padding(vertical = 5.dp)) - if(VERSION.SDK_INT >= 26) { - Button( - onClick = { - useToken = true - confirmDialog = true - focusMgr.clearFocus() - }, - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - enabled = tokenByteArray.size >=32 && password.length !in 1..3, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.reset_password_with_token)) - } - } - if(VERSION.SDK_INT <= 30) { - Button( - onClick = { - useToken = false - confirmDialog = true - focusMgr.clearFocus() - }, - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - enabled = password.length !in 1..3, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.reset_password)) - } + Button( + onClick = { + context.showOperationResultToast(resetPassword(password, token, flags)) + }, + colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError), + modifier = Modifier.fillMaxWidth(), + enabled = password == confirmPassword + ) { + Text(stringResource(R.string.reset_password)) } Notes(R.string.info_reset_password) } - if(confirmDialog) { - var confirmPassword by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { confirmDialog = false }, - title = { Text(stringResource(R.string.reset_password)) }, - text = { - val dialogFocusMgr = LocalFocusManager.current - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - label = { Text(stringResource(R.string.confirm_password)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { dialogFocusMgr.clearFocus() }), - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton( - onClick = { - val success = if(VERSION.SDK_INT >= 26 && useToken) { - Privilege.DPM.resetPasswordWithToken(Privilege.DAR, password, tokenByteArray, flag) - } else { - Privilege.DPM.resetPassword(password, flag) - } - context.showOperationResultToast(success) - password = "" - confirmDialog = false - }, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error), - enabled = confirmPassword == password - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton(onClick = { confirmDialog = false }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } } @Serializable object RequiredPasswordComplexity @RequiresApi(31) @Composable -fun RequiredPasswordComplexityScreen(onNavigateUp: () -> Unit) { +fun RequiredPasswordComplexityScreen( + getComplexity: () -> PasswordComplexity, setComplexity: (PasswordComplexity) -> Unit, + onNavigateUp: () -> Unit +) { val context = LocalContext.current - val passwordComplexity = mapOf( - PASSWORD_COMPLEXITY_NONE to R.string.none, - PASSWORD_COMPLEXITY_LOW to R.string.low, - PASSWORD_COMPLEXITY_MEDIUM to R.string.medium, - PASSWORD_COMPLEXITY_HIGH to R.string.high - ) - var selectedItem by remember { mutableIntStateOf(PASSWORD_COMPLEXITY_NONE) } - LaunchedEffect(Unit) { selectedItem = Privilege.DPM.requiredPasswordComplexity } + var complexity by rememberSaveable { mutableStateOf(PasswordComplexity.None) } + LaunchedEffect(Unit) { complexity = getComplexity() } MyScaffold(R.string.required_password_complexity, onNavigateUp, 0.dp) { - passwordComplexity.forEach { - FullWidthRadioButtonItem(it.value, selectedItem == it.key) { selectedItem = it.key } + PasswordComplexity.entries.forEach { + FullWidthRadioButtonItem(it.text, complexity == it) { complexity = it } } - Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - Privilege.DPM.requiredPasswordComplexity = selectedItem - selectedItem = Privilege.DPM.requiredPasswordComplexity + setComplexity(complexity) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) ) { Text(text = stringResource(R.string.apply)) } @@ -448,58 +386,63 @@ fun RequiredPasswordComplexityScreen(onNavigateUp: () -> Unit) { } } +data class KeyguardDisabledFeature(val id: Int, val text: Int, val requiresApi: Int = 0) +@Suppress("InlinedApi") +val keyguardDisabledFeatures = listOf( + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_WIDGETS_ALL, R.string.disable_keyguard_features_widgets), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_SECURE_CAMERA, R.string.disable_keyguard_features_camera), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_SECURE_NOTIFICATIONS, R.string.disable_keyguard_features_notification), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS, R.string.disable_keyguard_features_unredacted_notification), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_TRUST_AGENTS, R.string.disable_keyguard_features_trust_agents), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_FINGERPRINT, R.string.disable_keyguard_features_fingerprint), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_FACE, R.string.disable_keyguard_features_face, 28), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_IRIS, R.string.disable_keyguard_features_iris, 28), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_BIOMETRICS, R.string.disable_keyguard_features_biometrics, 28), + KeyguardDisabledFeature(DevicePolicyManager.KEYGUARD_DISABLE_SHORTCUTS_ALL, R.string.disable_keyguard_features_shortcuts, 34) +).filter { VERSION.SDK_INT >= it.requiresApi } + +enum class KeyguardDisableMode(val text: Int) { + None(R.string.enable_all), Custom(R.string.custom), All(R.string.disable_all) +} + +data class KeyguardDisableConfig(val mode: KeyguardDisableMode, val flags: Int) + + @Serializable object KeyguardDisabledFeatures @Composable -fun KeyguardDisabledFeaturesScreen(onNavigateUp: () -> Unit) { +fun KeyguardDisabledFeaturesScreen( + getConfig: () -> KeyguardDisableConfig, setConfig: (KeyguardDisableConfig) -> Unit, + onNavigateUp: () -> Unit +) { val context = LocalContext.current - var flag by remember { mutableIntStateOf(0) } - var mode by remember { mutableIntStateOf(0) } // 0:Enable all, 1:Disable all, 2:Custom - val flagsLiat = mutableListOf( - R.string.disable_keyguard_features_widgets to KEYGUARD_DISABLE_WIDGETS_ALL, - R.string.disable_keyguard_features_camera to KEYGUARD_DISABLE_SECURE_CAMERA, - R.string.disable_keyguard_features_notification to KEYGUARD_DISABLE_SECURE_NOTIFICATIONS, - R.string.disable_keyguard_features_unredacted_notification to KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS, - R.string.disable_keyguard_features_trust_agents to KEYGUARD_DISABLE_TRUST_AGENTS, - R.string.disable_keyguard_features_fingerprint to KEYGUARD_DISABLE_FINGERPRINT - ) - if(VERSION.SDK_INT >= 28) { - flagsLiat +=R.string.disable_keyguard_features_face to KEYGUARD_DISABLE_FACE - flagsLiat += R.string.disable_keyguard_features_iris to KEYGUARD_DISABLE_IRIS - flagsLiat += R.string.disable_keyguard_features_biometrics to KEYGUARD_DISABLE_BIOMETRICS + var mode by rememberSaveable { mutableStateOf(KeyguardDisableMode.None) } + var flags by rememberSaveable { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + val config = getConfig() + mode = config.mode + flags = config.flags } - if(VERSION.SDK_INT >= 34) flagsLiat += R.string.disable_keyguard_features_shortcuts to KEYGUARD_DISABLE_SHORTCUTS_ALL - fun refresh() { - flag = Privilege.DPM.getKeyguardDisabledFeatures(Privilege.DAR) - mode = when(flag) { - KEYGUARD_DISABLE_FEATURES_NONE -> 0 - KEYGUARD_DISABLE_FEATURES_ALL -> 1 - else -> 2 - } - } - LaunchedEffect(mode) { if(mode != 2) flag = Privilege.DPM.getKeyguardDisabledFeatures(Privilege.DAR) } - LaunchedEffect(Unit) { refresh() } MyScaffold(R.string.disable_keyguard_features, onNavigateUp) { - FullWidthRadioButtonItem(R.string.enable_all, mode == 0) { mode = 0 } - FullWidthRadioButtonItem(R.string.disable_all, mode == 1) { mode = 1 } - FullWidthRadioButtonItem(R.string.custom, mode == 2) { mode = 2 } - AnimatedVisibility(mode == 2) { + KeyguardDisableMode.entries.forEach { + FullWidthRadioButtonItem(it.text, mode == it) { mode = it } + } + Spacer(Modifier.height(8.dp)) + AnimatedVisibility(mode == KeyguardDisableMode.Custom) { Column { - flagsLiat.forEach { - FullWidthCheckBoxItem(it.first, flag and it.second == it.second) { checked -> - flag = if(checked) flag or it.second else flag and (flag xor it.second) + keyguardDisabledFeatures.forEach { + FullWidthCheckBoxItem(it.text, flags and it.id == it.id) { checked -> + flags = flags xor it.id } } } } Button( onClick = { - val disabledFeatures = if(mode == 0) KEYGUARD_DISABLE_FEATURES_NONE else if(mode == 1) KEYGUARD_DISABLE_FEATURES_ALL else flag - Privilege.DPM.setKeyguardDisabledFeatures(Privilege.DAR, disabledFeatures) - refresh() + setConfig(KeyguardDisableConfig(mode, flags)) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) ) { Text(text = stringResource(R.string.apply)) } @@ -520,7 +463,7 @@ fun RequiredPasswordQualityScreen(onNavigateUp: () -> Unit) { PASSWORD_QUALITY_BIOMETRIC_WEAK to R.string.password_quality_biometrics_weak, PASSWORD_QUALITY_NUMERIC_COMPLEX to R.string.password_quality_numeric_complex ) - var selectedItem by remember { mutableIntStateOf(PASSWORD_QUALITY_UNSPECIFIED) } + var selectedItem by rememberSaveable { mutableIntStateOf(PASSWORD_QUALITY_UNSPECIFIED) } LaunchedEffect(Unit) { selectedItem = Privilege.DPM.getPasswordQuality(Privilege.DAR) } MyScaffold(R.string.required_password_quality, onNavigateUp) { passwordQuality.forEach { @@ -538,14 +481,3 @@ fun RequiredPasswordQualityScreen(onNavigateUp: () -> Unit) { } } } - -private fun activateToken(context: Context) { - val desc = context.getString(R.string.activate_reset_password_token_here) - val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - val confirmIntent = keyguardManager.createConfirmDeviceCredentialIntent(context.getString(R.string.app_name), desc) - if (confirmIntent != null) { - startActivity(context, confirmIntent, null) - } else { - context.showOperationResultToast(false) - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt index fb4c6cf..059071d 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt @@ -2,14 +2,8 @@ package com.bintianqi.owndroid.dpm import android.app.admin.DevicePolicyManager import android.content.ComponentName -import android.content.Context -import android.content.pm.PackageManager import android.os.Build.VERSION -import android.os.PersistableBundle -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.annotation.Keep import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image @@ -22,12 +16,12 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +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.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions @@ -58,6 +52,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -68,10 +63,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -84,20 +78,18 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ChoosePackageContract -import com.bintianqi.owndroid.DHIZUKU_CLIENTS_FILE +import com.bintianqi.owndroid.AppInfo +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.DhizukuClientInfo import com.bintianqi.owndroid.DhizukuPermissions import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.IUserService -import com.bintianqi.owndroid.MyAdminComponent +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.SP import com.bintianqi.owndroid.Settings +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.InfoItem @@ -107,36 +99,32 @@ import com.bintianqi.owndroid.ui.MySmallTitleScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem -import com.bintianqi.owndroid.useShizuku import com.bintianqi.owndroid.yesOrNo import com.google.accompanist.drawablepainter.rememberDrawablePainter -import com.rosan.dhizuku.api.Dhizuku -import com.rosan.dhizuku.api.DhizukuRequestPermissionListener -import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json @Serializable data class WorkModes(val canNavigateUp: Boolean) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun WorkModesScreen( - params: WorkModes, onNavigateUp: () -> Unit, onActivate: () -> Unit, onNavigate: (Any) -> Unit + vm: MyViewModel, params: WorkModes, onNavigateUp: () -> Unit, onActivate: () -> Unit, + onDeactivate: () -> Unit, onNavigate: (Any) -> Unit ) { - val context = LocalContext.current - val coroutine = rememberCoroutineScope() val privilege by Privilege.status.collectAsStateWithLifecycle() /** 0: none, 1: device owner, 2: circular progress indicator, 3: result, 4: deactivate, 5: command */ - var dialog by remember { mutableIntStateOf(0) } - var operationSucceed by remember { mutableStateOf(false) } + var 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 } } @@ -194,92 +182,38 @@ fun WorkModesScreen( } ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> - var navigateUpOnSucceed by remember { mutableStateOf(true) } - var resultText by remember { mutableStateOf("") } - fun handleResult(succeeded: Boolean, activateSucceeded: Boolean, output: String?) { - if(succeeded) { - operationSucceed = activateSucceeded - resultText = output ?: "" - dialog = 3 - Privilege.updateStatus() - } else { - dialog = 0 - context.showOperationResultToast(false) - } + fun handleResult(succeeded: Boolean, output: String?) { + operationSucceed = succeeded + resultText = output ?: "" + dialog = 3 } - Column(Modifier - .fillMaxSize() - .padding(paddingValues)) { - if(!privilege.profile && (VERSION.SDK_INT >= 28 || !privilege.dhizuku)) Row( - Modifier - .fillMaxWidth() - .clickable(!privilege.device || privilege.dhizuku) { dialog = 1 } - .background(if (privilege.device) colorScheme.primaryContainer else Color.Transparent) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Column { - Text(stringResource(R.string.device_owner), style = typography.titleLarge) - if(!privilege.device || privilege.dhizuku) Text( - stringResource(R.string.recommended), color = colorScheme.primary, style = typography.labelLarge - ) - } - Icon( - if(privilege.device) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, - tint = if(privilege.device) colorScheme.primary else colorScheme.onBackground - ) - } - if(privilege.profile) Row( - Modifier - .fillMaxWidth() - .background(colorScheme.primaryContainer) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Column { - Text(stringResource(R.string.profile_owner), style = typography.titleLarge) - } - Icon(Icons.Default.Check, null, tint = colorScheme.primary) - } - if(privilege.dhizuku || !(privilege.device || privilege.profile)) Row( - Modifier - .fillMaxWidth() - .clickable(!privilege.dhizuku) { - dialog = 2 - activateDhizukuMode(context, ::handleResult) + Column(Modifier.fillMaxSize().padding(paddingValues)) { + if (!privilege.profile) { + WorkingModeItem(R.string.device_owner, privilege.device) { + if (!privilege.device || (VERSION.SDK_INT >= 28 && privilege.dhizuku)) { + dialog = 1 } - .background(if (privilege.dhizuku) colorScheme.primaryContainer else Color.Transparent) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Text(stringResource(R.string.dhizuku), style = typography.titleLarge) - Icon( - if(privilege.dhizuku) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, - tint = if(privilege.dhizuku) colorScheme.primary else colorScheme.onBackground - ) + } + } + if (privilege.profile) WorkingModeItem(R.string.profile_owner, true) { } + if (privilege.dhizuku || !privilege.activated) { + WorkingModeItem(R.string.dhizuku, privilege.dhizuku) { + if (!privilege.dhizuku) { + dialog = 2 + vm.activateDhizukuMode(::handleResult) + } + } } if( - privilege.work || (VERSION.SDK_INT < 24 || - Privilege.DPM.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE)) - ) Row( - Modifier - .fillMaxWidth() - .clickable(!privilege.work) { onNavigate(CreateWorkProfile) } - .background(if (privilege.device) colorScheme.primaryContainer else Color.Transparent) - .padding(HorizontalPadding, 10.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically + privilege.work || (VERSION.SDK_INT < 24 || vm.isCreatingWorkProfileAllowed()) ) { - Column { - Text(stringResource(R.string.work_profile), style = typography.titleLarge) + WorkingModeItem(R.string.work_profile, privilege.work) { + if (!privilege.work) onNavigate(CreateWorkProfile) } - Icon( - if(privilege.work) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, - tint = if(privilege.device) colorScheme.primary else colorScheme.onBackground - ) } - if ((privilege.device || privilege.profile) && !privilege.dhizuku) Row( + if (privilege.activated && !privilege.dhizuku) Row( Modifier .padding(top = 20.dp) .fillMaxWidth() @@ -305,27 +239,29 @@ fun WorkModesScreen( title = { Text(stringResource(R.string.activate_method)) }, text = { FlowRow(Modifier.fillMaxWidth()) { - if(!privilege.dhizuku) Button({ - dialog = 2 - coroutine.launch { - activateUsingShizuku(context, ::handleResult) + if (!privilege.dhizuku) { + Button({ dialog = 5 }, Modifier.padding(end = 8.dp)) { + Text(stringResource(R.string.adb_command)) + } + Button({ + dialog = 2 + vm.activateDoByShizuku(::handleResult) + }, Modifier.padding(end = 8.dp)) { + Text(stringResource(R.string.shizuku)) + } + Button({ + dialog = 2 + vm.activateDoByRoot(::handleResult) + }, Modifier.padding(end = 8.dp)) { + Text("Root") } - }, Modifier.padding(end = 8.dp)) { - Text(stringResource(R.string.shizuku)) } - if(!privilege.dhizuku) Button({ + if (VERSION.SDK_INT >= 28 && privilege.dhizuku) Button({ dialog = 2 - activateUsingRoot(context, ::handleResult) - }, Modifier.padding(end = 8.dp)) { - Text("Root") - } - if(VERSION.SDK_INT >= 28) Button({ - dialog = 2 - activateUsingDhizuku(context, ::handleResult) + vm.activateDoByDhizuku(::handleResult) }, Modifier.padding(end = 8.dp)) { Text(stringResource(R.string.dhizuku)) } - if (!privilege.dhizuku) Button({ dialog = 5 }) { Text(stringResource(R.string.adb_command)) } } }, confirmButton = { @@ -337,16 +273,14 @@ fun WorkModesScreen( if(dialog == 3) AlertDialog( title = { Text(stringResource(if(operationSucceed) R.string.succeeded else R.string.failed)) }, text = { - Column(Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState())) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { Text(resultText) } }, confirmButton = { TextButton({ dialog = 0 - if(navigateUpOnSucceed && operationSucceed && !params.canNavigateUp) onActivate() + if (operationSucceed && !params.canNavigateUp) onActivate() }) { Text(stringResource(R.string.confirm)) } @@ -368,18 +302,17 @@ fun WorkModesScreen( TextButton( { if(privilege.dhizuku) { - SP.dhizuku = false - Privilege.initialize(context) - Privilege.updateStatus() + vm.deactivateDhizukuMode() } else { if(privilege.device) { - Privilege.DPM.clearDeviceOwnerApp(context.packageName) + vm.clearDeviceOwner() } else if(VERSION.SDK_INT >= 24) { - Privilege.DPM.clearProfileOwner(MyAdminComponent) + vm.clearProfileOwner() } // Status updated in Receiver.onDisabled() } dialog = 0 + onDeactivate() }, enabled = time == 0, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) @@ -406,94 +339,21 @@ fun WorkModesScreen( } } -fun activateUsingShizuku(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - useShizuku(context) { service -> - try { - val result = IUserService.Stub.asInterface(service).execute(ACTIVATE_DEVICE_OWNER_COMMAND) - if (result == null) { - callback(false, false, null) - } else { - callback( - true, result.getInt("code", -1) == 0, - result.getString("output") + "\n" + result.getString("error") - ) - } - } catch (e: Exception) { - callback(false, false, null) - e.printStackTrace() - } - } -} - -fun activateUsingRoot(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - Shell.getShell { shell -> - if(shell.isRoot) { - val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec() - val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n") - callback(true, result.isSuccess, output) - } else { - callback(true, false, context.getString(R.string.permission_denied)) - } - } -} - -@RequiresApi(28) -fun activateUsingDhizuku(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - fun doTransfer() { - try { - if (SP.dhizuku) { - Privilege.DPM.transferOwnership(Privilege.DAR, MyAdminComponent, PersistableBundle()) - SP.dhizuku = false - Privilege.initialize(context) - } else { - val dpm = binderWrapperDevicePolicyManager(context) - if (dpm == null) { - callback(false, false, null) - return - } else { - dpm.transferOwnership(Dhizuku.getOwnerComponent(), MyAdminComponent, PersistableBundle()) - } - } - callback(true, true, null) - } catch (e: Exception) { - e.printStackTrace() - callback(false, false, null) - } - } - if(Dhizuku.init(context)) { - if(Dhizuku.isPermissionGranted()) { - doTransfer() - } else { - Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { - override fun onRequestPermission(grantResult: Int) { - if(grantResult == PackageManager.PERMISSION_GRANTED) doTransfer() - else callback(false, false, null) - } - }) - } - } else { - callback(true, false, context.getString(R.string.failed_to_init_dhizuku)) - } -} - -fun activateDhizukuMode(context: Context, callback: (Boolean, Boolean, String?) -> Unit) { - fun onSucceed() { - SP.dhizuku = true - Privilege.initialize(context) - callback(true, true, null) - } - if(Dhizuku.init(context)) { - if(Dhizuku.isPermissionGranted()) { - onSucceed() - } else { - Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { - override fun onRequestPermission(grantResult: Int) { - if(grantResult == PackageManager.PERMISSION_GRANTED) onSucceed() - } - }) - } - } else { - callback(true, false, context.getString(R.string.failed_to_init_dhizuku)) +@Composable +fun WorkingModeItem(text: Int, active: Boolean, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(if (active) colorScheme.primaryContainer else Color.Transparent) + .padding(HorizontalPadding, 10.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(text), style = typography.titleLarge) + Icon( + if(active) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, null, + tint = if(active) colorScheme.primary else colorScheme.onBackground + ) } } @@ -502,98 +362,81 @@ const val ACTIVATE_DEVICE_OWNER_COMMAND = "dpm set-device-owner com.bintianqi.ow @Serializable object DhizukuServerSettings @Composable -fun DhizukuServerSettingsScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val pm = context.packageManager - val file = context.filesDir.resolve(DHIZUKU_CLIENTS_FILE) - var enabled by remember { mutableStateOf(SP.dhizukuServer) } - val clients = remember { mutableStateListOf() } - fun changeEnableState(status: Boolean) { - enabled = status - SP.dhizukuServer = status - } - fun writeList() { - file.writeText(Json.encodeToString(clients.toList())) - } - LaunchedEffect(Unit) { - if (!file.exists()) file.writeText("[]") - } - LaunchedEffect(enabled) { - if (enabled) { - clients.clear() - val json = Json { ignoreUnknownKeys = true } - clients.addAll(json.decodeFromString>(file.readText())) - } - } +fun DhizukuServerSettingsScreen( + dhizukuClients: StateFlow>>, + getDhizukuClients: () -> Unit, updateDhizukuClient: (DhizukuClientInfo) -> Unit, + getServerEnabled: () -> Boolean, setServerEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { + var enabled by rememberSaveable { mutableStateOf(getServerEnabled()) } + val clients by dhizukuClients.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getDhizukuClients() } MyLazyScaffold(R.string.dhizuku_server, onNavigateUp) { item { - SwitchItem(R.string.enable, getState = { SP.dhizukuServer }, onCheckedChange = ::changeEnableState) + SwitchItem(R.string.enable, enabled, { + setServerEnabled(it) + enabled = it + }) HorizontalDivider(Modifier.padding(vertical = 8.dp)) } - if (enabled) itemsIndexed(clients) { index, client -> - val name = pm.getNameForUid(client.uid) - if (name == null) { - clients.dropWhile { it.uid == client.uid } - writeList() - } else { - val info = pm.getApplicationInfo(name, 0) - var expand by remember { mutableStateOf(false) } - Card( + if (enabled) items(clients) { (client, app) -> + var expand by remember { mutableStateOf(false) } + Card( + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp) + ) { + Row( Modifier .fillMaxWidth() - .padding(HorizontalPadding, 8.dp) + .padding(8.dp, 8.dp, 0.dp, 8.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Row( - Modifier - .fillMaxWidth() - .padding(8.dp, 8.dp, 0.dp, 8.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - rememberDrawablePainter(info.loadIcon(pm)), null, - Modifier - .padding(end = 16.dp) - .size(50.dp) - ) - Column { - Text(info.loadLabel(pm).toString(), style = typography.titleLarge) - Text(name, Modifier.alpha(0.7F), style = typography.bodyMedium) - } - } - val ts = when (DhizukuPermissions.filter { it !in client.permissions }.size) { - 0 -> ToggleableState.On - DhizukuPermissions.size -> ToggleableState.Off - else -> ToggleableState.Indeterminate - } - Row(verticalAlignment = Alignment.CenterVertically) { - TriStateCheckbox(ts, { - clients[index] = when (ts) { - ToggleableState.On, ToggleableState.Indeterminate -> client.copy(permissions = emptyList()) - ToggleableState.Off -> client.copy(permissions = DhizukuPermissions) - } - }) - val degrees by animateFloatAsState(if(expand) 180F else 0F) - IconButton({ expand = !expand }) { - Icon(Icons.Default.ArrowDropDown, null, Modifier.rotate(degrees)) - } + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + rememberDrawablePainter(app.icon), null, + Modifier.padding(end = 16.dp).size(45.dp) + ) + Column { + Text(app.label, style = typography.titleMedium) + Text(app.name, Modifier.alpha(0.7F), style = typography.bodyMedium) } } - AnimatedVisibility(expand, Modifier.padding(8.dp, 0.dp, 8.dp, 8.dp)) { - Column { - mapOf( - "remote_transact" to "Remote transact", "remote_process" to "Remote process", - "user_service" to "User service", "delegated_scopes" to "Delegated scopes", - "other" to context.getString(R.string.other) - ).forEach { (k, v) -> - Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(v) - Checkbox(k in client.permissions, { - val newPermissions = if (it) client.permissions.plus(k) else client.permissions.minus(k) - clients[index] = client.copy(permissions = newPermissions) - writeList() - }) - } + val ts = when (DhizukuPermissions.filter { it !in client.permissions }.size) { + 0 -> ToggleableState.On + DhizukuPermissions.size -> ToggleableState.Off + else -> ToggleableState.Indeterminate + } + Row(verticalAlignment = Alignment.CenterVertically) { + TriStateCheckbox(ts, { + if (ts == ToggleableState.Off) { + updateDhizukuClient(client.copy(permissions = DhizukuPermissions)) + } else { + updateDhizukuClient(client.copy(permissions = emptyList())) + } + }) + val degrees by animateFloatAsState(if(expand) 180F else 0F) + IconButton({ expand = !expand }) { + Icon(Icons.Default.ArrowDropDown, null, Modifier.rotate(degrees)) + } + } + } + AnimatedVisibility(expand, Modifier.padding(8.dp, 0.dp, 8.dp, 8.dp)) { + Column { + mapOf( + "remote_transact" to "Remote transact", "remote_process" to "Remote process", + "user_service" to "User service", "delegated_scopes" to "Delegated scopes", + "other" to "Other" + ).forEach { (k, v) -> + Row( + Modifier.fillMaxWidth(), Arrangement.SpaceBetween, + Alignment.CenterVertically + ) { + Text(v) + Checkbox(k in client.permissions, { + updateDhizukuClient(client.copy( + permissions = client.permissions.run { if (it) plus(k) else minus(k) } + )) + }) } } } @@ -607,10 +450,12 @@ fun DhizukuServerSettingsScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable -fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { +fun LockScreenInfoScreen( + getText: () -> String, setText: (String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var infoText by remember { mutableStateOf(Privilege.DPM.deviceOwnerLockScreenInfo?.toString() ?: "") } + var infoText by rememberSaveable { mutableStateOf(getText()) } MyScaffold(R.string.lock_screen_info, onNavigateUp) { OutlinedTextField( value = infoText, @@ -625,7 +470,7 @@ fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - Privilege.DPM.setDeviceOwnerLockScreenInfo(Privilege.DAR, infoText) + setText(infoText) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth() @@ -635,7 +480,7 @@ fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - Privilege.DPM.setDeviceOwnerLockScreenInfo(Privilege.DAR, null) + setText("") infoText = "" context.showOperationResultToast(true) }, @@ -648,124 +493,103 @@ fun LockScreenInfoScreen(onNavigateUp: () -> Unit) { } } -@Keep +data class DelegatedScope(val id: String, val string: Int, val requiresApi: Int = 26) @Suppress("InlinedApi") -enum class DelegatedScope(val id: String, @StringRes val string: Int, val requiresApi: Int = 0) { - AppRestrictions(DevicePolicyManager.DELEGATION_APP_RESTRICTIONS, R.string.manage_application_restrictions), - BlockUninstall(DevicePolicyManager.DELEGATION_BLOCK_UNINSTALL, R.string.block_uninstall), - CertInstall(DevicePolicyManager.DELEGATION_CERT_INSTALL, R.string.manage_certificates), - CertSelection(DevicePolicyManager.DELEGATION_CERT_SELECTION, R.string.select_keychain_certificates, 29), - EnableSystemApp(DevicePolicyManager.DELEGATION_ENABLE_SYSTEM_APP, R.string.enable_system_app), - InstallExistingPackage(DevicePolicyManager.DELEGATION_INSTALL_EXISTING_PACKAGE, R.string.install_existing_packages, 28), - KeepUninstalledPackages(DevicePolicyManager.DELEGATION_KEEP_UNINSTALLED_PACKAGES, R.string.manage_uninstalled_packages, 28), - NetworkLogging(DevicePolicyManager.DELEGATION_NETWORK_LOGGING, R.string.network_logging, 29), - PackageAccess(DevicePolicyManager.DELEGATION_PACKAGE_ACCESS, R.string.change_package_state), - PermissionGrant(DevicePolicyManager.DELEGATION_PERMISSION_GRANT, R.string.grant_permissions), - SecurityLogging(DevicePolicyManager.DELEGATION_SECURITY_LOGGING, R.string.security_logging, 31) -} +val delegatedScopesList = listOf( + DelegatedScope(DevicePolicyManager.DELEGATION_APP_RESTRICTIONS, R.string.manage_application_restrictions), + DelegatedScope(DevicePolicyManager.DELEGATION_BLOCK_UNINSTALL, R.string.block_uninstall), + DelegatedScope(DevicePolicyManager.DELEGATION_CERT_INSTALL, R.string.manage_certificates), + DelegatedScope(DevicePolicyManager.DELEGATION_CERT_SELECTION, R.string.select_keychain_certificates, 29), + DelegatedScope(DevicePolicyManager.DELEGATION_ENABLE_SYSTEM_APP, R.string.enable_system_app), + DelegatedScope(DevicePolicyManager.DELEGATION_INSTALL_EXISTING_PACKAGE, R.string.install_existing_packages, 28), + DelegatedScope(DevicePolicyManager.DELEGATION_KEEP_UNINSTALLED_PACKAGES, R.string.manage_uninstalled_packages, 28), + DelegatedScope(DevicePolicyManager.DELEGATION_NETWORK_LOGGING, R.string.network_logging, 29), + DelegatedScope(DevicePolicyManager.DELEGATION_PACKAGE_ACCESS, R.string.change_package_state), + DelegatedScope(DevicePolicyManager.DELEGATION_PERMISSION_GRANT, R.string.grant_permissions), + DelegatedScope(DevicePolicyManager.DELEGATION_SECURITY_LOGGING, R.string.security_logging, 31) +).filter { VERSION.SDK_INT >= it.requiresApi } + +data class DelegatedAdmin(val app: AppInfo, val scopes: List) @Serializable object DelegatedAdmins @RequiresApi(26) @Composable -fun DelegatedAdminsScreen(onNavigateUp: () -> Unit, onNavigate: (AddDelegatedAdmin) -> Unit) { - val packages = remember { mutableStateMapOf>() } - fun refresh() { - val list = mutableMapOf>() - DelegatedScope.entries.forEach { ds -> - if(VERSION.SDK_INT >= ds.requiresApi) { - Privilege.DPM.getDelegatePackages(Privilege.DAR, ds.id)?.forEach { pkg -> - if(list[pkg] != null) { - list[pkg]!!.add(ds) - } else { - list[pkg] = mutableListOf(ds) +fun DelegatedAdminsScreen( + delegatedAdmins: StateFlow>, getDelegatedAdmins: () -> Unit, + onNavigateUp: () -> Unit, onNavigate: (AddDelegatedAdmin) -> Unit +) { + val admins by delegatedAdmins.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getDelegatedAdmins() } + MyLazyScaffold(R.string.delegated_admins, onNavigateUp) { + items(admins, { it.app.name }) { (app, scopes) -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp).animateItem(), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = rememberDrawablePainter(app.icon), contentDescription = null, + modifier = Modifier.padding(start = 12.dp, end = 18.dp).size(40.dp) + ) + Column { + Text(app.label) + Text(app.name, Modifier.alpha(0.8F), style = typography.bodyMedium) } } + IconButton({ onNavigate(AddDelegatedAdmin(app.name, scopes)) }) { + Icon(Icons.Outlined.Edit, null) + } } } - packages.clear() - packages.putAll(list) - } - LaunchedEffect(Unit) { refresh() } - MyScaffold(R.string.delegated_admins, onNavigateUp, 0.dp) { - packages.forEach { (pkg, scopes) -> + item { Row( - Modifier + modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) - .padding(start = 14.dp, end = 8.dp), - Arrangement.SpaceBetween + .clickable { onNavigate(AddDelegatedAdmin()) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column { - Text(pkg, style = typography.titleMedium) - Text( - scopes.size.toString() + " " + stringResource(R.string.delegated_scope), - color = colorScheme.onSurfaceVariant, style = typography.bodyMedium - ) - } - IconButton({ onNavigate(AddDelegatedAdmin(pkg, scopes)) }) { - Icon(Icons.Outlined.Edit, stringResource(R.string.edit)) - } + Icon(Icons.Default.Add, null, modifier = Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.add_delegated_admin), style = typography.titleMedium) } } - if(packages.isEmpty()) Text( - stringResource(R.string.none), - color = colorScheme.onSurfaceVariant, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(vertical = 4.dp) - ) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onNavigate(AddDelegatedAdmin()) } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Add, null, modifier = Modifier.padding(end = 12.dp)) - Text(stringResource(R.string.add_delegated_admin), style = typography.titleMedium) - } } } -@Serializable data class AddDelegatedAdmin(val pkg: String = "", val scopes: List = emptyList()) +@Serializable data class AddDelegatedAdmin(val pkg: String = "", val scopes: List = emptyList()) @RequiresApi(26) @Composable -fun AddDelegatedAdminScreen(data: AddDelegatedAdmin, onNavigateUp: () -> Unit) { +fun AddDelegatedAdminScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, data: AddDelegatedAdmin, + setDelegatedAdmin: (String, List) -> Unit, onNavigateUp: () -> Unit +) { val updateMode = data.pkg.isNotEmpty() - val fm = LocalFocusManager.current - var input by remember { mutableStateOf(data.pkg) } - val scopes = remember { mutableStateListOf(*data.scopes.toTypedArray()) } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { input = it } + var input by rememberSaveable { mutableStateOf(data.pkg) } + val scopes = rememberSaveable { mutableStateListOf(*data.scopes.toTypedArray()) } + LaunchedEffect(Unit) { + input = chosenPackage.receive() } MySmallTitleScaffold(if(updateMode) R.string.place_holder else R.string.add_delegated_admin, onNavigateUp, 0.dp) { - OutlinedTextField( - value = input, onValueChange = { input = it }, - label = { Text(stringResource(R.string.package_name)) }, - trailingIcon = { - if(!updateMode) IconButton({ choosePackage.launch(null) }) { - Icon(painterResource(R.drawable.list_fill0), null) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() }, - readOnly = updateMode, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = HorizontalPadding) - ) - DelegatedScope.entries.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { scope -> - val checked = scope in scopes + if (updateMode) { + OutlinedTextField(input, {}, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + enabled = false, label = { Text(stringResource(R.string.package_name)) }) + } else { + PackageNameTextField(input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { input = it } + } + delegatedScopesList.forEach { scope -> + val checked = scope.id in scopes Row( Modifier .fillMaxWidth() - .clickable { if (!checked) scopes += scope else scopes -= scope } + .clickable { if (!checked) scopes += scope.id else scopes -= scope.id } .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - Checkbox(checked, { if(it) scopes += scope else scopes -= scope }, modifier = Modifier.padding(horizontal = 4.dp)) + Checkbox(checked, { if(it) scopes += scope.id else scopes -= scope.id }, + modifier = Modifier.padding(horizontal = 4.dp)) Column { Text(stringResource(scope.string)) Text(scope.id, style = typography.bodyMedium, color = colorScheme.onSurfaceVariant) @@ -774,63 +598,57 @@ fun AddDelegatedAdminScreen(data: AddDelegatedAdmin, onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setDelegatedScopes(Privilege.DAR, input, scopes.map { it.id }) + setDelegatedAdmin(input, scopes) onNavigateUp() }, - modifier = Modifier - .fillMaxWidth() - .padding(HorizontalPadding, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, vertical = 4.dp), enabled = input.isNotBlank() && (!updateMode || scopes.toList() != data.scopes) ) { Text(stringResource(if(updateMode) R.string.update else R.string.add)) } if(updateMode) Button( onClick = { - Privilege.DPM.setDelegatedScopes(Privilege.DAR, input, emptyList()) + setDelegatedAdmin(input, emptyList()) onNavigateUp() }, - modifier = Modifier - .fillMaxWidth() - .padding(HorizontalPadding), + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError) ) { Text(stringResource(R.string.delete)) } + Spacer(Modifier.height(BottomPadding)) } } @Serializable object DeviceInfo @Composable -fun DeviceInfoScreen(onNavigateUp: () -> Unit) { +fun DeviceInfoScreen(vm: MyViewModel, onNavigateUp: () -> Unit) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.device_info, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT>=34 && (privilege.device || privilege.org)) { - InfoItem(R.string.financed_device, Privilege.DPM.isDeviceFinanced.yesOrNo) + if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.org)) { + InfoItem(R.string.financed_device, vm.getDeviceFinanced().yesOrNo) } - if(VERSION.SDK_INT >= 33) { - val dpmRole = Privilege.DPM.devicePolicyManagementRoleHolderPackage - InfoItem(R.string.dpmrh, dpmRole ?: stringResource(R.string.none)) + if (VERSION.SDK_INT >= 33) { + InfoItem(R.string.dpmrh, vm.getDpmRh() ?: stringResource(R.string.none)) } - val encryptionStatus = mutableMapOf( - DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE to R.string.es_inactive, - DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE to R.string.es_active, - DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED to R.string.es_unsupported - ) - if(VERSION.SDK_INT >= 23) { encryptionStatus[DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY] = R.string.es_active_default_key } - if(VERSION.SDK_INT >= 24) { encryptionStatus[DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER] = R.string.es_active_per_user } - InfoItem(R.string.encryption_status, encryptionStatus[Privilege.DPM.storageEncryptionStatus] ?: R.string.unknown) - if(VERSION.SDK_INT >= 28) { - InfoItem(R.string.support_device_id_attestation, Privilege.DPM.isDeviceIdAttestationSupported.yesOrNo, true) { dialog = 1 } + val encryptionStatus = when (vm.getStorageEncryptionStatus()) { + DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE -> R.string.es_inactive + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE -> R.string.es_active + DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED -> R.string.es_unsupported + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY -> R.string.es_active_default_key + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER -> R.string.es_active_per_user + else -> R.string.unknown + } + InfoItem(R.string.encryption_status, encryptionStatus) + if (VERSION.SDK_INT >= 28) { + InfoItem(R.string.support_device_id_attestation, vm.getDeviceIdAttestationSupported().yesOrNo, true) { dialog = 1 } } if (VERSION.SDK_INT >= 30) { - InfoItem(R.string.support_unique_device_attestation, Privilege.DPM.isUniqueDeviceAttestationSupported.yesOrNo, true) { dialog = 2 } - } - val adminList = Privilege.DPM.activeAdmins - if(adminList != null) { - InfoItem(R.string.activated_device_admin, adminList.joinToString("\n") { it.flattenToShortString() }) + InfoItem(R.string.support_unique_device_attestation, vm.getUniqueDeviceAttestationSupported().yesOrNo, true) { dialog = 2 } } + InfoItem(R.string.activated_device_admin, vm.getActiveAdmins()) } if(dialog != 0) AlertDialog( text = { Text(stringResource(if(dialog == 1) R.string.info_device_id_attestation else R.string.info_unique_device_attestation)) }, @@ -843,15 +661,17 @@ fun DeviceInfoScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable -fun SupportMessageScreen(onNavigateUp: () -> Unit) { +fun SupportMessageScreen( + getShortMessage: () -> String, getLongMessage: () -> String, setShortMessage: (String?) -> Unit, + setLongMessage: (String?) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var shortMsg by remember { mutableStateOf("") } - var longMsg by remember { mutableStateOf("") } - val refreshMsg = { - shortMsg = Privilege.DPM.getShortSupportMessage(Privilege.DAR)?.toString() ?: "" - longMsg = Privilege.DPM.getLongSupportMessage(Privilege.DAR)?.toString() ?: "" + var shortMsg by rememberSaveable { mutableStateOf("") } + var longMsg by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + shortMsg = getShortMessage() + longMsg = getLongMessage() } - LaunchedEffect(Unit) { refreshMsg() } MyScaffold(R.string.support_messages, onNavigateUp) { OutlinedTextField( value = shortMsg, @@ -865,8 +685,7 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setShortSupportMessage(Privilege.DAR, shortMsg) - refreshMsg() + setShortMessage(shortMsg) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.49F) @@ -875,8 +694,8 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setShortSupportMessage(Privilege.DAR, null) - refreshMsg() + setShortMessage(null) + shortMsg = "" context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -898,8 +717,7 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setLongSupportMessage(Privilege.DAR, longMsg) - refreshMsg() + setLongMessage(longMsg) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.49F) @@ -908,8 +726,8 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setLongSupportMessage(Privilege.DAR, null) - refreshMsg() + setLongMessage(null) + longMsg = "" context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -921,57 +739,60 @@ fun SupportMessageScreen(onNavigateUp: () -> Unit) { } } +data class DeviceAdmin(val app: AppInfo, val admin: ComponentName) + @Serializable object TransferOwnership @RequiresApi(28) @Composable -fun TransferOwnershipScreen(onNavigateUp: () -> Unit, onTransferred: () -> Unit) { - val context = LocalContext.current +fun TransferOwnershipScreen( + deviceAdmins: StateFlow>, getDeviceAdmins: () -> Unit, + transferOwnership: (ComponentName) -> Unit, onNavigateUp: () -> Unit, onTransferred: () -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() - val focusMgr = LocalFocusManager.current - var input by remember { mutableStateOf("") } - val componentName = ComponentName.unflattenFromString(input) - var dialog by remember { mutableStateOf(false) } - MyScaffold(R.string.transfer_ownership, onNavigateUp) { - OutlinedTextField( - value = input, onValueChange = { input = it }, label = { Text(stringResource(R.string.target_component_name)) }, - modifier = Modifier.fillMaxWidth(), - isError = input != "" && componentName == null, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusMgr.clearFocus() }) - ) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { dialog = true }, - modifier = Modifier.fillMaxWidth(), - enabled = componentName != null - ) { - Text(stringResource(R.string.transfer)) + var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } + var dialog by rememberSaveable { mutableStateOf(false) } + val receivers by deviceAdmins.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getDeviceAdmins() } + MyLazyScaffold(R.string.transfer_ownership, onNavigateUp) { + itemsIndexed(receivers) { index, admin -> + Row( + Modifier.fillMaxWidth().clickable { selectedIndex = index }.padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selectedIndex == index, { selectedIndex = index }) + Image(rememberDrawablePainter(admin.app.icon), null, Modifier.size(40.dp)) + Column(Modifier.padding(start = 8.dp)) { + Text(admin.app.label) + Text(admin.app.name, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + } + item { + Button( + onClick = { dialog = true }, + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 10.dp), + enabled = receivers.getOrNull(selectedIndex) != null + ) { + Text(stringResource(R.string.transfer)) + } + Notes(R.string.info_transfer_ownership, HorizontalPadding) } - Spacer(Modifier.padding(vertical = 10.dp)) - Notes(R.string.info_transfer_ownership) } - if(dialog) AlertDialog( + if (dialog) AlertDialog( text = { Text(stringResource( R.string.transfer_ownership_warning, stringResource(if(privilege.device) R.string.device_owner else R.string.profile_owner), - ComponentName.unflattenFromString(input)!!.packageName + receivers[selectedIndex].app.name )) }, confirmButton = { TextButton( onClick = { - try { - Privilege.DPM.transferOwnership(Privilege.DAR, componentName!!, null) - Privilege.updateStatus() - context.showOperationResultToast(true) - dialog = false - onTransferred() - } catch(e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) - } + transferOwnership(receivers[selectedIndex].admin) + dialog = false + onTransferred() }, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) ) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 635957b..777e865 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -1,10 +1,7 @@ package com.bintianqi.owndroid.dpm import android.annotation.SuppressLint -import android.app.ActivityOptions import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyManager.FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY -import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback import android.app.admin.DevicePolicyManager.MTE_DISABLED import android.app.admin.DevicePolicyManager.MTE_ENABLED import android.app.admin.DevicePolicyManager.MTE_NOT_CONTROLLED_BY_POLICY @@ -19,15 +16,10 @@ import android.app.admin.DevicePolicyManager.WIPE_EUICC import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE import android.app.admin.DevicePolicyManager.WIPE_RESET_PROTECTION_DATA import android.app.admin.DevicePolicyManager.WIPE_SILENTLY -import android.app.admin.FactoryResetProtectionPolicy -import android.app.admin.SystemUpdateInfo -import android.app.admin.SystemUpdatePolicy import android.app.admin.SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC import android.app.admin.SystemUpdatePolicy.TYPE_INSTALL_WINDOWED import android.app.admin.SystemUpdatePolicy.TYPE_POSTPONE -import android.content.ComponentName import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Build.VERSION import android.os.HardwarePropertiesManager @@ -39,17 +31,13 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -68,6 +56,7 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -79,17 +68,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider -import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDatePickerState @@ -99,9 +86,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -112,25 +97,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.ChoosePackageContract +import com.bintianqi.owndroid.AppInfo +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.NotificationUtils +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP -import com.bintianqi.owndroid.createShortcuts -import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.humanReadableDate -import com.bintianqi.owndroid.parseDate +import com.bintianqi.owndroid.clickableTextField +import com.bintianqi.owndroid.formatDate +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.ErrorDialog import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem @@ -141,32 +126,29 @@ import com.bintianqi.owndroid.ui.MySmallTitleScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem -import com.bintianqi.owndroid.uriToStream -import kotlinx.coroutines.Dispatchers +import com.bintianqi.owndroid.yesOrNo +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import java.io.ByteArrayOutputStream -import java.security.MessageDigest -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate +import java.text.SimpleDateFormat import java.util.Date +import java.util.Locale import java.util.TimeZone -import java.util.concurrent.Executors import kotlin.math.roundToLong @Serializable object SystemManager @Composable -fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun SystemManagerScreen( + vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit +) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() /** 1: reboot, 2: bug report, 3: org name, 4: org id, 5: enrollment specific id*/ - var dialog by remember { mutableIntStateOf(0) } - var enrollmentSpecificId by remember { - mutableStateOf(if (VERSION.SDK_INT >= 31 && (privilege.device || privilege.profile)) Privilege.DPM.enrollmentSpecificId else "") - } + var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.system, onNavigateUp, 0.dp) { FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(SystemOptions) } FunctionItem(R.string.keyguard, icon = R.drawable.screen_lock_portrait_fill0) { onNavigate(Keyguard) } @@ -199,7 +181,7 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(VERSION.SDK_INT >= 31) { FunctionItem(R.string.nearby_streaming_policy, icon = R.drawable.share_fill0) { onNavigate(NearbyStreamingPolicy) } } - if(VERSION.SDK_INT >= 28 && privilege.device) { + if (VERSION.SDK_INT >= 28 && privilege.device && !privilege.dhizuku) { FunctionItem(R.string.lock_task_mode, icon = R.drawable.lock_fill0) { onNavigate(LockTaskMode) } } FunctionItem(R.string.ca_cert, icon = R.drawable.license_fill0) { onNavigate(CaCert) } @@ -213,7 +195,7 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { if(VERSION.SDK_INT >= 31) { FunctionItem(R.string.org_id, icon = R.drawable.corporate_fare_fill0) { dialog = 4 } } - if(enrollmentSpecificId != "") { + if (VERSION.SDK_INT >= 31) { FunctionItem(R.string.enrollment_specific_id, icon = R.drawable.id_card_fill0) { dialog = 5 } } if(VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { @@ -249,9 +231,9 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { TextButton( onClick = { if(dialog == 1) { - Privilege.DPM.reboot(Privilege.DAR) + vm.reboot() } else { - context.showOperationResultToast(Privilege.DPM.requestBugreport(Privilege.DAR)) + context.showOperationResultToast(vm.requestBugReport()) } dialog = 0 } @@ -262,17 +244,23 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { modifier = Modifier.fillMaxWidth() ) if(dialog in 3..5) { - var input by remember { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } AlertDialog( text = { val focusMgr = LocalFocusManager.current LaunchedEffect(Unit) { - if(dialog == 5 && VERSION.SDK_INT >= 31) input = Privilege.DPM.enrollmentSpecificId + if (dialog == 5 && VERSION.SDK_INT >= 31) { + val id = vm.getEnrollmentSpecificId() + input = id.ifEmpty { context.getString(R.string.none) } + } + if (dialog == 3 && VERSION.SDK_INT >= 24) input = vm.getOrgName() } Column { OutlinedTextField( input, { input = it }, - Modifier.fillMaxWidth().padding(bottom = if (dialog != 3) 8.dp else 0.dp), + Modifier + .fillMaxWidth() + .padding(bottom = if (dialog != 3) 8.dp else 0.dp), readOnly = dialog == 5, label = { Text(stringResource( @@ -302,16 +290,11 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { confirmButton = { TextButton( onClick = { - try { - if (dialog == 3 && VERSION.SDK_INT >= 24) Privilege.DPM.setOrganizationName(Privilege.DAR, input) - if (dialog == 4 && VERSION.SDK_INT >= 31) { - Privilege.DPM.setOrganizationId(input) - enrollmentSpecificId = Privilege.DPM.enrollmentSpecificId - } - dialog = 0 - } catch(_: IllegalStateException) { - context.showOperationResultToast(false) + if (dialog == 3 && VERSION.SDK_INT >= 24) vm.setOrgName(input) + if (dialog == 4 && VERSION.SDK_INT >= 31) { + context.showOperationResultToast(vm.setOrgId(input)) } + dialog = 0 }, enabled = dialog != 4 || input.length in 6..64 ) { @@ -322,77 +305,87 @@ fun SystemManagerScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } } +data class SystemOptionsStatus( + val cameraDisabled: Boolean = false, + val screenCaptureDisabled: Boolean = false, + val statusBarDisabled: Boolean = false, + val autoTimeEnabled: Boolean = true, + val autoTimeZoneEnabled: Boolean = true, + val autoTimeRequired: Boolean = true, + val masterVolumeMuted: Boolean = false, + val backupServiceEnabled: Boolean = false, + val btContactSharingDisabled: Boolean = false, + val commonCriteriaMode: Boolean = false, + val usbSignalEnabled: Boolean = true, + val canDisableUsbSignal: Boolean = true +) + @Serializable object SystemOptions @Composable -fun SystemOptionsScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun SystemOptionsScreen(vm: MyViewModel, onNavigateUp: () -> Unit) { val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } + val status by vm.systemOptionsStatus.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.getSystemOptionsStatus() } MyScaffold(R.string.options, onNavigateUp, 0.dp) { - SwitchItem(R.string.disable_cam, icon = R.drawable.no_photography_fill0, - getState = { Privilege.DPM.getCameraDisabled(null) }, onCheckedChange = { - Privilege.DPM.setCameraDisabled(Privilege.DAR, it) - createShortcuts(context) - } - ) - SwitchItem(R.string.disable_screen_capture, icon = R.drawable.screenshot_fill0, - getState = { Privilege.DPM.getScreenCaptureDisabled(null) }, - onCheckedChange = { Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, it) } - ) - if(VERSION.SDK_INT >= 34 && (privilege.device || (privilege.profile && privilege.affiliated))) { - SwitchItem(R.string.disable_status_bar, icon = R.drawable.notifications_fill0, - getState = { Privilege.DPM.isStatusBarDisabled}, - onCheckedChange = { Privilege.DPM.setStatusBarDisabled(Privilege.DAR, it) } - ) + SwitchItem(R.string.disable_cam, status.cameraDisabled, vm::setCameraDisabled, + R.drawable.no_photography_fill0) + SwitchItem(R.string.disable_screen_capture, status.screenCaptureDisabled, + vm::setScreenCaptureDisabled, R.drawable.screenshot_fill0) + if (VERSION.SDK_INT >= 34 && privilege.run { device || (profile && affiliated) }) { + SwitchItem(R.string.disable_status_bar, status.statusBarDisabled, + vm::setStatusBarDisabled, R.drawable.notifications_fill0) } - if(privilege.device || privilege.org) { + if (privilege.device || privilege.org) { if(VERSION.SDK_INT >= 30) { - SwitchItem(R.string.auto_time, icon = R.drawable.schedule_fill0, - getState = { Privilege.DPM.getAutoTimeEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setAutoTimeEnabled(Privilege.DAR, it) } - ) - SwitchItem(R.string.auto_timezone, icon = R.drawable.globe_fill0, - getState = { Privilege.DPM.getAutoTimeZoneEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setAutoTimeZoneEnabled(Privilege.DAR, it) } - ) + SwitchItem(R.string.auto_time, status.autoTimeEnabled, vm::setAutoTimeEnabled, + R.drawable.schedule_fill0) + SwitchItem(R.string.auto_timezone, status.autoTimeZoneEnabled, + vm::setAutoTimeZoneEnabled, R.drawable.globe_fill0) } else { - SwitchItem(R.string.require_auto_time, icon = R.drawable.schedule_fill0, - getState = { Privilege.DPM.autoTimeRequired }, - onCheckedChange = { Privilege.DPM.setAutoTimeRequired(Privilege.DAR, it) }, padding = false) + SwitchItem(R.string.require_auto_time, status.autoTimeRequired, + vm::setAutoTimeRequired, R.drawable.schedule_fill0) } } - if (!privilege.work) SwitchItem(R.string.master_mute, icon = R.drawable.volume_off_fill0, - getState = { Privilege.DPM.isMasterVolumeMuted(Privilege.DAR) }, onCheckedChange = { - Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, it) - createShortcuts(context) - } - ) - if(VERSION.SDK_INT >= 26) { + if (!privilege.work) SwitchItem(R.string.master_mute, + status.masterVolumeMuted, vm::setMasterVolumeMuted, R.drawable.volume_off_fill0) + if (VERSION.SDK_INT >= 26) { SwitchItem(R.string.backup_service, icon = R.drawable.backup_fill0, - getState = { Privilege.DPM.isBackupServiceEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setBackupServiceEnabled(Privilege.DAR, it) }, - onClickBlank = { dialog = 1 } - ) + state = status.backupServiceEnabled, onCheckedChange = vm::setBackupServiceEnabled, + onClickBlank = { dialog = 1 }) } - if(VERSION.SDK_INT >= 24 && privilege.work) { - SwitchItem(R.string.disable_bt_contact_share, icon = R.drawable.account_circle_fill0, - getState = { Privilege.DPM.getBluetoothContactSharingDisabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setBluetoothContactSharingDisabled(Privilege.DAR, it) } - ) + if (VERSION.SDK_INT >= 24 && privilege.work) { + SwitchItem(R.string.disable_bt_contact_share, status.btContactSharingDisabled, + vm::setBtContactSharingDisabled, R.drawable.account_circle_fill0) } - if(VERSION.SDK_INT >= 30 && privilege.device) { - SwitchItem(R.string.common_criteria_mode , icon =R.drawable.security_fill0, - getState = { Privilege.DPM.isCommonCriteriaModeEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setCommonCriteriaModeEnabled(Privilege.DAR, it) }, - onClickBlank = { dialog = 2 } - ) + if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { + SwitchItem(R.string.common_criteria_mode, icon = R.drawable.security_fill0, + state = status.commonCriteriaMode, + onCheckedChange = vm::setCommonCriteriaModeEnabled, + onClickBlank = { dialog = 2 }) } - if(VERSION.SDK_INT >= 31 && (privilege.device || privilege.org) && Privilege.DPM.canUsbDataSignalingBeDisabled()) { - SwitchItem( - R.string.disable_usb_signal, icon = R.drawable.usb_fill0, getState = { !Privilege.DPM.isUsbDataSignalingEnabled }, - onCheckedChange = { Privilege.DPM.isUsbDataSignalingEnabled = !it }, - ) + if (VERSION.SDK_INT >= 31 && (privilege.device || privilege.org) && status.canDisableUsbSignal) { + SwitchItem(R.string.enable_usb_signal, status.usbSignalEnabled, + vm::setUsbSignalEnabled, R.drawable.usb_fill0) + } + if (VERSION.SDK_INT >= 23 && VERSION.SDK_INT < 34) { + Row( + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.status_bar), style = typography.titleMedium) + Button({ + vm.setStatusBarDisabled(true) + }, Modifier.padding(horizontal = 4.dp)) { + Text(stringResource(R.string.disable)) + } + Button({ + vm.setStatusBarDisabled(false) + }) { + Text(stringResource(R.string.enable)) + } + } } } if(dialog != 0) AlertDialog( @@ -415,23 +408,26 @@ fun SystemOptionsScreen(onNavigateUp: () -> Unit) { @Serializable object Keyguard @Composable -fun KeyguardScreen(onNavigateUp: () -> Unit) { +fun KeyguardScreen( + setKeyguardDisabled: (Boolean) -> Boolean, lock: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() MyScaffold(R.string.keyguard, onNavigateUp) { - if(VERSION.SDK_INT >= 23 && (privilege.device || (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated))) { + if (VERSION.SDK_INT >= 23 && (privilege.device || + (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated))) { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { Button( - onClick = { context.showOperationResultToast(Privilege.DPM.setKeyguardDisabled(Privilege.DAR, true)) }, + onClick = { context.showOperationResultToast(setKeyguardDisabled(true)) }, modifier = Modifier.fillMaxWidth(0.49F) ) { Text(stringResource(R.string.disable)) } Button( - onClick = { context.showOperationResultToast(Privilege.DPM.setKeyguardDisabled(Privilege.DAR, false)) }, + onClick = { context.showOperationResultToast(setKeyguardDisabled(false)) }, modifier = Modifier.fillMaxWidth(0.96F) ) { Text(stringResource(R.string.enable)) @@ -442,69 +438,61 @@ fun KeyguardScreen(onNavigateUp: () -> Unit) { } if(VERSION.SDK_INT >= 23) Text(text = stringResource(R.string.lock_now), style = typography.headlineLarge) Spacer(Modifier.padding(vertical = 2.dp)) - var flag by remember { mutableIntStateOf(0) } - if(VERSION.SDK_INT >= 26 && privilege.work) { - CheckBoxItem( - R.string.evict_credential_encryption_key, - flag and FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY != 0 - ) { flag = flag xor FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY } - Spacer(Modifier.padding(vertical = 2.dp)) - } + var evictKey by rememberSaveable { mutableStateOf(false) } Button( - onClick = { - if(VERSION.SDK_INT >= 26) Privilege.DPM.lockNow(flag) else Privilege.DPM.lockNow() - }, + onClick = { lock(evictKey) }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.lock_now)) } - if(VERSION.SDK_INT >= 26 && privilege.work) { + if (VERSION.SDK_INT >= 26 && privilege.work) { + CheckBoxItem(R.string.evict_credential_encryption_key, evictKey) { evictKey = true } + Spacer(Modifier.height(5.dp)) Notes(R.string.info_evict_credential_encryption_key) } } } +data class HardwareProperties( + val temperatures: Map> = emptyMap(), + val cpuUsages: List> = emptyList(), + val fanSpeeds: List = emptyList() +) + +@RequiresApi(24) +val temperatureTypes = mapOf( + HardwarePropertiesManager.DEVICE_TEMPERATURE_CPU to R.string.cpu_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_GPU to R.string.gpu_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_BATTERY to R.string.battery_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_SKIN to R.string.skin_temp +) + @Serializable object HardwareMonitor @RequiresApi(24) @Composable -fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val hpm = context.getSystemService(HardwarePropertiesManager::class.java) - var refreshInterval by remember { mutableFloatStateOf(1F) } +fun HardwareMonitorScreen( + hardwareProperties: StateFlow, getHardwareProperties: suspend () -> Unit, + setRefreshInterval: (Float) -> Unit, + onNavigateUp: () -> Unit +) { + val properties by hardwareProperties.collectAsStateWithLifecycle() + var refreshInterval by rememberSaveable { mutableFloatStateOf(1F) } val refreshIntervalMs = (refreshInterval * 1000).roundToLong() - val temperatures = remember { mutableStateMapOf>() } - val tempTypeMap = mapOf( - HardwarePropertiesManager.DEVICE_TEMPERATURE_CPU to R.string.cpu_temp, - HardwarePropertiesManager.DEVICE_TEMPERATURE_GPU to R.string.gpu_temp, - HardwarePropertiesManager.DEVICE_TEMPERATURE_BATTERY to R.string.battery_temp, - HardwarePropertiesManager.DEVICE_TEMPERATURE_SKIN to R.string.skin_temp - ) - val cpuUsages = remember { mutableStateListOf>() } - val fanSpeeds = remember { mutableStateListOf() } - fun refresh() { - cpuUsages.clear() - cpuUsages.addAll(hpm.cpuUsages.map { it.active to it.total }) - temperatures.clear() - tempTypeMap.forEach { - temperatures += it.key to hpm.getDeviceTemperatures(it.key, HardwarePropertiesManager.TEMPERATURE_CURRENT).toList() - } - fanSpeeds.clear() - fanSpeeds.addAll(hpm.fanSpeeds.toList()) - } LaunchedEffect(Unit) { - while(true) { - refresh() - delay(refreshIntervalMs) - } + getHardwareProperties() } MyScaffold(R.string.hardware_monitor, onNavigateUp) { - Text(stringResource(R.string.refresh_interval), style = typography.titleLarge, modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)) - Slider(refreshInterval, { refreshInterval = it }, valueRange = 0.5F..2F, steps = 14) + Text(stringResource(R.string.refresh_interval), Modifier.padding(top = 8.dp, bottom = 4.dp), + style = typography.titleLarge) + Slider(refreshInterval, { + refreshInterval = it + setRefreshInterval(it) + }, valueRange = 0.5F..2F, steps = 14) Text("${refreshIntervalMs}ms") Spacer(Modifier.padding(vertical = 10.dp)) - temperatures.forEach { tempMapItem -> - Text(stringResource(tempTypeMap[tempMapItem.key]!!), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) + properties.temperatures.forEach { tempMapItem -> + Text(stringResource(temperatureTypes[tempMapItem.key]!!), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) if(tempMapItem.value.isEmpty()) { Text(stringResource(R.string.unsupported)) } else { @@ -518,10 +506,10 @@ fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 10.dp)) } Text(stringResource(R.string.cpu_usages), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) - if(cpuUsages.isEmpty()) { + if (properties.cpuUsages.isEmpty()) { Text(stringResource(R.string.unsupported)) } else { - cpuUsages.forEachIndexed { index, usage -> + properties.cpuUsages.forEachIndexed { index, usage -> Row(modifier = Modifier.padding(vertical = 4.dp)) { Text(index.toString(), style = typography.titleMedium, modifier = Modifier.padding(start = 8.dp, end = 12.dp)) Column { @@ -533,10 +521,10 @@ fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { } Spacer(Modifier.padding(vertical = 10.dp)) Text(stringResource(R.string.fan_speeds), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) - if(fanSpeeds.isEmpty()) { + if (properties.fanSpeeds.isEmpty()) { Text(stringResource(R.string.unsupported)) } else { - fanSpeeds.forEachIndexed { index, speed -> + properties.fanSpeeds.forEachIndexed { index, speed -> Row(modifier = Modifier.padding(vertical = 4.dp)) { Text(index.toString(), style = typography.titleMedium, modifier = Modifier.padding(start = 8.dp, end = 12.dp)) Text("$speed RPM") @@ -551,99 +539,106 @@ fun HardwareMonitorScreen(onNavigateUp: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(28) @Composable -fun ChangeTimeScreen(onNavigateUp: () -> Unit) { +fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current + var tab by rememberSaveable { mutableIntStateOf(0) } val pagerState = rememberPagerState { 2 } - var picker by remember { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker + tab = pagerState.currentPage + val coroutine = rememberCoroutineScope() + var picker by rememberSaveable { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker + var useCurrentTz by rememberSaveable { mutableStateOf(true) } val datePickerState = rememberDatePickerState() - val timePickerState = rememberTimePickerState() - val dateInteractionSource = remember { MutableInteractionSource() } - val timeInteractionSource = remember { MutableInteractionSource() } - if(dateInteractionSource.collectIsPressedAsState().value) picker = 1 - if(timeInteractionSource.collectIsPressedAsState().value) picker = 2 - MyScaffold(R.string.change_time, onNavigateUp) { - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) + val timePickerState = rememberTimePickerState(is24Hour = true) + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.change_time)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + Modifier + .fillMaxSize() + .padding(paddingValues) ) { - val coroutine = rememberCoroutineScope() - SegmentedButton( - selected = pagerState.targetPage == 0, shape = SegmentedButtonDefaults.itemShape(0, 2), - onClick = { - coroutine.launch { - pagerState.animateScrollToPage(0) - } - } - ) { - Text(stringResource(R.string.selector)) + PrimaryTabRow(tab) { + Tab( + tab == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.selector)) } + ) + Tab( + tab == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.manually_input)) } + ) } - SegmentedButton( - selected = pagerState.targetPage == 1, shape = SegmentedButtonDefaults.itemShape(1, 2), - onClick = { - coroutine.launch { - pagerState.animateScrollToPage(1) - } - } - ) { - Text(stringResource(R.string.manually_input)) - } - } - HorizontalPager( - state = pagerState, modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top - ) { page -> - Column(Modifier.padding(top = 4.dp)) { - if(page == 0) { - OutlinedTextField( - value = datePickerState.selectedDateMillis?.humanReadableDate ?: "", - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.date)) }, - interactionSource = dateInteractionSource, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = timePickerState.hour.toString() + ":" + timePickerState.minute.toString(), - onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.time)) }, - interactionSource = timeInteractionSource, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) - Button( - onClick = { - val timeMillis = datePickerState.selectedDateMillis!! + timePickerState.hour * 3600000 + timePickerState.minute * 60000 - context.showOperationResultToast(Privilege.DPM.setTime(Privilege.DAR, timeMillis)) - }, - modifier = Modifier.fillMaxWidth(), - enabled = datePickerState.selectedDateMillis != null - ) { - Text(stringResource(R.string.apply)) - } - } else { - var inputTime by remember { mutableStateOf("") } - OutlinedTextField( - value = inputTime, - label = { Text(stringResource(R.string.time_unit_ms)) }, - onValueChange = { inputTime = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() - ) - Button( - onClick = { - val timeMillis = inputTime.toLong() - context.showOperationResultToast(Privilege.DPM.setTime(Privilege.DAR, timeMillis)) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - enabled = inputTime.toLongOrNull() != null - ) { - Text(stringResource(R.string.apply)) + HorizontalPager( + pagerState, Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { page -> + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp) + .padding(horizontal = HorizontalPadding) + ) { + if(page == 0) { + OutlinedTextField( + value = datePickerState.selectedDateMillis?.let { formatDate(it) } ?: "", + onValueChange = {}, readOnly = true, + label = { Text(stringResource(R.string.date)) }, + modifier = Modifier.fillMaxWidth().clickableTextField { picker = 1 } + ) + OutlinedTextField( + value = timePickerState.hour.toString().padStart(2, '0') + ":" + + timePickerState.minute.toString().padStart(2, '0'), + onValueChange = {}, readOnly = true, + label = { Text(stringResource(R.string.time)) }, + modifier = Modifier + .fillMaxWidth() + .clickableTextField { picker = 2 } + .padding(vertical = 4.dp) + ) + CheckBoxItem(R.string.use_current_timezone, useCurrentTz) { + useCurrentTz = it + } + Button( + onClick = { + val timeMillis = datePickerState.selectedDateMillis!! + + timePickerState.hour * 3600000 + timePickerState.minute * 60000 + context.showOperationResultToast(setTime(timeMillis, useCurrentTz)) + }, + modifier = Modifier.fillMaxWidth(), + enabled = datePickerState.selectedDateMillis != null + ) { + Text(stringResource(R.string.apply)) + } + } else { + var inputTime by rememberSaveable { mutableStateOf("") } + OutlinedTextField( + value = inputTime, + label = { Text(stringResource(R.string.time_unit_ms)) }, + onValueChange = { inputTime = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + context.showOperationResultToast(setTime(inputTime.toLong(), false)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + enabled = inputTime.toLongOrNull() != null + ) { + Text(stringResource(R.string.apply)) + } } + Spacer(Modifier.height(BottomPadding)) } } } @@ -656,33 +651,40 @@ fun ChangeTimeScreen(onNavigateUp: () -> Unit) { }, onDismissRequest = { picker = 0; focusMgr.clearFocus() } ) { - DatePicker(datePickerState) + Column(Modifier.verticalScroll(rememberScrollState())) { + DatePicker(datePickerState) + } } - if(picker == 2) AlertDialog( - text = { TimePicker(timePickerState) }, + if (picker == 2) TimePickerDialog( + title = {}, confirmButton = { - TextButton(onClick = { picker = 0; focusMgr.clearFocus() } ) { + TextButton({ picker = 0 }) { Text(stringResource(R.string.confirm)) } }, - onDismissRequest = { picker = 0; focusMgr.clearFocus() } - ) + onDismissRequest = { picker = 0 } + ) { + TimePicker(timePickerState) + } } @Serializable object ChangeTimeZone @RequiresApi(28) @Composable -fun ChangeTimeZoneScreen(onNavigateUp: () -> Unit) { +fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var inputTimezone by remember { mutableStateOf("") } - var dialog by remember { mutableStateOf(false) } + var inputTimezone by rememberSaveable { mutableStateOf("") } + var dialog by rememberSaveable { mutableStateOf(false) } + val availableIds = TimeZone.getAvailableIDs() + val validInput = inputTimezone in availableIds MyScaffold(R.string.change_timezone, onNavigateUp) { OutlinedTextField( value = inputTimezone, label = { Text(stringResource(R.string.timezone_id)) }, onValueChange = { inputTimezone = it }, + isError = inputTimezone.isNotEmpty() && !validInput, trailingIcon = { IconButton(onClick = { dialog = true }) { Icon(imageVector = Icons.AutoMirrored.Default.List, contentDescription = null) @@ -695,9 +697,10 @@ fun ChangeTimeZoneScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - context.showOperationResultToast(Privilege.DPM.setTimeZone(Privilege.DAR, inputTimezone)) + context.showOperationResultToast(setTimeZone(inputTimezone)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + enabled = inputTimezone.isNotEmpty() && validInput ) { Text(stringResource(R.string.apply)) } @@ -707,7 +710,7 @@ fun ChangeTimeZoneScreen(onNavigateUp: () -> Unit) { if(dialog) AlertDialog( text = { LazyColumn { - items(TimeZone.getAvailableIDs()) { + items(availableIds) { Text( text = it, modifier = Modifier @@ -736,8 +739,11 @@ fun ChangeTimeZoneScreen(onNavigateUp: () -> Unit) { @RequiresApi(36) @Composable -fun AutoTimePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_time_policy, onNavigateUp, 0.dp) { - var policy by remember { mutableIntStateOf(Privilege.DPM.autoTimePolicy) } +fun AutoTimePolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) = MyScaffold(R.string.auto_time_policy, onNavigateUp, 0.dp) { + val context = LocalContext.current + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } listOf( DevicePolicyManager.AUTO_TIME_ENABLED to R.string.enable, DevicePolicyManager.AUTO_TIME_DISABLED to R.string.disabled, @@ -747,10 +753,15 @@ fun AutoTimePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_ti policy = it.first } } - Button({ - Privilege.DPM.autoTimePolicy = policy - policy = Privilege.DPM.autoTimePolicy - }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { + Button( + { + setPolicy(policy) + context.showOperationResultToast(true) + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + ) { Text(stringResource(R.string.apply)) } } @@ -759,8 +770,11 @@ fun AutoTimePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_ti @RequiresApi(36) @Composable -fun AutoTimeZonePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.auto_timezone_policy, onNavigateUp, 0.dp) { - var policy by remember { mutableIntStateOf(Privilege.DPM.autoTimeZonePolicy) } +fun AutoTimeZonePolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) = MyScaffold(R.string.auto_timezone_policy, onNavigateUp, 0.dp) { + val context = LocalContext.current + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } listOf( DevicePolicyManager.AUTO_TIME_ZONE_ENABLED to R.string.enable, DevicePolicyManager.AUTO_TIME_ZONE_DISABLED to R.string.disabled, @@ -771,9 +785,11 @@ fun AutoTimeZonePolicyScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.aut } } Button({ - Privilege.DPM.autoTimeZonePolicy = policy - policy = Privilege.DPM.autoTimeZonePolicy - }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { + setPolicy(policy) + context.showOperationResultToast(true) + }, Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding)) { Text(stringResource(R.string.apply)) } } @@ -962,11 +978,11 @@ fun KeyPairs(navCtrl: NavHostController) { @RequiresApi(35) @Composable -fun ContentProtectionPolicyScreen(onNavigateUp: () -> Unit) { +fun ContentProtectionPolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var policy by remember { mutableIntStateOf(DevicePolicyManager.CONTENT_PROTECTION_NOT_CONTROLLED_BY_POLICY) } - fun refresh() { policy = Privilege.DPM.getContentProtectionPolicy(Privilege.DAR) } - LaunchedEffect(Unit) { refresh() } + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.content_protection_policy, onNavigateUp, 0.dp) { mapOf( DevicePolicyManager.CONTENT_PROTECTION_NOT_CONTROLLED_BY_POLICY to R.string.not_controlled_by_policy, @@ -977,8 +993,7 @@ fun ContentProtectionPolicyScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setContentProtectionPolicy(Privilege.DAR, policy) - refresh() + setPolicy(policy) context.showOperationResultToast(true) }, modifier = Modifier @@ -995,9 +1010,11 @@ fun ContentProtectionPolicyScreen(onNavigateUp: () -> Unit) { @RequiresApi(23) @Composable -fun PermissionPolicyScreen(onNavigateUp: () -> Unit) { +fun PermissionPolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var selectedPolicy by remember { mutableIntStateOf(Privilege.DPM.getPermissionPolicy(Privilege.DAR)) } + var selectedPolicy by rememberSaveable { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.permission_policy, onNavigateUp, 0.dp) { FullWidthRadioButtonItem(R.string.default_stringres, selectedPolicy == PERMISSION_POLICY_PROMPT) { selectedPolicy = PERMISSION_POLICY_PROMPT @@ -1008,15 +1025,14 @@ fun PermissionPolicyScreen(onNavigateUp: () -> Unit) { FullWidthRadioButtonItem(R.string.auto_deny, selectedPolicy == PERMISSION_POLICY_AUTO_DENY) { selectedPolicy = PERMISSION_POLICY_AUTO_DENY } - Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - Privilege.DPM.setPermissionPolicy(Privilege.DAR,selectedPolicy) + setPolicy(selectedPolicy) context.showOperationResultToast(true) }, modifier = Modifier .fillMaxWidth() - .padding(horizontal = HorizontalPadding) + .padding(HorizontalPadding, 5.dp) ) { Text(stringResource(R.string.apply)) } @@ -1028,24 +1044,19 @@ fun PermissionPolicyScreen(onNavigateUp: () -> Unit) { @RequiresApi(34) @Composable -fun MtePolicyScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - var selectedMtePolicy by remember { mutableIntStateOf(Privilege.DPM.mtePolicy) } +fun MtePolicyScreen( + getPolicy: () -> Int, setPolicy: (Int) -> Boolean, onNavigateUp: () -> Unit +) { + var policy by rememberSaveable { mutableIntStateOf(getPolicy()) } MyScaffold(R.string.mte_policy, onNavigateUp, 0.dp) { - FullWidthRadioButtonItem(R.string.decide_by_user, selectedMtePolicy == MTE_NOT_CONTROLLED_BY_POLICY) { - selectedMtePolicy = MTE_NOT_CONTROLLED_BY_POLICY + FullWidthRadioButtonItem(R.string.decide_by_user, policy == MTE_NOT_CONTROLLED_BY_POLICY) { + policy = MTE_NOT_CONTROLLED_BY_POLICY } - FullWidthRadioButtonItem(R.string.enabled, selectedMtePolicy == MTE_ENABLED) { selectedMtePolicy = MTE_ENABLED } - FullWidthRadioButtonItem(R.string.disabled, selectedMtePolicy == MTE_DISABLED) { selectedMtePolicy = MTE_DISABLED } + FullWidthRadioButtonItem(R.string.enabled, policy == MTE_ENABLED) { policy = MTE_ENABLED } + FullWidthRadioButtonItem(R.string.disabled, policy == MTE_DISABLED) { policy = MTE_DISABLED } Button( onClick = { - try { - Privilege.DPM.mtePolicy = selectedMtePolicy - context.showOperationResultToast(true) - } catch(_: java.lang.UnsupportedOperationException) { - context.popToast(R.string.unsupported) - } - selectedMtePolicy = Privilege.DPM.mtePolicy + if (!setPolicy(policy)) policy = getPolicy() }, modifier = Modifier .fillMaxWidth() @@ -1061,9 +1072,12 @@ fun MtePolicyScreen(onNavigateUp: () -> Unit) { @RequiresApi(31) @Composable -fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { +fun NearbyStreamingPolicyScreen( + getAppPolicy: () -> Int, setAppPolicy: (Int) -> Unit, getNotificationPolicy: () -> Int, + setNotificationPolicy: (Int) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - var appPolicy by remember { mutableIntStateOf(Privilege.DPM.nearbyAppStreamingPolicy) } + var appPolicy by rememberSaveable { mutableIntStateOf(getAppPolicy()) } MySmallTitleScaffold(R.string.nearby_streaming_policy, onNavigateUp, 0.dp) { Text( stringResource(R.string.nearby_app_streaming), @@ -1081,8 +1095,7 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { ) { appPolicy = NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY } Button( onClick = { - Privilege.DPM.nearbyAppStreamingPolicy = appPolicy - appPolicy = Privilege.DPM.nearbyAppStreamingPolicy + setAppPolicy(appPolicy) context.showOperationResultToast(true) }, modifier = Modifier @@ -1092,7 +1105,8 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.apply)) } Notes(R.string.info_nearby_app_streaming_policy, HorizontalPadding) - var notificationPolicy by remember { mutableIntStateOf(Privilege.DPM.nearbyNotificationStreamingPolicy) } + Spacer(Modifier.height(20.dp)) + var notificationPolicy by rememberSaveable { mutableIntStateOf(getNotificationPolicy()) } Text( stringResource(R.string.nearby_notification_streaming), Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), style = typography.titleLarge @@ -1115,8 +1129,7 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { ) { notificationPolicy = NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY } Button( onClick = { - Privilege.DPM.nearbyNotificationStreamingPolicy = notificationPolicy - notificationPolicy = Privilege.DPM.nearbyNotificationStreamingPolicy + setNotificationPolicy(notificationPolicy) context.showOperationResultToast(true) }, modifier = Modifier @@ -1134,11 +1147,19 @@ fun NearbyStreamingPolicyScreen(onNavigateUp: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(28) @Composable -fun LockTaskModeScreen(onNavigateUp: () -> Unit) { +fun LockTaskModeScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + lockTaskPackages: StateFlow>, getLockTaskPackages: () -> Unit, + setLockTaskPackage: (String, Boolean) -> Unit, startLockTaskMode: (String, String) -> Boolean, + getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String?, onNavigateUp: () -> Unit +) { val coroutine = rememberCoroutineScope() val pagerState = rememberPagerState { 3 } - var tabIndex by remember { mutableIntStateOf(0) } + var tabIndex by rememberSaveable { mutableIntStateOf(0) } tabIndex = pagerState.targetPage + LaunchedEffect(Unit) { + getLockTaskPackages() + } Scaffold( topBar = { TopAppBar( @@ -1147,7 +1168,7 @@ fun LockTaskModeScreen(onNavigateUp: () -> Unit) { colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -1169,26 +1190,12 @@ fun LockTaskModeScreen(onNavigateUp: () -> Unit) { ) } HorizontalPager(pagerState, verticalAlignment = Alignment.Top) { page -> - if(page == 0 || page == 1) { - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = HorizontalPadding) - .padding(bottom = 80.dp) - ) { - if(page == 0) StartLockTaskMode() - else LockTaskPackages() - } + if(page == 0) { + StartLockTaskMode(startLockTaskMode, chosenPackage, onChoosePackage) + } else if (page == 1) { + LockTaskPackages(chosenPackage, onChoosePackage, lockTaskPackages, setLockTaskPackage) } else { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(bottom = 80.dp) - ) { - LockTaskFeatures() - } + LockTaskFeatures(getLockTaskFeatures, setLockTaskFeature) } } } @@ -1197,231 +1204,186 @@ fun LockTaskModeScreen(onNavigateUp: () -> Unit) { @RequiresApi(28) @Composable -private fun ColumnScope.StartLockTaskMode() { +private fun StartLockTaskMode( + startLockTaskMode: (String, String) -> Boolean, + chosenPackage: Channel, onChoosePackage: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var startLockTaskApp by rememberSaveable { mutableStateOf("") } - var startLockTaskActivity by rememberSaveable { mutableStateOf("") } + var packageName by rememberSaveable { mutableStateOf("") } + var activity by rememberSaveable { mutableStateOf("") } var specifyActivity by rememberSaveable { mutableStateOf(false) } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { startLockTaskApp = it } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() } - Spacer(Modifier.padding(vertical = 5.dp)) - OutlinedTextField( - value = startLockTaskApp, - onValueChange = { startLockTaskApp = it }, - label = { Text(stringResource(R.string.package_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - trailingIcon = { - Icon(painter = painterResource(R.drawable.list_fill0), contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { choosePackage.launch(null) } - .padding(3.dp)) - }, - modifier = Modifier + Column( + Modifier .fillMaxWidth() - .padding(vertical = 3.dp) - ) - CheckBoxItem(R.string.specify_activity, specifyActivity) { specifyActivity = it } - AnimatedVisibility(specifyActivity) { - OutlinedTextField( - value = startLockTaskActivity, - onValueChange = { startLockTaskActivity = it }, - label = { Text("Activity") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), + .padding(horizontal = HorizontalPadding) + .verticalScroll(rememberScrollState()) + ) { + Spacer(Modifier.height(5.dp)) + PackageNameTextField(packageName, onChoosePackage) { packageName = it } + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(specifyActivity, { + specifyActivity = it + activity = "" + }) + OutlinedTextField( + value = activity, + onValueChange = { activity = it }, + label = { Text("Activity") }, + enabled = specifyActivity, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), + modifier = Modifier.fillMaxWidth() + ) + } + Button( modifier = Modifier .fillMaxWidth() - .padding(bottom = 5.dp) - ) + .padding(bottom = 5.dp), + onClick = { + val result = startLockTaskMode(packageName, activity) + if (!result) context.showOperationResultToast(false) + }, + enabled = packageName.isNotBlank() && (!specifyActivity || activity.isNotBlank()) + ) { + Text(stringResource(R.string.start)) + } + Notes(R.string.info_start_lock_task_mode) } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - if(!NotificationUtils.checkPermission(context)) return@Button - if(!Privilege.DPM.isLockTaskPermitted(startLockTaskApp)) { - context.popToast(R.string.app_not_allowed) - return@Button - } - val options = ActivityOptions.makeBasic().setLockTaskEnabled(true) - val packageManager = context.packageManager - val launchIntent = if(specifyActivity) Intent().setComponent(ComponentName(startLockTaskApp, startLockTaskActivity)) - else packageManager.getLaunchIntentForPackage(startLockTaskApp) - if (launchIntent != null) { - context.startActivity(launchIntent, options.toBundle()) - } else { - context.showOperationResultToast(false) - } - }, - enabled = startLockTaskApp.isNotBlank() && (!specifyActivity || startLockTaskActivity.isNotBlank()) - ) { - Text(stringResource(R.string.start)) - } - Notes(R.string.info_start_lock_task_mode) } @RequiresApi(26) @Composable -private fun ColumnScope.LockTaskPackages() { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - val lockTaskPackages = remember { mutableStateListOf() } - var input by rememberSaveable { mutableStateOf("") } - val choosePackage = rememberLauncherForActivityResult(ChoosePackageContract()) { result -> - result?.let { input = it } +private fun LockTaskPackages( + chosenPackage: Channel, onChoosePackage: () -> Unit, + lockTaskPackages: StateFlow>, setLockTaskPackage: (String, Boolean) -> Unit +) { + val packages by lockTaskPackages.collectAsStateWithLifecycle() + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() } - LaunchedEffect(Unit) { lockTaskPackages.addAll(Privilege.DPM.getLockTaskPackages(Privilege.DAR)) } - Spacer(Modifier.padding(vertical = 5.dp)) - if(lockTaskPackages.isEmpty()) Text(text = stringResource(R.string.none)) - for(i in lockTaskPackages) { - ListItem(i) { lockTaskPackages -= i } - } - OutlinedTextField( - value = input, - onValueChange = { input = it }, - label = { Text(stringResource(R.string.package_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - trailingIcon = { - Icon(painter = painterResource(R.drawable.list_fill0), contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { choosePackage.launch(null) } - .padding(3.dp)) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { - lockTaskPackages.add(input) - input = "" - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.add)) + LazyColumn { + items(packages, { it.name }) { + ApplicationItem(it) { setLockTaskPackage(it.name, false) } } - Button( - onClick = { - lockTaskPackages.remove(input) - input = "" - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.remove)) + item { + Column(Modifier + .padding(horizontal = HorizontalPadding) + .padding(bottom = 40.dp)) { + PackageNameTextField(packageName, onChoosePackage, + Modifier.padding(vertical = 3.dp)) { packageName = it } + Button( + onClick = { + setLockTaskPackage(packageName, true) + packageName = "" + }, + modifier = Modifier.fillMaxWidth(), + enabled = packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } + Notes(R.string.info_lock_task_packages) + Spacer(Modifier.height(BottomPadding)) + } } } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - Privilege.DPM.setLockTaskPackages(Privilege.DAR, lockTaskPackages.toTypedArray()) - context.showOperationResultToast(true) - } - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_lock_task_packages) } @RequiresApi(28) @Composable -private fun ColumnScope.LockTaskFeatures() { +private fun LockTaskFeatures( + getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String? +) { val context = LocalContext.current - var flags by remember { mutableIntStateOf(0) } - var custom by rememberSaveable { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - fun refresh() { - flags = Privilege.DPM.getLockTaskFeatures(Privilege.DAR) - custom = flags != 0 - } - LaunchedEffect(Unit) { refresh() } - Spacer(Modifier.padding(vertical = 5.dp)) - FullWidthRadioButtonItem(R.string.disable_all, !custom) { custom = false } - FullWidthRadioButtonItem(R.string.custom, custom) { custom = true } - AnimatedVisibility(custom, Modifier.padding(top = 4.dp)) { - Column { - listOf( - DevicePolicyManager.LOCK_TASK_FEATURE_SYSTEM_INFO to R.string.ltf_sys_info, - DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS to R.string.ltf_notifications, - DevicePolicyManager.LOCK_TASK_FEATURE_HOME to R.string.ltf_home, - DevicePolicyManager.LOCK_TASK_FEATURE_OVERVIEW to R.string.ltf_overview, - DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS to R.string.ltf_global_actions, - DevicePolicyManager.LOCK_TASK_FEATURE_KEYGUARD to R.string.ltf_keyguard - ).let { - if(VERSION.SDK_INT >= 30) - it.plus(DevicePolicyManager.LOCK_TASK_FEATURE_BLOCK_ACTIVITY_START_IN_TASK to R.string.ltf_block_activity_start_in_task) - else it - }.forEach { (id, title) -> - FullWidthCheckBoxItem(title, flags and id != 0) { flags = flags xor id } - } - } - } - Button( - modifier = Modifier + var flags by rememberSaveable { mutableIntStateOf(getLockTaskFeatures()) } + var errorMessage by rememberSaveable { mutableStateOf(null) } + Column( + Modifier .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding), - onClick = { - try { - Privilege.DPM.setLockTaskFeatures(Privilege.DAR, flags) - context.showOperationResultToast(true) - } catch (e: IllegalArgumentException) { - errorMessage = e.message - } - refresh() - } + .verticalScroll(rememberScrollState()) ) { - Text(stringResource(R.string.apply)) + Spacer(Modifier.padding(vertical = 5.dp)) + listOf( + DevicePolicyManager.LOCK_TASK_FEATURE_SYSTEM_INFO to R.string.ltf_sys_info, + DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS to R.string.ltf_notifications, + DevicePolicyManager.LOCK_TASK_FEATURE_HOME to R.string.ltf_home, + DevicePolicyManager.LOCK_TASK_FEATURE_OVERVIEW to R.string.ltf_overview, + DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS to R.string.ltf_global_actions, + DevicePolicyManager.LOCK_TASK_FEATURE_KEYGUARD to R.string.ltf_keyguard + ).let { + if(VERSION.SDK_INT >= 30) it.plus( + DevicePolicyManager.LOCK_TASK_FEATURE_BLOCK_ACTIVITY_START_IN_TASK to + R.string.ltf_block_activity_start_in_task) + else it + }.forEach { (id, title) -> + FullWidthCheckBoxItem(title, flags and id != 0) { flags = flags xor id } + } + Button( + onClick = { + val result = setLockTaskFeature(flags) + if (result == null) { + context.showOperationResultToast(true) + } else { + errorMessage = result + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = HorizontalPadding) + ) { + Text(stringResource(R.string.apply)) + } + Spacer(Modifier.height(BottomPadding)) + ErrorDialog(errorMessage) { errorMessage = null } } - ErrorDialog(errorMessage) { errorMessage = null } } data class CaCertInfo( val hash: String, - val data: ByteArray + val serialNumber: String, + val issuer: String, + val subject: String, + val issuedTime: Long, + val expiresTime: Long, + val bytes: ByteArray ) @Serializable object CaCert @OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class) @Composable -fun CaCertScreen(onNavigateUp: () -> Unit) { +fun CaCertScreen( + caCertificates: StateFlow>, getCerts: () -> Unit, + selectedCaCert: MutableStateFlow, selectCaCert: (CaCertInfo) -> Unit, + installCert: () -> Boolean, parseCert: (Uri) -> Unit, + exportCert: (Uri) -> Unit, uninstallCert: () -> Unit, + uninstallAllCerts: () -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current /** 0:none, 1:install, 2:info, 3:uninstall all */ - var dialog by remember { mutableIntStateOf(0) } - var caCertByteArray by remember { mutableStateOf(byteArrayOf()) } - val coroutine = rememberCoroutineScope() - val getCertLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {uri -> - if(uri != null) { - uriToStream(context, uri) { - caCertByteArray = it.readBytes() - } + var dialog by rememberSaveable { mutableIntStateOf(0) } + val caCerts by caCertificates.collectAsStateWithLifecycle() + val selectedCert by selectedCaCert.collectAsStateWithLifecycle() + val getCertLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + parseCert(uri) dialog = 1 } } - val exportCertLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument()) { uri -> - if(uri != null) { - context.contentResolver.openOutputStream(uri)?.use { - it.write(caCertByteArray) - } - context.showOperationResultToast(true) - } + val exportCertLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument()) { uri -> + if (uri != null) exportCert(uri) } - val caCerts = remember { mutableStateListOf() } - fun refresh() { - caCerts.clear() - coroutine.launch(Dispatchers.IO) { - val md = MessageDigest.getInstance("SHA-256") - Privilege.DPM.getInstalledCaCerts(Privilege.DAR).forEach { ba -> - val hash = md.digest(ba).toHexString() - withContext(Dispatchers.Main) { caCerts += CaCertInfo(hash, ba) } - } - } - } - LaunchedEffect(Unit) { refresh() } + LaunchedEffect(Unit) { getCerts() } Scaffold( topBar = { TopAppBar( @@ -1442,7 +1404,7 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { Icon(Icons.Default.Add, stringResource(R.string.install)) } }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn( Modifier @@ -1455,7 +1417,7 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { Modifier .fillMaxWidth() .clickable { - caCertByteArray = cert.data + selectCaCert(cert) dialog = 2 } .animateItem() @@ -1466,40 +1428,34 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { HorizontalDivider() } item { - if(caCerts.isEmpty()) Text(stringResource(R.string.no_ca_cert), Modifier.padding(top = 8.dp), colorScheme.onSurfaceVariant) - else Spacer(Modifier.padding(vertical = 30.dp)) + Spacer(Modifier.height(BottomPadding)) } } - if(dialog != 0) AlertDialog( - text = { - if(dialog == 3) Text(stringResource(R.string.uninstall_all_user_ca_cert)) - else { - var text: String - val sha256 = MessageDigest.getInstance("SHA-256").digest(caCertByteArray).toHexString() - try { - val cf = CertificateFactory.getInstance("X.509") - val cert = cf.generateCertificate(caCertByteArray.inputStream()) as X509Certificate - text = "Serial number\n" + cert.serialNumber.toString(16) + "\n\n" + - "Subject\n" + cert.subjectX500Principal.name + "\n\n" + - "Issuer\n" + cert.issuerX500Principal.name + "\n\n" + - "Issued on: " + parseDate(cert.notBefore) + "\n" + - "Expires on: " + parseDate(cert.notAfter) + "\n\n" + - "SHA-256 fingerprint" + "\n$sha256" - } catch(e: Exception) { - e.printStackTrace() - text = stringResource(R.string.parse_cert_failed) - } + if (selectedCert != null && (dialog == 1 || dialog == 2)) { + val cert = selectedCert!! + AlertDialog( + text = { Column(Modifier.verticalScroll(rememberScrollState())) { - SelectionContainer { - Text(text) - } - if(dialog == 2) Row(Modifier - .fillMaxWidth() - .padding(top = 4.dp), Arrangement.SpaceBetween) { + 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( onClick = { - Privilege.DPM.uninstallCaCert(Privilege.DAR, caCertByteArray) - refresh() + uninstallCert() dialog = 0 }, modifier = Modifier.fillMaxWidth(0.49F), @@ -1509,7 +1465,7 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { } FilledTonalButton( onClick = { - exportCertLauncher.launch(sha256.substring(0..7) + ".0") + exportCertLauncher.launch(cert.hash.substring(0..7) + ".0") }, modifier = Modifier.fillMaxWidth(0.96F) ) { @@ -1517,34 +1473,58 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { } } } - } - }, - confirmButton = { - TextButton({ - try { - if(dialog == 1) { - context.showOperationResultToast(Privilege.DPM.installCaCert(Privilege.DAR, caCertByteArray)) + }, + confirmButton = { + if (dialog == 1) { + TextButton({ + context.showOperationResultToast(installCert()) + dialog = 0 + }) { + Text(stringResource(R.string.install)) } - if(dialog == 3) { - Privilege.DPM.uninstallAllUserCaCerts(Privilege.DAR) + } else { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.confirm)) } - refresh() - dialog = 0 - } catch(e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) } - }) { - Text(stringResource(if(dialog == 1) R.string.install else R.string.confirm)) - } - }, - dismissButton = { - if(dialog != 2) TextButton({ dialog = 0 }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { dialog = 0 } - ) + }, + dismissButton = { + if (dialog == 1) { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.cancel)) + } + } + }, + onDismissRequest = { dialog = 0 } + ) + } + if (dialog == 3) { + AlertDialog( + text = { + Text(stringResource(R.string.uninstall_all_user_ca_cert)) + }, + confirmButton = { + TextButton({ + uninstallAllCerts() + dialog = 0 + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = 0 } + ) + } } } @@ -1552,97 +1532,118 @@ fun CaCertScreen(onNavigateUp: () -> Unit) { @RequiresApi(24) @Composable -fun SecurityLoggingScreen(onNavigateUp: () -> Unit) { +fun SecurityLoggingScreen( + getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, exportLogs: (Uri, () -> Unit) -> Unit, + getCount: () -> Int, deleteLogs: () -> Unit, getPRLogs: () -> Boolean, + exportPRLogs: (Uri, () -> Unit) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current - val logFile = context.filesDir.resolve("SecurityLogs.json") - var fileSize by remember { mutableLongStateOf(0) } - LaunchedEffect(Unit) { fileSize = logFile.length() } - var preRebootSecurityLogs by remember { mutableStateOf(byteArrayOf()) } - val exportPreRebootSecurityLogs = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - if(uri != null) context.contentResolver.openOutputStream(uri)?.use { outStream -> - preRebootSecurityLogs.inputStream().copyTo(outStream) + var enabled by rememberSaveable { mutableStateOf(getEnabled()) } + var logsCount by rememberSaveable { mutableIntStateOf(getCount()) } + var exporting by rememberSaveable { mutableStateOf(false) } + var dialog by rememberSaveable { mutableStateOf(false) } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) { + exporting = true + exportLogs(it) { + exporting = false + context.showOperationResultToast(true) + } } } - val exportSecurityLogs = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - if(uri != null) context.contentResolver.openOutputStream(uri)?.use { outStream -> - outStream.write("[".toByteArray()) - logFile.inputStream().use { it.copyTo(outStream) } - outStream.write("]".toByteArray()) - context.showOperationResultToast(true) + val exportPRLogsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) { + exporting = true + exportPRLogs(it) { + exporting = false + context.showOperationResultToast(true) + } } } - MyScaffold(R.string.security_logging, onNavigateUp) { + MyScaffold(R.string.security_logging, onNavigateUp, 0.dp) { SwitchItem( - R.string.enable, - getState = { Privilege.DPM.isSecurityLoggingEnabled(Privilege.DAR) }, - onCheckedChange = { Privilege.DPM.setSecurityLoggingEnabled(Privilege.DAR, it) }, - padding = false + R.string.enable, enabled, { + setEnabled(it) + enabled = it + } ) - Text(stringResource(R.string.log_file_size_is, formatFileSize(fileSize))) - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - exportSecurityLogs.launch("SecurityLogs.json") - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.export_logs)) - } - Button( - onClick = { - logFile.delete() - fileSize = logFile.length() - }, - enabled = fileSize > 0, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.delete_logs)) - } + Text( + stringResource(R.string.n_logs_in_total, logsCount), + Modifier.padding(HorizontalPadding) + ) + Button( + { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportLauncher.launch("security_logs_$date") + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + logsCount > 0 + ) { + Text(stringResource(R.string.export_logs)) } - Notes(R.string.info_security_log) - Spacer(Modifier.padding(vertical = 5.dp)) + if (logsCount > 0) FilledTonalButton( + { dialog = true }, + Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp) + ) { + Text(stringResource(R.string.delete_logs)) + } + Notes(R.string.info_security_log, HorizontalPadding) Button( onClick = { - val logs = Privilege.DPM.retrievePreRebootSecurityLogs(Privilege.DAR) - if(logs == null) { - context.popToast(R.string.no_logs) - return@Button + if (getPRLogs()) { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportPRLogsLauncher.launch("pre_reboot_security_logs_$date") } else { - val outputStream = ByteArrayOutputStream() - outputStream.write("[".encodeToByteArray()) - processSecurityLogs(logs, outputStream) - outputStream.write("]".encodeToByteArray()) - preRebootSecurityLogs = outputStream.toByteArray() - exportPreRebootSecurityLogs.launch("PreRebootSecurityLogs.json") + context.showOperationResultToast(false) } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 15.dp) ) { Text(stringResource(R.string.pre_reboot_security_logs)) } - Notes(R.string.info_pre_reboot_security_log) + Notes(R.string.info_pre_reboot_security_log, HorizontalPadding) } + if (exporting) CircularProgressDialog { exporting = false } + if (dialog) AlertDialog( + text = { Text(stringResource(R.string.delete_logs)) }, + confirmButton = { + TextButton({ + deleteLogs() + logsCount = 0 + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) } @Serializable object DisableAccountManagement @Composable -fun DisableAccountManagementScreen(onNavigateUp: () -> Unit) { +fun DisableAccountManagementScreen( + mdAccounts: StateFlow>, getMdAccounts: () -> Unit, + setMdAccount: (String, Boolean) -> Unit, onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current + val list by mdAccounts.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getMdAccounts() } MyScaffold(R.string.disable_account_management, onNavigateUp) { - val list = remember { mutableStateListOf() } - fun refreshList() { - list.clear() - Privilege.DPM.accountTypesWithManagementDisabled?.forEach { list += it } - } - LaunchedEffect(Unit) { refreshList() } + Column(modifier = Modifier.animateContentSize()) { - if(list.isEmpty()) Text(stringResource(R.string.none)) for(i in list) { ListItem(i) { - Privilege.DPM.setAccountManagementDisabled(Privilege.DAR, i, false) - refreshList() + setMdAccount(i, false) } } } @@ -1654,9 +1655,8 @@ fun DisableAccountManagementScreen(onNavigateUp: () -> Unit) { trailingIcon = { IconButton( onClick = { - Privilege.DPM.setAccountManagementDisabled(Privilege.DAR, inputText, true) + setMdAccount(inputText, true) inputText = "" - refreshList() }, enabled = inputText != "" ) { @@ -1674,58 +1674,45 @@ fun DisableAccountManagementScreen(onNavigateUp: () -> Unit) { } } +data class FrpPolicyInfo( + val supported: Boolean, + val usePolicy: Boolean, + val enabled: Boolean, + val accounts: List +) + @Serializable object FrpPolicy @RequiresApi(30) @Composable -fun FrpPolicyScreen(onNavigateUp: () -> Unit) { +fun FrpPolicyScreen( + frpPolicy: FrpPolicyInfo, setFrpPolicy: (FrpPolicyInfo) -> Unit, + onNavigateUp: () -> Unit +) { + val context = LocalContext.current val focusMgr = LocalFocusManager.current - var usePolicy by remember { mutableStateOf(false) } - var enabled by remember { mutableStateOf(false) } - var unsupported by remember { mutableStateOf(false) } - val accountList = remember { mutableStateListOf() } - var inputAccount by remember { mutableStateOf("") } - LaunchedEffect(Unit) { - var policy: FactoryResetProtectionPolicy? = null - try { - policy = Privilege.DPM.getFactoryResetProtectionPolicy(Privilege.DAR) - } catch(_: UnsupportedOperationException) { - unsupported = true - policy = null - } finally { - if(policy == null) { - usePolicy = false - } else { - usePolicy = true - enabled = policy.isFactoryResetProtectionEnabled - } - } - } - MyScaffold(R.string.frp_policy, onNavigateUp) { - if(unsupported) { + var usePolicy by rememberSaveable { mutableStateOf(frpPolicy.usePolicy) } + var enabled by rememberSaveable { mutableStateOf(frpPolicy.enabled) } + var supported by rememberSaveable { mutableStateOf(frpPolicy.supported) } + val accountList = rememberSaveable { mutableStateListOf(*frpPolicy.accounts.toTypedArray()) } + var inputAccount by rememberSaveable { mutableStateOf("") } + MyScaffold(R.string.frp_policy, onNavigateUp, 0.dp) { + if (!supported) { Column( Modifier .fillMaxWidth() - .padding(vertical = 8.dp) + .padding(HorizontalPadding, 8.dp) .clip(RoundedCornerShape(8.dp)) .background(colorScheme.primaryContainer) ) { Text(stringResource(R.string.frp_not_supported), Modifier.padding(8.dp), color = colorScheme.onPrimaryContainer) } } else { - Row( - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 6.dp, vertical = 8.dp) - ) { - Text(stringResource(R.string.use_policy), style = typography.titleLarge) - Switch(checked = usePolicy, onCheckedChange = { usePolicy = it }) - } + SwitchItem(R.string.use_policy, usePolicy, { usePolicy = it }) } - AnimatedVisibility(usePolicy) { - Column { - CheckBoxItem(R.string.enable_frp, enabled) { enabled = it } + if (usePolicy) { + FullWidthCheckBoxItem(R.string.enable_frp, enabled) { enabled = it } + Column(Modifier.padding(horizontal = HorizontalPadding)) { Text(stringResource(R.string.account_list_is)) Column(modifier = Modifier.animateContentSize()) { if(accountList.isEmpty()) Text(stringResource(R.string.none)) @@ -1752,65 +1739,70 @@ fun FrpPolicyScreen(onNavigateUp: () -> Unit) { keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), modifier = Modifier.fillMaxWidth() ) + Button( + onClick = { + focusMgr.clearFocus() + setFrpPolicy(FrpPolicyInfo(true, usePolicy, enabled, accountList)) + context.showOperationResultToast(true) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.apply)) + } } } - if(!unsupported) Button( - onClick = { - focusMgr.clearFocus() - val policy = FactoryResetProtectionPolicy.Builder() - .setFactoryResetProtectionEnabled(enabled) - .setFactoryResetProtectionAccounts(accountList) - .build() - Privilege.DPM.setFactoryResetProtectionPolicy(Privilege.DAR, policy) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_frp_policy) + Notes(R.string.info_frp_policy, HorizontalPadding) } } @Serializable object WipeData @Composable -fun WipeDataScreen(onNavigateUp: () -> Unit) { +fun WipeDataScreen( + wipeData: (Boolean, Int, String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager val privilege by Privilege.status.collectAsStateWithLifecycle() val focusMgr = LocalFocusManager.current - var flag by remember { mutableIntStateOf(0) } - var warning by remember { mutableStateOf(false) } - var wipeDevice by remember { mutableStateOf(false) } - var silent by remember { mutableStateOf(false) } - var reason by remember { mutableStateOf("") } - MyScaffold(R.string.wipe_data, onNavigateUp) { - CheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { flag = flag xor WIPE_EXTERNAL_STORAGE } - if(VERSION.SDK_INT >= 22 && privilege.device) CheckBoxItem( - R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0) { flag = flag xor WIPE_RESET_PROTECTION_DATA } - if(VERSION.SDK_INT >= 28) CheckBoxItem(R.string.wipe_euicc, flag and WIPE_EUICC != 0) { flag = flag xor WIPE_EUICC } - if(VERSION.SDK_INT >= 29) CheckBoxItem(R.string.wipe_silently, silent) { silent = it } - AnimatedVisibility(!silent && VERSION.SDK_INT >= 28) { - OutlinedTextField( - value = reason, onValueChange = { reason = it }, - label = { Text(stringResource(R.string.reason)) }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp) - ) + 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 } - Spacer(Modifier.padding(vertical = 5.dp)) - if(VERSION.SDK_INT < 34 || !userManager.isSystemUser) { + if(VERSION.SDK_INT >= 22 && privilege.device) FullWidthCheckBoxItem( + R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0) { + flag = flag xor WIPE_RESET_PROTECTION_DATA + } + if(VERSION.SDK_INT >= 28) FullWidthCheckBoxItem(R.string.wipe_euicc, + flag and WIPE_EUICC != 0) { + flag = flag xor WIPE_EUICC + } + if (VERSION.SDK_INT < 34 || !userManager.isSystemUser) { + if(VERSION.SDK_INT >= 29) CheckBoxItem(R.string.wipe_silently, flag and WIPE_SILENTLY != 0) { + flag = flag xor WIPE_SILENTLY + reason = "" + } + AnimatedVisibility(flag and WIPE_SILENTLY != 0 && VERSION.SDK_INT >= 28) { + OutlinedTextField( + value = reason, onValueChange = { reason = it }, + label = { Text(stringResource(R.string.reason)) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) + } Button( onClick = { focusMgr.clearFocus() - wipeDevice = false - warning = true + dialog = 1 }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 5.dp) ) { Text("WipeData") } @@ -1819,18 +1811,16 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { Button( onClick = { focusMgr.clearFocus() - wipeDevice = true - warning = true + dialog = 2 }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 5.dp) ) { Text("WipeDevice") } } } - if(warning) { - LaunchedEffect(Unit) { silent = reason == "" } + if (dialog != 0) { AlertDialog( title = { Text(text = stringResource(R.string.warning), color = colorScheme.error) @@ -1844,7 +1834,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { color = colorScheme.error ) }, - onDismissRequest = { warning = false }, + onDismissRequest = { dialog = 0 }, confirmButton = { var timer by remember { mutableIntStateOf(6) } LaunchedEffect(Unit) { @@ -1856,16 +1846,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { val timerText = if(timer > 0) "(${timer}s)" else "" TextButton( onClick = { - if(silent && VERSION.SDK_INT >= 29) { flag = flag or WIPE_SILENTLY } - if(wipeDevice && VERSION.SDK_INT >= 34) { - Privilege.DPM.wipeDevice(flag) - } else { - if(VERSION.SDK_INT >= 28 && reason != "") { - Privilege.DPM.wipeData(flag, reason) - } else { - Privilege.DPM.wipeData(flag) - } - } + wipeData(dialog == 2, flag, reason) }, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error), modifier = Modifier.animateContentSize(), @@ -1875,7 +1856,7 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { } }, dismissButton = { - TextButton(onClick = { warning = false }) { + TextButton(onClick = { dialog = 0 }) { Text(stringResource(R.string.cancel)) } } @@ -1883,35 +1864,53 @@ fun WipeDataScreen(onNavigateUp: () -> Unit) { } } +data class SystemUpdatePolicyInfo(val type: Int, val start: Int, val end: Int) +data class PendingSystemUpdateInfo(val exists: Boolean, val time: Long, val securityPatch: Boolean) + @Serializable object SetSystemUpdatePolicy @RequiresApi(23) @Composable -fun SystemUpdatePolicyScreen(onNavigateUp: () -> Unit) { +fun SystemUpdatePolicyScreen( + getPolicy: () -> SystemUpdatePolicyInfo, setPolicy: (SystemUpdatePolicyInfo) -> Unit, + getPendingUpdate: () -> PendingSystemUpdateInfo, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current + var policyType by remember { mutableIntStateOf(-1) } + var windowedPolicyStart by remember { mutableStateOf("") } + var windowedPolicyEnd by remember { mutableStateOf("") } + var pendingUpdate by remember { mutableStateOf(PendingSystemUpdateInfo(false, 0, false)) } + LaunchedEffect(Unit) { + val policy = getPolicy() + policyType = policy.type + if (policy.type == TYPE_INSTALL_WINDOWED) { + windowedPolicyStart = policy.start.toString() + windowedPolicyEnd = policy.end.toString() + } + if (VERSION.SDK_INT >= 26) pendingUpdate = getPendingUpdate() + } MyScaffold(R.string.system_update_policy, onNavigateUp, 0.dp) { - var selectedPolicy by remember { mutableStateOf(Privilege.DPM.systemUpdatePolicy?.policyType) } + FullWidthRadioButtonItem(R.string.none, policyType == -1) { policyType = -1 } FullWidthRadioButtonItem( R.string.system_update_policy_automatic, - selectedPolicy == TYPE_INSTALL_AUTOMATIC - ) { selectedPolicy = TYPE_INSTALL_AUTOMATIC } + policyType == TYPE_INSTALL_AUTOMATIC + ) { policyType = TYPE_INSTALL_AUTOMATIC } FullWidthRadioButtonItem( R.string.system_update_policy_install_windowed, - selectedPolicy == TYPE_INSTALL_WINDOWED - ) { selectedPolicy = TYPE_INSTALL_WINDOWED } + policyType == TYPE_INSTALL_WINDOWED + ) { policyType = TYPE_INSTALL_WINDOWED } FullWidthRadioButtonItem( R.string.system_update_policy_postpone, - selectedPolicy == TYPE_POSTPONE - ) { selectedPolicy = TYPE_POSTPONE } - FullWidthRadioButtonItem(R.string.none, selectedPolicy == null) { selectedPolicy = null } - var windowedPolicyStart by remember { mutableStateOf("") } - var windowedPolicyEnd by remember { mutableStateOf("") } - AnimatedVisibility(selectedPolicy == 2) { + policyType == TYPE_POSTPONE + ) { policyType = TYPE_POSTPONE } + AnimatedVisibility(policyType == TYPE_INSTALL_WINDOWED) { Column(Modifier.padding(horizontal = HorizontalPadding)) { - Row(Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), Arrangement.SpaceBetween) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), Arrangement.SpaceBetween + ) { OutlinedTextField( value = windowedPolicyStart, label = { Text(stringResource(R.string.start_time)) }, @@ -1922,47 +1921,40 @@ fun SystemUpdatePolicyScreen(onNavigateUp: () -> Unit) { ) OutlinedTextField( value = windowedPolicyEnd, - onValueChange = {windowedPolicyEnd = it }, + onValueChange = { windowedPolicyEnd = it }, label = { Text(stringResource(R.string.end_time)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier - .fillMaxWidth(0.96F) - .padding(bottom = 2.dp) + modifier = Modifier.fillMaxWidth(0.96F) ) } - Text(stringResource(R.string.minutes_in_one_day), color = colorScheme.onSurfaceVariant, style = typography.bodyMedium) + Text(stringResource(R.string.minutes_in_one_day), + color = colorScheme.onSurfaceVariant, style = typography.bodyMedium) } } Button( onClick = { - val policy = - when(selectedPolicy) { - TYPE_INSTALL_AUTOMATIC-> SystemUpdatePolicy.createAutomaticInstallPolicy() - TYPE_INSTALL_WINDOWED-> SystemUpdatePolicy.createWindowedInstallPolicy(windowedPolicyStart.toInt(), windowedPolicyEnd.toInt()) - TYPE_POSTPONE-> SystemUpdatePolicy.createPostponeInstallPolicy() - else -> null - } - Privilege.DPM.setSystemUpdatePolicy(Privilege.DAR, policy) + setPolicy(SystemUpdatePolicyInfo( + policyType, windowedPolicyStart.toIntOrNull() ?: 0, + windowedPolicyEnd.toIntOrNull() ?: 0 + )) context.showOperationResultToast(true) }, modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding) + .padding(vertical = 4.dp, horizontal = HorizontalPadding), + enabled = policyType != TYPE_INSTALL_WINDOWED || + listOf(windowedPolicyStart, windowedPolicyEnd).map { it.toIntOrNull() } + .all { it != null && it <= 1440 } ) { Text(stringResource(R.string.apply)) } - if(VERSION.SDK_INT >= 26) { - val sysUpdateInfo = Privilege.DPM.getPendingSystemUpdate(Privilege.DAR) + if (VERSION.SDK_INT >= 26) { Column(Modifier.padding(HorizontalPadding)) { - if(sysUpdateInfo != null) { - Text(text = stringResource(R.string.update_received_time, Date(sysUpdateInfo.receivedTime))) - val securityPatchStateText = when(sysUpdateInfo.securityPatchState) { - SystemUpdateInfo.SECURITY_PATCH_STATE_FALSE -> R.string.no - SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE -> R.string.yes - else -> R.string.unknown - } - Text(text = stringResource(R.string.is_security_patch, stringResource(securityPatchStateText))) + if (pendingUpdate.exists) { + Text(stringResource(R.string.update_received_time, formatDate(pendingUpdate.time))) + Text(stringResource(R.string.is_security_patch, + stringResource(pendingUpdate.securityPatch.yesOrNo))) } else { Text(text = stringResource(R.string.no_system_update)) } @@ -1975,24 +1967,12 @@ fun SystemUpdatePolicyScreen(onNavigateUp: () -> Unit) { @SuppressLint("NewApi") @Composable -fun InstallSystemUpdateScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val callback = object: InstallSystemUpdateCallback() { - override fun onInstallUpdateError(errorCode: Int, errorMessage: String) { - super.onInstallUpdateError(errorCode, errorMessage) - val errDetail = when(errorCode) { - UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low - UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid - UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver - UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist - else -> R.string.unknown - } - val errMsg = context.getString(R.string.install_system_update_failed) + context.getString(errDetail) - context.popToast(errMsg) - } - } +fun InstallSystemUpdateScreen( + installSystemUpdate: (Uri, (String) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { var uri by remember { mutableStateOf(null) } - var errorMessage by remember { mutableStateOf(null) } + var installing by rememberSaveable { mutableStateOf(false) } + var errorMessage by rememberSaveable { mutableStateOf(null) } val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri = it } MyScaffold(R.string.install_system_update, onNavigateUp) { Button( @@ -2005,21 +1985,17 @@ fun InstallSystemUpdateScreen(onNavigateUp: () -> Unit) { ) { Text(stringResource(R.string.select_ota_package)) } - AnimatedVisibility(uri != null) { - Button( - onClick = { - val executor = Executors.newCachedThreadPool() - try { - Privilege.DPM.installSystemUpdate(Privilege.DAR, uri!!, executor, callback) - context.popToast(R.string.start_install_system_update) - } catch(e: Exception) { - errorMessage = e.message - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.install_system_update)) - } + Button( + onClick = { + installing = true + installSystemUpdate(uri!!) { message -> + errorMessage = message + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = uri != null && !installing + ) { + Text(stringResource(R.string.install_system_update)) } Spacer(Modifier.padding(vertical = 10.dp)) Notes(R.string.auto_reboot_after_install_succeed) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index 8527a26..8fe03b4 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -1,22 +1,20 @@ package com.bintianqi.owndroid.dpm -import android.os.Build -import android.os.UserManager -import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi -import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime +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.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -24,6 +22,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,10 +38,8 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf 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 @@ -54,20 +51,26 @@ 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 kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable @Serializable data class Restriction( val id: String, - @StringRes val name: Int, - @DrawableRes val icon: Int, + val name: Int, + val icon: Int, val requiresApi: Int = 0 ) @@ -76,12 +79,12 @@ data class Restriction( @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(24) @Composable -fun UserRestrictionScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun UserRestrictionScreen( + getRestrictions: () -> Unit,onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit +) { val privilege by Privilege.status.collectAsStateWithLifecycle() val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - fun navigateToOptions(title: Int, items: List) { - onNavigate(UserRestrictionOptions(title, items)) - } + LaunchedEffect(Unit) { getRestrictions() } Scaffold( Modifier.nestedScroll(sb.nestedScrollConnection), topBar = { @@ -96,7 +99,7 @@ fun UserRestrictionScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -114,54 +117,51 @@ fun UserRestrictionScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { Text(text = stringResource(R.string.some_features_invalid_in_work_profile), modifier = Modifier.padding(start = 16.dp)) } Spacer(Modifier.padding(vertical = 2.dp)) - FunctionItem(R.string.network, icon = R.drawable.language_fill0) { - navigateToOptions(R.string.network, RestrictionData.internet) + UserRestrictionCategory.entries.forEach { + FunctionItem(it.title, icon = it.icon) { + onNavigate(UserRestrictionOptions(it.name)) + } } - FunctionItem(R.string.connectivity, icon = R.drawable.devices_other_fill0) { - navigateToOptions(R.string.connectivity, RestrictionData.connectivity) - } - FunctionItem(R.string.applications, icon = R.drawable.apps_fill0) { - navigateToOptions(R.string.applications, RestrictionData.applications) - } - FunctionItem(R.string.users, icon = R.drawable.account_circle_fill0) { - navigateToOptions(R.string.users, RestrictionData.users) - } - FunctionItem(R.string.media, icon = R.drawable.volume_up_fill0) { - navigateToOptions(R.string.media, RestrictionData.media) - } - FunctionItem(R.string.other, icon = R.drawable.more_horiz_fill0) { - navigateToOptions(R.string.other, RestrictionData.other) + 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) } } } } @Serializable -data class UserRestrictionOptions( - val title: Int, val items: List -) +data class UserRestrictionOptions(val id: String) @RequiresApi(24) @Composable fun UserRestrictionOptionsScreen( - data: UserRestrictionOptions, onNavigateUp: () -> Unit + args: UserRestrictionOptions, userRestrictions: StateFlow>, + setRestriction: (String, Boolean) -> Boolean, setShortcut: (String) -> Boolean, + onNavigateUp: () -> Unit ) { val context = LocalContext.current - val status = remember { mutableStateMapOf() } - fun refresh() { - val restrictions = Privilege.DPM.getUserRestrictions(Privilege.DAR) - data.items.forEach { - status.put(it.id, restrictions.getBoolean(it.id)) - } - } - LaunchedEffect(Unit) { refresh() } - MyLazyScaffold(data.title, onNavigateUp) { - items(data.items.filter { Build.VERSION.SDK_INT >= it.requiresApi }) { restriction -> + val status by userRestrictions.collectAsStateWithLifecycle() + val (title, items) = UserRestrictionsRepository.getData(args.id) + MyLazyScaffold(title, onNavigateUp) { + items(items) { restriction -> Row( - Modifier.fillMaxWidth().padding(15.dp, 6.dp), + Modifier + .fillMaxWidth() + .combinedClickable(onClick = {}, onLongClick = { + if (!setShortcut(restriction.id)) context.popToast(R.string.unsupported) + }) + .padding(15.dp, 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Row(Modifier.fillMaxWidth(0.8F), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { Icon(painterResource(restriction.icon), null, Modifier.padding(start = 6.dp, end = 16.dp)) Column { Text(stringResource(restriction.name), style = typography.titleMedium) @@ -174,125 +174,32 @@ fun UserRestrictionOptionsScreen( Switch( status[restriction.id] == true, { - try { - if (it) { - Privilege.DPM.addUserRestriction(Privilege.DAR, restriction.id) - } else { - Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction.id) - } - } catch (e: Exception) { - e.printStackTrace() + if (!setRestriction(restriction.id, it)) { context.showOperationResultToast(false) } - refresh() - } + }, + Modifier.padding(start = 8.dp) ) } } item { - Spacer(Modifier.padding(vertical = 30.dp)) + Spacer(Modifier.height(BottomPadding)) } } } -@Suppress("InlinedApi") -object RestrictionData { - val internet = listOf( - Restriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS, R.string.config_mobile_network, R.drawable.signal_cellular_alt_fill0), - Restriction(UserManager.DISALLOW_CONFIG_WIFI, R.string.config_wifi, R.drawable.wifi_fill0), - Restriction(UserManager.DISALLOW_DATA_ROAMING, R.string.data_roaming, R.drawable.network_cell_fill0, 24), - Restriction(UserManager.DISALLOW_CELLULAR_2G, R.string.cellular_2g, R.drawable.network_cell_fill0, 34), - Restriction(UserManager.DISALLOW_ULTRA_WIDEBAND_RADIO, R.string.ultra_wideband_radio, R.drawable.wifi_tethering_fill0, 34), - Restriction(UserManager.DISALLOW_ADD_WIFI_CONFIG, R.string.add_wifi_conf, R.drawable.wifi_fill0, 33), - Restriction(UserManager.DISALLOW_CHANGE_WIFI_STATE, R.string.change_wifi_state, R.drawable.wifi_fill0, 33), - Restriction(UserManager.DISALLOW_WIFI_DIRECT, R.string.wifi_direct, R.drawable.wifi_tethering_fill0), - Restriction(UserManager.DISALLOW_WIFI_TETHERING, R.string.wifi_tethering, R.drawable.wifi_tethering_fill0, 33), - Restriction(UserManager.DISALLOW_SHARING_ADMIN_CONFIGURED_WIFI, R.string.share_admin_wifi, R.drawable.share_fill0, 33), - Restriction(UserManager.DISALLOW_NETWORK_RESET, R.string.network_reset, R.drawable.reset_wrench_fill0, 23), - Restriction(UserManager.DISALLOW_CONFIG_TETHERING, R.string.config_tethering, R.drawable.wifi_tethering_fill0), - Restriction(UserManager.DISALLOW_CONFIG_VPN, R.string.config_vpn, R.drawable.vpn_key_fill0), - Restriction(UserManager.DISALLOW_CONFIG_PRIVATE_DNS, R.string.config_private_dns, R.drawable.dns_fill0, 29), - Restriction(UserManager.DISALLOW_AIRPLANE_MODE, R.string.airplane_mode, R.drawable.airplanemode_active_fill0, 28), - Restriction(UserManager.DISALLOW_CONFIG_CELL_BROADCASTS, R.string.config_cell_broadcasts, R.drawable.cell_tower_fill0), - Restriction(UserManager.DISALLOW_SMS, R.string.sms, R.drawable.sms_fill0), - Restriction(UserManager.DISALLOW_OUTGOING_CALLS, R.string.outgoing_calls, R.drawable.phone_forwarded_fill0), - Restriction(UserManager.DISALLOW_SIM_GLOBALLY, R.string.download_esim, R.drawable.sim_card_download_fill0), - Restriction(UserManager.DISALLOW_THREAD_NETWORK, R.string.thread_network, R.drawable.router_fill0, 36) - ) - val connectivity = listOf( - Restriction(UserManager.DISALLOW_BLUETOOTH, R.string.bluetooth, R.drawable.bluetooth_fill0, 26), - Restriction(UserManager.DISALLOW_BLUETOOTH_SHARING, R.string.bt_share, R.drawable.bluetooth_searching_fill0, 26), - Restriction(UserManager.DISALLOW_SHARE_LOCATION, R.string.share_location, R.drawable.location_on_fill0), - Restriction(UserManager.DISALLOW_CONFIG_LOCATION, R.string.config_location, R.drawable.location_on_fill0, 28), - Restriction(UserManager.DISALLOW_NEAR_FIELD_COMMUNICATION_RADIO, R.string.nfc, R.drawable.nfc_fill0, 35), - Restriction(UserManager.DISALLOW_OUTGOING_BEAM, R.string.outgoing_beam, R.drawable.nfc_fill0, 22), - Restriction(UserManager.DISALLOW_USB_FILE_TRANSFER, R.string.usb_file_transfer, R.drawable.usb_fill0), - Restriction(UserManager.DISALLOW_MOUNT_PHYSICAL_MEDIA, R.string.mount_physical_media, R.drawable.sd_card_fill0), - Restriction(UserManager.DISALLOW_PRINTING, R.string.printing, R.drawable.print_fill0, 28) - ) - val applications = listOf( - Restriction(UserManager.DISALLOW_INSTALL_APPS, R.string.install_app, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY, R.string.install_unknown_src_globally, R.drawable.android_fill0, 29), - Restriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES, R.string.inst_unknown_src, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_UNINSTALL_APPS, R.string.uninstall_app, R.drawable.delete_fill0), - Restriction(UserManager.DISALLOW_APPS_CONTROL, R.string.apps_ctrl, R.drawable.apps_fill0), - Restriction(UserManager.DISALLOW_CONFIG_DEFAULT_APPS, R.string.config_default_apps, R.drawable.apps_fill0, 34) - ) - val media = listOf( - Restriction(UserManager.DISALLOW_CONFIG_BRIGHTNESS, R.string.config_brightness, R.drawable.brightness_5_fill0, 28), - Restriction(UserManager.DISALLOW_CONFIG_SCREEN_TIMEOUT, R.string.config_scr_timeout, R.drawable.screen_lock_portrait_fill0, 28), - Restriction(UserManager.DISALLOW_AMBIENT_DISPLAY, R.string.ambient_display, R.drawable.brightness_5_fill0, 28), - Restriction(UserManager.DISALLOW_ADJUST_VOLUME, R.string.adjust_volume, R.drawable.volume_up_fill0), - Restriction(UserManager.DISALLOW_UNMUTE_MICROPHONE, R.string.unmute_microphone, R.drawable.mic_fill0), - Restriction(UserManager.DISALLOW_CAMERA_TOGGLE, R.string.camera_toggle, R.drawable.cameraswitch_fill0, 31), - Restriction(UserManager.DISALLOW_MICROPHONE_TOGGLE, R.string.microphone_toggle, R.drawable.mic_fill0, 31) - ) - val users = listOf( - Restriction(UserManager.DISALLOW_ADD_USER, R.string.add_user, R.drawable.account_circle_fill0), - Restriction(UserManager.DISALLOW_REMOVE_USER, R.string.remove_user, R.drawable.account_circle_fill0), - Restriction(UserManager.DISALLOW_USER_SWITCH, R.string.switch_user, R.drawable.account_circle_fill0, 28), - Restriction(UserManager.DISALLOW_ADD_MANAGED_PROFILE, R.string.create_work_profile, R.drawable.work_fill0, 26), - Restriction(UserManager.DISALLOW_REMOVE_MANAGED_PROFILE, R.string.delete_work_profile, R.drawable.delete_forever_fill0, 26), - Restriction(UserManager.DISALLOW_ADD_PRIVATE_PROFILE, R.string.create_private_space, R.drawable.lock_fill0, 35), - Restriction(UserManager.DISALLOW_SET_USER_ICON, R.string.set_user_icon, R.drawable.account_circle_fill0, 24), - Restriction(UserManager.DISALLOW_CROSS_PROFILE_COPY_PASTE, R.string.cross_profile_copy, R.drawable.content_paste_fill0), - Restriction(UserManager.DISALLOW_SHARE_INTO_MANAGED_PROFILE, R.string.share_into_managed_profile, R.drawable.share_fill0, 28), - Restriction(UserManager.DISALLOW_UNIFIED_PASSWORD, R.string.unified_pwd, R.drawable.work_fill0, 28) - ) - val other = listOf( - Restriction(UserManager.DISALLOW_AUTOFILL, R.string.autofill, R.drawable.password_fill0, 26), - Restriction(UserManager.DISALLOW_CONFIG_CREDENTIALS, R.string.config_credentials, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_CONTENT_CAPTURE, R.string.content_capture, R.drawable.screenshot_fill0, 29), - Restriction(UserManager.DISALLOW_CONTENT_SUGGESTIONS, R.string.content_suggestions, R.drawable.sms_fill0, 29), - Restriction(UserManager.DISALLOW_ASSIST_CONTENT, R.string.assist_content, R.drawable.info_fill0, 35), - Restriction(UserManager.DISALLOW_CREATE_WINDOWS, R.string.create_windows, R.drawable.web_asset), - Restriction(UserManager.DISALLOW_SET_WALLPAPER, R.string.set_wallpaper, R.drawable.wallpaper_fill0, 24), - Restriction(UserManager.DISALLOW_GRANT_ADMIN, R.string.grant_admin, R.drawable.security_fill0, 34), - Restriction(UserManager.DISALLOW_FUN, R.string.`fun`, R.drawable.stadia_controller_fill0, 23), - Restriction(UserManager.DISALLOW_MODIFY_ACCOUNTS, R.string.modify_accounts, R.drawable.manage_accounts_fill0), - Restriction(UserManager.DISALLOW_CONFIG_LOCALE, R.string.config_locale, R.drawable.language_fill0, 28), - Restriction(UserManager.DISALLOW_CONFIG_DATE_TIME, R.string.config_date_time, R.drawable.schedule_fill0, 28), - Restriction(UserManager.DISALLOW_SYSTEM_ERROR_DIALOGS, R.string.sys_err_dialog, R.drawable.warning_fill0, 28), - Restriction(UserManager.DISALLOW_FACTORY_RESET, R.string.factory_reset, R.drawable.android_fill0), - Restriction(UserManager.DISALLOW_SAFE_BOOT, R.string.safe_boot, R.drawable.security_fill0, 23), - Restriction(UserManager.DISALLOW_DEBUGGING_FEATURES, R.string.debug_features, R.drawable.adb_fill0) - ) -} - @Serializable object UserRestrictionEditor @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(24) @Composable -fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { +fun UserRestrictionEditorScreen( + restrictions: StateFlow>, setRestriction: (String, Boolean) -> Boolean, + onNavigateUp: () -> Unit +) { val context = LocalContext.current - val list = remember { mutableStateListOf() } - fun refresh() { - val restrictions = Privilege.DPM.getUserRestrictions(Privilege.DAR) - list.clear() - list.addAll(restrictions.keySet().filter { restrictions.getBoolean(it) }) - } - LaunchedEffect(Unit) { refresh() } + val map by restrictions.collectAsStateWithLifecycle() + val list = map.filter { it.value }.map { it.key } Scaffold( topBar = { TopAppBar( @@ -300,7 +207,7 @@ fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { navigationIcon = { NavIcon(onNavigateUp) } ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn(Modifier.fillMaxSize().padding(paddingValues)) { items(list, { it }) { @@ -310,32 +217,19 @@ fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { ) { Text(it) IconButton({ - try { - Privilege.DPM.clearUserRestriction(Privilege.DAR, it) - } catch (e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) - } - refresh() + if (!setRestriction(it, false)) context.showOperationResultToast(false) }) { Icon(Icons.Outlined.Delete, null) } } } item { - var input by remember { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } fun add() { - try { - Privilege.DPM.addUserRestriction(Privilege.DAR, input) - input = "" - } catch (e: Exception) { - e.printStackTrace() - context.showOperationResultToast(false) - } - refresh() + if (!setRestriction(input, false)) context.showOperationResultToast(false) } OutlinedTextField( - input, { input = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 20.dp), + input, { input = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), label = { Text("id") }, trailingIcon = { IconButton(::add, enabled = input.isNotBlank()) { @@ -345,6 +239,7 @@ fun UserRestrictionEditorScreen(onNavigateUp: () -> Unit) { keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), keyboardActions = KeyboardActions { add() } ) + Spacer(Modifier.height(BottomPadding)) } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt index 88689fa..2177563 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt @@ -1,18 +1,13 @@ package com.bintianqi.owndroid.dpm import android.app.admin.DevicePolicyManager -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Binder import android.os.Build.VERSION -import android.os.Process -import android.os.UserHandle -import android.os.UserManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -20,6 +15,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -32,8 +28,14 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -44,11 +46,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -62,9 +63,10 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.parseTimestamp +import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog @@ -77,25 +79,23 @@ import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem import com.bintianqi.owndroid.uriToStream import com.bintianqi.owndroid.yesOrNo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable @Serializable object Users @Composable -fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { val context = LocalContext.current val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by remember { mutableIntStateOf(0) } + /** 1: logout */ + var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.users, onNavigateUp, 0.dp) { if(VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated) { - FunctionItem(R.string.logout, icon = R.drawable.logout_fill0) { dialog = 2 } + FunctionItem(R.string.logout, icon = R.drawable.logout_fill0) { dialog = 1 } } FunctionItem(R.string.user_info, icon = R.drawable.person_fill0) { onNavigate(UserInfo) } if(VERSION.SDK_INT >= 28 && privilege.device) { - FunctionItem(R.string.secondary_users, icon = R.drawable.list_fill0) { dialog = 1 } FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(UsersOptions) } } if(privilege.device) { @@ -118,7 +118,11 @@ fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { context.popToast(R.string.select_an_image) launcher.launch("image/*") } - if(changeUserIconDialog) ChangeUserIconDialog(bitmap!!) { changeUserIconDialog = false } + if (changeUserIconDialog) ChangeUserIconDialog( + bitmap!!, { + vm.setUserIcon(bitmap!!) + changeUserIconDialog = false + }) { changeUserIconDialog = false } } if(VERSION.SDK_INT >= 28 && privilege.device) { FunctionItem(R.string.user_session_msg, icon = R.drawable.notifications_fill0) { onNavigate(UserSessionMessage) } @@ -127,36 +131,21 @@ fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { FunctionItem(R.string.affiliation_id, icon = R.drawable.id_card_fill0) { onNavigate(AffiliationId) } } } - if(dialog != 0 && VERSION.SDK_INT >= 28) AlertDialog( - title = { Text(stringResource(if(dialog == 1) R.string.secondary_users else R.string.logout)) }, + if (VERSION.SDK_INT >= 28 && dialog == 1) AlertDialog( + title = { Text(stringResource(R.string.logout)) }, text = { - if(dialog == 1) { - val um = context.getSystemService(Context.USER_SERVICE) as UserManager - val list = Privilege.DPM.getSecondaryUsers(Privilege.DAR) - if(list.isEmpty()) { - Text(stringResource(R.string.no_secondary_users)) - } else { - Text("(" + stringResource(R.string.serial_number) + ")\n" + list.joinToString("\n") { um.getSerialNumberForUser(it).toString() }) - } - } else { - Text(stringResource(R.string.info_logout)) - } + Text(stringResource(R.string.info_logout)) }, confirmButton = { - TextButton( - onClick = { - if(dialog == 2) { - val result = Privilege.DPM.logoutUser(Privilege.DAR) - context.popToast(userOperationResultCode(result)) - } - dialog = 0 - } - ) { + TextButton({ + context.popToast(vm.logoutUser()) + dialog = 0 + }) { Text(stringResource(R.string.confirm)) } }, dismissButton = { - if(dialog != 1) TextButton(onClick = { dialog = 0 }) { + TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } }, @@ -167,41 +156,53 @@ fun UsersScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { @Serializable object UsersOptions @Composable -fun UsersOptionsScreen(onNavigateUp: () -> Unit) { +fun UsersOptionsScreen( + getLogoutEnabled: () -> Boolean, setLogoutEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit +) { + var logoutEnabled by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { logoutEnabled = getLogoutEnabled() } MyScaffold(R.string.options, onNavigateUp, 0.dp) { if(VERSION.SDK_INT >= 28) { - SwitchItem(R.string.enable_logout, getState = { Privilege.DPM.isLogoutEnabled }, - onCheckedChange = { Privilege.DPM.setLogoutEnabled(Privilege.DAR, it) }) + SwitchItem(R.string.enable_logout, logoutEnabled, { + setLogoutEnabled(it) + logoutEnabled = it + }) } } } +data class UserInformation( + val multiUser: Boolean = false, val headless: Boolean = false, val system: Boolean = false, + val admin: Boolean = false, val demo: Boolean = false, val time: Long = 0, + val logout: Boolean = false, val ephemeral: Boolean = false, val affiliated: Boolean = false, + val serial: Long = 0 +) + @Serializable object UserInfo @Composable -fun UserInfoScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val privilege by Privilege.status.collectAsStateWithLifecycle() - val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - val user = Process.myUserHandle() - var infoDialog by remember { mutableIntStateOf(0) } +fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { + var info by remember { mutableStateOf(UserInformation()) } + var infoDialog by rememberSaveable { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + info = getInfo() + } MyScaffold(R.string.user_info, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 24) InfoItem(R.string.support_multiuser, UserManager.supportsMultipleUsers().yesOrNo) - if(VERSION.SDK_INT >= 31) InfoItem(R.string.headless_system_user_mode, UserManager.isHeadlessSystemUserMode().yesOrNo, true) { infoDialog = 1 } - Spacer(Modifier.padding(vertical = 8.dp)) - if(VERSION.SDK_INT >= 23) InfoItem(R.string.system_user, userManager.isSystemUser.yesOrNo) - if(VERSION.SDK_INT >= 34) InfoItem(R.string.admin_user, userManager.isAdminUser.yesOrNo) - if(VERSION.SDK_INT >= 25) InfoItem(R.string.demo_user, userManager.isDemoUser.yesOrNo) - if(VERSION.SDK_INT >= 26) userManager.getUserCreationTime(user).let { - if(it != 0L) InfoItem(R.string.creation_time, parseTimestamp(it)) - } + if (VERSION.SDK_INT >= 24) InfoItem(R.string.support_multiuser, info.multiUser.yesOrNo) + if (VERSION.SDK_INT >= 31) InfoItem(R.string.headless_system_user_mode, info.headless.yesOrNo, true) { infoDialog = 1 } + Spacer(Modifier.height(8.dp)) + if (VERSION.SDK_INT >= 23) InfoItem(R.string.system_user, info.system.yesOrNo) + if (VERSION.SDK_INT >= 34) InfoItem(R.string.admin_user, info.admin.yesOrNo) + if (VERSION.SDK_INT >= 25) InfoItem(R.string.demo_user, info.demo.yesOrNo) + if (info.time != 0L) InfoItem(R.string.creation_time, formatDate(info.time)) + if (VERSION.SDK_INT >= 28) { - InfoItem(R.string.logout_enabled, Privilege.DPM.isLogoutEnabled.yesOrNo) - InfoItem(R.string.ephemeral_user, Privilege.DPM.isEphemeralUser(Privilege.DAR).yesOrNo) - InfoItem(R.string.affiliated_user, privilege.affiliated.yesOrNo) + InfoItem(R.string.logout_enabled, info.logout.yesOrNo) + InfoItem(R.string.ephemeral_user, info.ephemeral.yesOrNo) + InfoItem(R.string.affiliated_user, info.affiliated.yesOrNo) } InfoItem(R.string.user_id, (Binder.getCallingUid() / 100000).toString()) - InfoItem(R.string.user_serial_number, userManager.getSerialNumberForUser(Process.myUserHandle()).toString()) + InfoItem(R.string.user_serial_number, info.serial.toString()) } if(infoDialog != 0) AlertDialog( text = { Text(stringResource(R.string.info_headless_system_user_mode)) }, @@ -214,30 +215,43 @@ fun UserInfoScreen(onNavigateUp: () -> Unit) { ) } +class UserIdentifier(val id: Int, val serial: Long) + +enum class UserOperationType { + Start, Switch, Stop, Delete +} + @Serializable object UserOperation +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun UserOperationScreen(onNavigateUp: () -> Unit) { +fun UserOperationScreen( + getUsers: () -> List, doOperation: (UserOperationType, Int, Boolean) -> Boolean, + createShortcut: (UserOperationType, Int, Boolean) -> Boolean, onNavigateUp: () -> Unit +) { + val context = LocalContext.current - val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - var input by remember { mutableStateOf("") } + var input by rememberSaveable { mutableStateOf("") } val focusMgr = LocalFocusManager.current - var useUserId by remember { mutableStateOf(false) } - fun withUserHandle(operation: (UserHandle) -> Unit) { - val userHandle = if(useUserId && VERSION.SDK_INT >= 24) { - UserHandle.getUserHandleForUid(input.toInt() * 100000) - } else { - userManager.getUserForSerialNumber(input.toLong()) - } - if(userHandle == null) { - context.popToast(R.string.user_not_exist) - } else { - operation(userHandle) + var useUserId by rememberSaveable { mutableStateOf(false) } + var dialog by rememberSaveable { mutableStateOf(false) } + var menu by remember { mutableStateOf(false) } + val legalInput = input.toIntOrNull() != null + val identifiers = remember { mutableStateListOf() } + @Composable + fun CreateShortcutIcon(type: UserOperationType) { + FilledTonalIconButton({ + if (!createShortcut(type, input.toInt(), useUserId)) + context.showOperationResultToast(false) + }, enabled = legalInput) { + Icon(painterResource(R.drawable.open_in_new), null) } } - val legalInput = input.toIntOrNull() != null + LaunchedEffect(Unit) { + identifiers.addAll(getUsers()) + } MyScaffold(R.string.user_operation, onNavigateUp) { - if(VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + if (VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { SegmentedButton(!useUserId, { useUserId = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text(stringResource(R.string.serial_number)) } @@ -245,68 +259,89 @@ fun UserOperationScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.user_id)) } } - OutlinedTextField( - value = input, - onValueChange = { input = it }, - label = { Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number)) }, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) - ) - if(VERSION.SDK_INT >= 28) { - Button( - onClick = { - focusMgr.clearFocus() - withUserHandle { - val result = Privilege.DPM.startUserInBackground(Privilege.DAR, it) - context.popToast(userOperationResultCode(result)) - } + ExposedDropdownMenuBox(menu, { menu = it }) { + OutlinedTextField( + input, { input = it }, + Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryEditable) + .padding(top = 4.dp, bottom = 8.dp), + label = { + Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number)) }, - enabled = legalInput, - modifier = Modifier.fillMaxWidth() + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + ) + ExposedDropdownMenu(menu, { menu = false }) { + if (identifiers.isEmpty()) { + DropdownMenuItem( + { Text(stringResource(R.string.no_secondary_users)) }, {} + ) + } else { + identifiers.forEach { + val text = (if (useUserId) it.id else it.serial).toString() + DropdownMenuItem( + { Text(text) }, + { + input = text + menu = false + } + ) + } + } + } + } + if (VERSION.SDK_INT >= 28) Row { + Button( + { + focusMgr.clearFocus() + val result = doOperation(UserOperationType.Start, input.toInt(), useUserId) + context.showOperationResultToast(result) + }, + Modifier.weight(1F), + legalInput ) { Icon(Icons.Default.PlayArrow, null, Modifier.padding(end = 4.dp)) Text(stringResource(R.string.start_in_background)) } + CreateShortcutIcon(UserOperationType.Start) } - Button( - onClick = { - focusMgr.clearFocus() - withUserHandle { context.showOperationResultToast(Privilege.DPM.switchUser(Privilege.DAR, it)) } - }, - enabled = legalInput, - modifier = Modifier.fillMaxWidth() - ) { - Icon(painterResource(R.drawable.sync_alt_fill0), null, Modifier.padding(end = 4.dp)) - Text(stringResource(R.string.user_operation_switch)) - } - if(VERSION.SDK_INT >= 28) { + Row { Button( - onClick = { + { focusMgr.clearFocus() - withUserHandle { - val result = Privilege.DPM.stopUser(Privilege.DAR, it) - context.popToast(userOperationResultCode(result)) - } + val result = doOperation(UserOperationType.Switch, input.toInt(), useUserId) + context.showOperationResultToast(result) }, - enabled = legalInput, - modifier = Modifier.fillMaxWidth() + Modifier.weight(1F), + legalInput + ) { + Icon(painterResource(R.drawable.sync_alt_fill0), null, Modifier.padding(end = 4.dp)) + Text(stringResource(R.string.user_operation_switch)) + } + CreateShortcutIcon(UserOperationType.Switch) + } + if (VERSION.SDK_INT >= 28) Row { + Button( + { + focusMgr.clearFocus() + val result = doOperation(UserOperationType.Stop, input.toInt(), useUserId) + context.showOperationResultToast(result) + }, + Modifier.weight(1F), + legalInput ) { Icon(Icons.Default.Close, null, Modifier.padding(end = 4.dp)) Text(stringResource(R.string.stop)) } + CreateShortcutIcon(UserOperationType.Stop) } Button( onClick = { focusMgr.clearFocus() - withUserHandle { - if(Privilege.DPM.removeUser(Privilege.DAR, it)) { - context.showOperationResultToast(true) - input = "" - } else { - context.showOperationResultToast(false) - } - } + dialog = true }, enabled = legalInput, modifier = Modifier.fillMaxWidth() @@ -315,21 +350,40 @@ fun UserOperationScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.delete)) } } + if (dialog) AlertDialog( + text = { + Text(stringResource(R.string.delete_user_confirmation, input)) + }, + confirmButton = { + TextButton({ + val result = doOperation(UserOperationType.Delete, input.toInt(), useUserId) + context.showOperationResultToast(result) + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { Text(stringResource(R.string.cancel)) } + }, + onDismissRequest = { dialog = false } + ) } +data class CreateUserResult(val message: Int, val serial: Long = -1) + @Serializable object CreateUser @RequiresApi(24) @Composable -fun CreateUserScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager +fun CreateUserScreen( + createUser: (String, Int, (CreateUserResult) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { + var result by remember { mutableStateOf(null) } val focusMgr = LocalFocusManager.current - var userName by remember { mutableStateOf("") } - var creating by remember { mutableStateOf(false) } - var createdUserSerialNumber by remember { mutableLongStateOf(-1) } - var flag by remember { mutableIntStateOf(0) } - val coroutine = rememberCoroutineScope() + var userName by rememberSaveable { mutableStateOf("") } + var creating by rememberSaveable { mutableStateOf(false) } + var flags by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.create_user, onNavigateUp, 0.dp) { OutlinedTextField( userName, { userName= it }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), @@ -340,55 +394,47 @@ fun CreateUserScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 5.dp)) FullWidthCheckBoxItem( R.string.create_user_skip_wizard, - flag and DevicePolicyManager.SKIP_SETUP_WIZARD != 0 - ) { flag = flag xor DevicePolicyManager.SKIP_SETUP_WIZARD } + flags and DevicePolicyManager.SKIP_SETUP_WIZARD != 0 + ) { flags = flags xor DevicePolicyManager.SKIP_SETUP_WIZARD } if(VERSION.SDK_INT >= 28) { FullWidthCheckBoxItem( R.string.create_user_ephemeral_user, - flag and DevicePolicyManager.MAKE_USER_EPHEMERAL != 0 - ) { flag = flag xor DevicePolicyManager.MAKE_USER_EPHEMERAL } + flags and DevicePolicyManager.MAKE_USER_EPHEMERAL != 0 + ) { flags = flags xor DevicePolicyManager.MAKE_USER_EPHEMERAL } FullWidthCheckBoxItem( R.string.create_user_enable_all_system_app, - flag and DevicePolicyManager.LEAVE_ALL_SYSTEM_APPS_ENABLED != 0 - ) { flag = flag xor DevicePolicyManager.LEAVE_ALL_SYSTEM_APPS_ENABLED } + flags and DevicePolicyManager.LEAVE_ALL_SYSTEM_APPS_ENABLED != 0 + ) { flags = flags xor DevicePolicyManager.LEAVE_ALL_SYSTEM_APPS_ENABLED } } Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { focusMgr.clearFocus() creating = true - coroutine.launch(Dispatchers.IO) { - try { - val uh = Privilege.DPM.createAndManageUser(Privilege.DAR, userName, Privilege.DAR, null, flag) - withContext(Dispatchers.Main) { - createdUserSerialNumber = userManager.getSerialNumberForUser(uh) - } - } catch(e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - if (VERSION.SDK_INT >= 28 && e is UserManager.UserOperationException) { - context.popToast(e.message ?: context.getString(R.string.error)) - } else { - context.showOperationResultToast(false) - } - } - } - withContext(Dispatchers.Main) { creating = false } + createUser(userName, flags) { + creating = false + result = it } }, modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.create)) } - if(createdUserSerialNumber != -1L) AlertDialog( - title = { Text(stringResource(R.string.success)) }, - text = { Text(stringResource(R.string.serial_number_of_new_user_is, createdUserSerialNumber)) }, - confirmButton = { - TextButton({ createdUserSerialNumber = -1 }) { Text(stringResource(R.string.confirm)) } + if (result != null) AlertDialog( + text = { + Column { + Text(stringResource(result!!.message)) + if (result?.serial != -1L) { + Text(stringResource(R.string.serial_number) + ": " + result!!.serial) + } + } }, - onDismissRequest = { createdUserSerialNumber = -1 } + confirmButton = { + TextButton({ result = null }) { Text(stringResource(R.string.confirm)) } + }, + onDismissRequest = { result = null } ) - if(creating) CircularProgressDialog { } + if (creating) CircularProgressDialog { } } } @@ -396,24 +442,21 @@ fun CreateUserScreen(onNavigateUp: () -> Unit) { @RequiresApi(26) @Composable -fun AffiliationIdScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun AffiliationIdScreen( + affiliationIds: StateFlow>, getIds: () -> Unit, setId: (String, Boolean) -> Unit, + onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current - var input by remember { mutableStateOf("") } - val list = remember { mutableStateListOf() } - val refreshIds = { - list.clear() - list.addAll(Privilege.DPM.getAffiliationIds(Privilege.DAR)) - } - LaunchedEffect(Unit) { refreshIds() } + var input by rememberSaveable { mutableStateOf("") } + val list by affiliationIds.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { getIds() } MyScaffold(R.string.affiliation_id, onNavigateUp) { Column(modifier = Modifier.animateContentSize()) { - if(list.isEmpty()) Text(stringResource(R.string.none)) - for(i in list) { - ListItem(i) { list -= i } + if (list.isEmpty()) Text(stringResource(R.string.none)) + for (i in list) { + ListItem(i) { setId(i, false) } } } - Spacer(Modifier.padding(vertical = 5.dp)) OutlinedTextField( value = input, onValueChange = { input = it }, @@ -421,7 +464,7 @@ fun AffiliationIdScreen(onNavigateUp: () -> Unit) { trailingIcon = { IconButton( onClick = { - list += input + setId(input, true) input = "" }, enabled = input.isNotEmpty() @@ -429,22 +472,10 @@ fun AffiliationIdScreen(onNavigateUp: () -> Unit) { Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add)) } }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }) + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) ) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - list.removeAll(setOf("")) - Privilege.DPM.setAffiliationIds(Privilege.DAR, list.toSet()) - context.showOperationResultToast(true) - refreshIds() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.apply)) - } Notes(R.string.info_affiliation_id) } } @@ -452,10 +483,10 @@ fun AffiliationIdScreen(onNavigateUp: () -> Unit) { @Serializable object ChangeUsername @Composable -fun ChangeUsernameScreen(onNavigateUp: () -> Unit) { +fun ChangeUsernameScreen(setName: (String) -> Unit, onNavigateUp: () -> Unit) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var inputUsername by remember { mutableStateOf("") } + var inputUsername by rememberSaveable { mutableStateOf("") } MyScaffold(R.string.change_username, onNavigateUp) { OutlinedTextField( value = inputUsername, @@ -468,19 +499,13 @@ fun ChangeUsernameScreen(onNavigateUp: () -> Unit) { Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - Privilege.DPM.setProfileName(Privilege.DAR, inputUsername) + setName(inputUsername) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.apply)) } - Button( - onClick = { Privilege.DPM.setProfileName(Privilege.DAR, null) }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.reset)) - } } } @@ -488,16 +513,19 @@ fun ChangeUsernameScreen(onNavigateUp: () -> Unit) { @RequiresApi(28) @Composable -fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { +fun UserSessionMessageScreen( + getMessages: () -> Pair, setStartMessage: (String?) -> Unit, + setEndMessage: (String?) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var start by remember { mutableStateOf("") } - var end by remember { mutableStateOf("") } - val refreshMsg = { - start = Privilege.DPM.getStartUserSessionMessage(Privilege.DAR)?.toString() ?: "" - end = Privilege.DPM.getEndUserSessionMessage(Privilege.DAR)?.toString() ?: "" + var start by rememberSaveable { mutableStateOf("") } + var end by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + val messages = getMessages() + start = messages.first + end = messages.second } - LaunchedEffect(Unit) { refreshMsg() } MyScaffold(R.string.user_session_msg, onNavigateUp) { OutlinedTextField( value = start, @@ -510,8 +538,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setStartUserSessionMessage(Privilege.DAR, start) - refreshMsg() + setStartMessage(start) }, modifier = Modifier.fillMaxWidth(0.49F) ) { @@ -519,8 +546,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setStartUserSessionMessage(Privilege.DAR, null) - refreshMsg() + setStartMessage(null) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -540,8 +566,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { - Privilege.DPM.setEndUserSessionMessage(Privilege.DAR, end) - refreshMsg() + setStartMessage(end) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.49F) @@ -550,8 +575,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { } Button( onClick = { - Privilege.DPM.setEndUserSessionMessage(Privilege.DAR, null) - refreshMsg() + setEndMessage(null) context.showOperationResultToast(true) }, modifier = Modifier.fillMaxWidth(0.96F) @@ -564,8 +588,7 @@ fun UserSessionMessageScreen(onNavigateUp: () -> Unit) { @RequiresApi(23) @Composable -private fun ChangeUserIconDialog(bitmap: Bitmap, onClose: () -> Unit) { - val context = LocalContext.current +private fun ChangeUserIconDialog(bitmap: Bitmap, onSet: () -> Unit, onClose: () -> Unit) { AlertDialog( title = { Text(stringResource(R.string.change_user_icon)) }, text = { @@ -577,11 +600,7 @@ private fun ChangeUserIconDialog(bitmap: Bitmap, onClose: () -> Unit) { } }, confirmButton = { - TextButton({ - Privilege.DPM.setUserIcon(Privilege.DAR, bitmap) - context.showOperationResultToast(true) - onClose() - }) { + TextButton(onSet) { Text(stringResource(R.string.confirm)) } }, @@ -593,13 +612,3 @@ private fun ChangeUserIconDialog(bitmap: Bitmap, onClose: () -> Unit) { onDismissRequest = onClose ) } - -@StringRes -private fun userOperationResultCode(result:Int): Int = - when(result) { - UserManager.USER_OPERATION_SUCCESS -> R.string.success - UserManager.USER_OPERATION_ERROR_UNKNOWN -> R.string.unknown_error - UserManager.USER_OPERATION_ERROR_MANAGED_PROFILE-> R.string.fail_managed_profile - UserManager.USER_OPERATION_ERROR_CURRENT_USER-> R.string.fail_current_user - else -> R.string.unknown - } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt index 1fac984..180deed 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt @@ -1,22 +1,9 @@ package com.bintianqi.owndroid.dpm -import android.accounts.Account -import android.app.admin.DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_ALLOW_OFFLINE -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_PACKAGE_NAME -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION -import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION -import android.app.admin.DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT -import android.app.admin.DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED -import android.app.admin.DevicePolicyManager.PERSONAL_APPS_NOT_SUSPENDED -import android.app.admin.DevicePolicyManager.PERSONAL_APPS_SUSPENDED_PROFILE_TIMEOUT +import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.WIPE_EUICC import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE -import android.content.ActivityNotFoundException import android.content.Intent -import android.content.IntentFilter import android.os.Binder import android.os.Build.VERSION import androidx.activity.compose.rememberLauncherForActivityResult @@ -24,6 +11,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -33,8 +21,14 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -45,6 +39,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -55,18 +50,18 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.IUserService -import com.bintianqi.owndroid.MyAdminComponent +import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.CircularProgressDialog +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem -import com.bintianqi.owndroid.useShizuku +import com.bintianqi.owndroid.yesOrNo import kotlinx.serialization.Serializable @Serializable object WorkProfile @@ -86,22 +81,28 @@ fun WorkProfileScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { } } +data class CreateWorkProfileOptions( + val skipEncrypt: Boolean, val offline: Boolean, val migrateAccount: Boolean, + val accountName: String, val accountType: String, val keepAccount: Boolean +) + @Serializable object CreateWorkProfile @Composable -fun CreateWorkProfileScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun CreateWorkProfileScreen( + createIntent: (CreateWorkProfileOptions) -> Intent, onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - MyScaffold(R.string.create_work_profile, onNavigateUp) { + MyScaffold(R.string.create_work_profile, onNavigateUp, 0.dp) { var skipEncrypt by remember { mutableStateOf(false) } var offlineProvisioning by remember { mutableStateOf(true) } var migrateAccount by remember { mutableStateOf(false) } var migrateAccountName by remember { mutableStateOf("") } var migrateAccountType by remember { mutableStateOf("") } var keepAccount by remember { mutableStateOf(true) } - if(VERSION.SDK_INT >= 22) { - CheckBoxItem(R.string.migrate_account, migrateAccount) { migrateAccount = it } + if (VERSION.SDK_INT >= 22) { + FullWidthCheckBoxItem(R.string.migrate_account, migrateAccount) { migrateAccount = it } AnimatedVisibility(migrateAccount) { val fr = FocusRequester() Column(modifier = Modifier.padding(start = 10.dp)) { @@ -110,47 +111,40 @@ fun CreateWorkProfileScreen(onNavigateUp: () -> Unit) { label = { Text(stringResource(R.string.account_name)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions { fr.requestFocus() }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) ) OutlinedTextField( value = migrateAccountType, onValueChange = { migrateAccountType = it }, label = { Text(stringResource(R.string.account_type)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions { focusMgr.clearFocus() }, - modifier = Modifier.fillMaxWidth().focusRequester(fr) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .focusRequester(fr) ) if(VERSION.SDK_INT >= 26) { - CheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = it } + FullWidthCheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = it } } } } } - if(VERSION.SDK_INT >= 24) CheckBoxItem(R.string.skip_encryption, skipEncrypt) { skipEncrypt = it } - if(VERSION.SDK_INT >= 33) CheckBoxItem(R.string.offline_provisioning, offlineProvisioning) { offlineProvisioning = it } + if (VERSION.SDK_INT >= 24) FullWidthCheckBoxItem( + R.string.skip_encryption, skipEncrypt + ) { skipEncrypt = it } + if (VERSION.SDK_INT >= 33) FullWidthCheckBoxItem( + R.string.offline_provisioning, offlineProvisioning + ) { offlineProvisioning = it } Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { - try { - val intent = Intent(ACTION_PROVISION_MANAGED_PROFILE) - if(VERSION.SDK_INT >= 23) { - intent.putExtra(EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, MyAdminComponent) - } else { - intent.putExtra(EXTRA_PROVISIONING_DEVICE_ADMIN_PACKAGE_NAME, context.packageName) - } - if(migrateAccount && VERSION.SDK_INT >= 22) { - intent.putExtra(EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE, Account(migrateAccountName, migrateAccountType)) - if(VERSION.SDK_INT >= 26) { - intent.putExtra(EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION, keepAccount) - } - } - if(VERSION.SDK_INT >= 24) { intent.putExtra(EXTRA_PROVISIONING_SKIP_ENCRYPTION, skipEncrypt) } - if(VERSION.SDK_INT >= 33) { intent.putExtra(EXTRA_PROVISIONING_ALLOW_OFFLINE, offlineProvisioning) } - launcher.launch(intent) - } catch(_: ActivityNotFoundException) { - context.popToast(R.string.unsupported) - } + val intent = createIntent(CreateWorkProfileOptions( + skipEncrypt, offlineProvisioning, migrateAccount, migrateAccountName, + migrateAccountType, keepAccount + )) + launcher.launch(intent) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.create)) } @@ -161,18 +155,19 @@ fun CreateWorkProfileScreen(onNavigateUp: () -> Unit) { @RequiresApi(30) @Composable -fun OrganizationOwnedProfileScreen(onNavigateUp: () -> Unit) { +fun OrganizationOwnedProfileScreen( + onActivate: ((Boolean) -> Unit) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var activating by remember { mutableStateOf(false) } var dialog by remember { mutableStateOf(false) } MyScaffold(R.string.org_owned_work_profile, onNavigateUp) { Button({ - useShizuku(context) { service -> - val result = IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand) - if (result?.getInt("code", -1) == 0) { - context.showOperationResultToast(true) - } else { - context.showOperationResultToast(false) - } + activating = true + onActivate { + activating = false + context.showOperationResultToast(it) + if (it) onNavigateUp() } }) { Text(stringResource(R.string.shizuku)) @@ -189,6 +184,7 @@ fun OrganizationOwnedProfileScreen(onNavigateUp: () -> Unit) { }, onDismissRequest = { dialog = false } ) + if (activating) CircularProgressDialog { } } } @@ -199,41 +195,46 @@ val activateOrgProfileCommand = "dpm mark-profile-owner-on-organization-owned-de @RequiresApi(30) @Composable -fun SuspendPersonalAppScreen(onNavigateUp: () -> Unit) { +fun SuspendPersonalAppScreen( + getSuspendedReasons: () -> Int, setSuspended: (Boolean) -> Unit, getMaxTime: () -> Long, + setMaxTime: (Long) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var suspend by remember { mutableStateOf(Privilege.DPM.getPersonalAppsSuspendedReasons(Privilege.DAR) != PERSONAL_APPS_NOT_SUSPENDED) } + var reason by remember { mutableIntStateOf(DevicePolicyManager.PERSONAL_APPS_NOT_SUSPENDED) } + var time by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + reason = getSuspendedReasons() + time = getMaxTime().toString() + } MyScaffold(R.string.suspend_personal_app, onNavigateUp) { - SwitchItem(R.string.suspend_personal_app, state = suspend, + SwitchItem(R.string.suspend_personal_app, state = reason != 0, onCheckedChange = { - Privilege.DPM.setPersonalAppsSuspended(Privilege.DAR, it) - suspend = Privilege.DPM.getPersonalAppsSuspendedReasons(Privilege.DAR) != PERSONAL_APPS_NOT_SUSPENDED + setSuspended(it) + reason = if (it) DevicePolicyManager.PERSONAL_APPS_SUSPENDED_EXPLICITLY + else DevicePolicyManager.PERSONAL_APPS_NOT_SUSPENDED }, padding = false ) - var time by remember { mutableStateOf("") } - time = Privilege.DPM.getManagedProfileMaximumTimeOff(Privilege.DAR).toString() Spacer(Modifier.padding(vertical = 10.dp)) Text(text = stringResource(R.string.profile_max_time_off), style = typography.titleLarge) Text(text = stringResource(R.string.profile_max_time_out_desc)) - Text( - text = stringResource( - R.string.personal_app_suspended_because_timeout, - Privilege.DPM.getPersonalAppsSuspendedReasons(Privilege.DAR) == PERSONAL_APPS_SUSPENDED_PROFILE_TIMEOUT - ) - ) + Text(stringResource( + R.string.personal_app_suspended_because_timeout, + stringResource((reason == DevicePolicyManager.PERSONAL_APPS_SUSPENDED_PROFILE_TIMEOUT).yesOrNo) + )) OutlinedTextField( value = time, onValueChange = { time=it }, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), label = { Text(stringResource(R.string.time_unit_ms)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }) ) - Text(text = stringResource(R.string.cannot_less_than_72_hours)) Button( onClick = { - Privilege.DPM.setManagedProfileMaximumTimeOff(Privilege.DAR, time.toLong()) + setMaxTime(time.toLong()) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + enabled = time.toLongOrNull() != null ) { Text(stringResource(R.string.apply)) } @@ -241,14 +242,33 @@ fun SuspendPersonalAppScreen(onNavigateUp: () -> Unit) { } } +data class IntentFilterOptions( + val action: String, val category: String, val mimeType: String, + val direction: IntentFilterDirection +) +enum class IntentFilterDirection(val text: Int) { + ToParent(R.string.work_to_personal), ToManaged(R.string.personal_to_work), + Both(R.string.both_direction) +} + @Serializable object CrossProfileIntentFilter +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun CrossProfileIntentFilterScreen(onNavigateUp: () -> Unit) { +fun CrossProfileIntentFilterScreen( + addFilter: (IntentFilterOptions) -> Unit, + onNavigateUp: () -> Unit +) { val context = LocalContext.current val focusMgr = LocalFocusManager.current + var action by remember { mutableStateOf("") } + var customCategory by remember { mutableStateOf(false) } + var category by remember { mutableStateOf("") } + var customMimeType by remember { mutableStateOf(false) } + var mimeType by remember { mutableStateOf("") } + var dropdown by remember { mutableStateOf(false) } + var direction by remember { mutableStateOf(IntentFilterDirection.Both) } MyScaffold(R.string.intent_filter, onNavigateUp) { - var action by remember { mutableStateOf("") } OutlinedTextField( value = action, onValueChange = { action = it }, label = { Text("Action") }, @@ -256,32 +276,61 @@ fun CrossProfileIntentFilterScreen(onNavigateUp: () -> Unit) { keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }), modifier = Modifier.fillMaxWidth() ) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - Privilege.DPM.addCrossProfileIntentFilter(Privilege.DAR, IntentFilter(action), FLAG_PARENT_CAN_ACCESS_MANAGED) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.add_intent_filter_work_to_personal)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(customCategory, { + customCategory = it + category = "" + }) + OutlinedTextField( + category, { category = it }, Modifier.fillMaxWidth(), + label = { Text("Category") }, enabled = customCategory + ) + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(customMimeType, { + customMimeType = it + mimeType = "" + }) + OutlinedTextField( + mimeType, { mimeType = it }, Modifier.fillMaxWidth(), + label = { Text("MIME type") }, enabled = customMimeType + ) + } + ExposedDropdownMenuBox(dropdown, { dropdown = it }, Modifier.padding(vertical = 5.dp)) { + OutlinedTextField( + stringResource(direction.text), {}, + Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + label = { Text(stringResource(R.string.direction)) }, readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + IntentFilterDirection.entries.forEach { + DropdownMenuItem({ Text(stringResource(it.text)) }, { + direction = it + dropdown = false + }) + } + } } Button( - onClick = { - Privilege.DPM.addCrossProfileIntentFilter(Privilege.DAR, IntentFilter(action), FLAG_MANAGED_CAN_ACCESS_PARENT) + { + addFilter(IntentFilterOptions( + action, category, mimeType, direction + )) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + enabled = action.isNotBlank() && (!customCategory || category.isNotBlank()) && + (!customMimeType || mimeType.isNotBlank()) ) { - Text(stringResource(R.string.add_intent_filter_personal_to_work)) + Text(stringResource(R.string.add)) } - Spacer(Modifier.padding(vertical = 2.dp)) Button( onClick = { Privilege.DPM.clearCrossProfileIntentFilters(Privilege.DAR) context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) ) { Text(stringResource(R.string.clear_cross_profile_filters)) } @@ -292,28 +341,34 @@ fun CrossProfileIntentFilterScreen(onNavigateUp: () -> Unit) { @Serializable object DeleteWorkProfile @Composable -fun DeleteWorkProfileScreen(onNavigateUp: () -> Unit) { +fun DeleteWorkProfileScreen( + deleteProfile: (Boolean, Int, String) -> Unit, onNavigateUp: () -> Unit +) { val focusMgr = LocalFocusManager.current - var flag by remember { mutableIntStateOf(0) } + var flags by remember { mutableIntStateOf(0) } var warning by remember { mutableStateOf(false) } - var silent by remember { mutableStateOf(false) } var reason by remember { mutableStateOf("") } MyScaffold(R.string.delete_work_profile, onNavigateUp) { - CheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { flag = flag xor WIPE_EXTERNAL_STORAGE } - if(VERSION.SDK_INT >= 28) CheckBoxItem(R.string.wipe_euicc, flag and WIPE_EUICC != 0) { flag = flag xor WIPE_EUICC } - CheckBoxItem(R.string.wipe_silently, silent) { silent = it } - AnimatedVisibility(!silent && VERSION.SDK_INT >= 28) { - OutlinedTextField( - value = reason, onValueChange = { reason = it }, - label = { Text(stringResource(R.string.reason)) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) - ) + CheckBoxItem(R.string.wipe_external_storage, flags and WIPE_EXTERNAL_STORAGE != 0) { + flags = flags xor WIPE_EXTERNAL_STORAGE } + if(VERSION.SDK_INT >= 28) CheckBoxItem(R.string.wipe_euicc, flags and WIPE_EUICC != 0) { + flags = flags xor WIPE_EUICC + } + CheckBoxItem(R.string.wipe_silently, flags and DevicePolicyManager.WIPE_SILENTLY != 0) { + flags = flags xor DevicePolicyManager.WIPE_SILENTLY + reason = "" + } + if (VERSION.SDK_INT >= 28) OutlinedTextField( + value = reason, onValueChange = { reason = it }, + label = { Text(stringResource(R.string.reason)) }, + enabled = flags and DevicePolicyManager.WIPE_SILENTLY == 0, + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) + ) Spacer(Modifier.padding(vertical = 5.dp)) Button( onClick = { focusMgr.clearFocus() - silent = reason == "" warning = true }, colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), @@ -322,8 +377,7 @@ fun DeleteWorkProfileScreen(onNavigateUp: () -> Unit) { Text(stringResource(R.string.delete)) } } - if(warning) { - LaunchedEffect(Unit) { silent = reason == "" } + if (warning) { AlertDialog( title = { Text(text = stringResource(R.string.warning), color = colorScheme.error) @@ -335,11 +389,7 @@ fun DeleteWorkProfileScreen(onNavigateUp: () -> Unit) { confirmButton = { TextButton( onClick = { - if(VERSION.SDK_INT >= 28 && !silent) { - Privilege.DPM.wipeData(flag, reason) - } else { - Privilege.DPM.wipeData(flag) - } + deleteProfile(false, flags, reason) }, colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) ) { diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/Animations.kt b/app/src/main/java/com/bintianqi/owndroid/ui/Animations.kt deleted file mode 100644 index 90c364a..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/ui/Animations.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.bintianqi.owndroid.ui - -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.ui.unit.IntOffset -import androidx.navigation.NavBackStackEntry - -object Animations { - private const val INITIAL_OFFSET_VALUE = 96 - private const val TARGET_OFFSET_VALUE = 96 - - private val bezier = CubicBezierEasing(0.20f, 0.85f, 0.0f, 1f) - - private val tween: FiniteAnimationSpec = tween(durationMillis = 550, easing = bezier, delayMillis = 50) - - val navHostEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { - fadeIn(tween(100, easing = LinearEasing)) + - slideIntoContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.End, - initialOffset = { INITIAL_OFFSET_VALUE } - ) - } - - val navHostExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { - fadeOut(tween(100, easing = LinearEasing)) + - slideOutOfContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.Start, - targetOffset = { -TARGET_OFFSET_VALUE } - ) - } - - val navHostPopEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { - fadeIn(tween(100, easing = LinearEasing)) + - slideIntoContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.End, - initialOffset = { -INITIAL_OFFSET_VALUE } - ) - } - - val navHostPopExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { - fadeOut(tween(100, easing = LinearEasing)) + - slideOutOfContainer( - animationSpec = tween, - towards = AnimatedContentTransitionScope.SlideDirection.Start, - targetOffset = { TARGET_OFFSET_VALUE } - ) - } - -} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt new file mode 100644 index 0000000..3d52595 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt @@ -0,0 +1,257 @@ +package com.bintianqi.owndroid.ui + +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.APK_MIME +import com.bintianqi.owndroid.AppInstallerViewModel +import com.bintianqi.owndroid.AppLockDialog +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.SP +import com.bintianqi.owndroid.SerializableSaver +import com.bintianqi.owndroid.SessionParamsOptions +import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage +import kotlinx.coroutines.launch +import java.net.URLDecoder + + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun AppInstaller( + uiState: AppInstallerViewModel.UiState = AppInstallerViewModel.UiState(), + onPackagesAdd: (List) -> Unit = {}, + onPackageRemove: (Uri) -> Unit = {}, + onStartInstall: (SessionParamsOptions) -> Unit = {}, + onResultDialogClose: () -> Unit = {} +) { + var appLockDialog by rememberSaveable { mutableStateOf(false) } + var options by rememberSaveable(stateSaver = SerializableSaver(SessionParamsOptions.serializer())) { + mutableStateOf(SessionParamsOptions()) + } + val coroutine = rememberCoroutineScope() + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_installer)) } + ) + }, + floatingActionButton = { + if(uiState.packages.isNotEmpty()) ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.start)) }, + icon = { + if(uiState.installing) CircularProgressIndicator(modifier = Modifier.size(24.dp)) + else Icon(Icons.Default.PlayArrow, null) + }, + onClick = { + if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall(options) else appLockDialog = true + }, + expanded = !uiState.installing + ) + } + ) { paddingValues -> + var tab by rememberSaveable { mutableIntStateOf(0) } + val pagerState = rememberPagerState { 2 } + val scrollState = rememberScrollState() + tab = pagerState.targetPage + Column(modifier = Modifier.padding(paddingValues)) { + TabRow(tab) { + Tab( + tab == 0, + onClick = { + coroutine.launch { scrollState.animateScrollTo(0) } + coroutine.launch { pagerState.animateScrollToPage(0) } + }, + text = { Text(stringResource(R.string.packages)) } + ) + Tab( + tab == 1, + onClick = { + coroutine.launch { scrollState.animateScrollTo(0) } + coroutine.launch { pagerState.animateScrollToPage(1) } + }, + text = { Text(stringResource(R.string.options)) } + ) + } + HorizontalPager(pagerState, Modifier.fillMaxHeight(), verticalAlignment = Alignment.Top) { page -> + if (page == 0) Packages(uiState, onPackageRemove, onPackagesAdd) + else Options(options) { options = it } + } + } + ResultDialog(uiState.result, onResultDialogClose) + } + if(appLockDialog) { + AppLockDialog({ + appLockDialog = false + onStartInstall(options) + }) { appLockDialog = false } + } +} + + +@Composable +private fun Packages( + uiState: AppInstallerViewModel.UiState, onRemove: (Uri) -> Unit, onAdd: (List) -> Unit +) { + val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onAdd) + LazyColumn(Modifier.padding(top = 8.dp)) { + itemsIndexed(uiState.packages, { _, it -> it }) { i, it -> + val status = when { + uiState.packageWriting < 0 -> 0 + i < uiState.packageWriting -> 3 + i == uiState.packageWriting -> 2 + else -> 1 + } + PackageItem(it, status) { onRemove(it) } + } + if (!uiState.installing) { + item { + Row( + Modifier.fillMaxWidth().animateItem().padding(vertical = 4.dp).clickable { + chooseSplitPackage.launch(APK_MIME) + }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp)) + Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium) + } + } + } + } +} + +/** + * @param status 0: not installing, 1: installing, 2: writing, 3: written + */ +@Composable +private fun LazyItemScope.PackageItem(uri: Uri, status: Int, onRemove: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().animateItem().padding(start = 8.dp, end = 6.dp, bottom = 6.dp).heightIn(min = 40.dp) + ) { + Text( + URLDecoder.decode(URLDecoder.decode(uri.path ?: uri.toString())), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(0.85F) + ) + when (status) { + 0 -> IconButton(onRemove) { + Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove)) + } + 2 -> CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp)) + 3 -> Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary) + } + } +} + +@Composable +private fun Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) = Column { + Text( + stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) { + onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false)) + } + FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { + onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING)) + } + if(Build.VERSION.SDK_INT >= 34) { + AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { + FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) { + onChange(options.copy(noKill = it)) + } + } + FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) { + onChange(options.copy(keepOriginalEnabledSetting = it)) + } + } + Text( + stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO)) + } + FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)) + } + FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL)) + } +} + +@Composable +private fun ResultDialog(result: Intent?, onDialogClose: () -> Unit) { + if(result != null) { + val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + AlertDialog( + title = { + val text = if(status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure + Text(stringResource(text)) + }, + text = { + val context = LocalContext.current + Text(parsePackageInstallerMessage(context, result)) + }, + confirmButton = { + TextButton(onDialogClose) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = onDialogClose + ) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt index 51447c1..d0cb905 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt @@ -2,7 +2,6 @@ package com.bintianqi.owndroid.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,10 +9,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -23,7 +20,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -51,7 +47,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -60,6 +55,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.zhCN @Composable @@ -233,6 +229,22 @@ fun SwitchItem( } } +@Composable +fun SwitchItem( + title: Int, state: Boolean, onCheckedChange: (Boolean) -> Unit, icon: Int? = null +) { + Row( + Modifier.fillMaxWidth().padding(25.dp, 5.dp, 15.dp, 5.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + if (icon != null) Icon(painterResource(icon), null, Modifier.padding(end = 20.dp)) + Text(stringResource(title), style = typography.titleLarge) + } + Switch(state, onCheckedChange, Modifier.padding(start = 10.dp)) + } +} + @Composable fun InfoItem(title: Int, text: Int, withInfo: Boolean = false, onClick: () -> Unit = {}) = InfoItem(title, stringResource(text), withInfo, onClick) @@ -243,7 +255,7 @@ fun InfoItem(title: Int, text: String, withInfo: Boolean = false, onClick: () -> Modifier.fillMaxWidth().padding(vertical = 6.dp).padding(start = HorizontalPadding, end = 8.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Column { + Column(Modifier.weight(1F)) { Text(stringResource(title), style = typography.titleLarge) Text(text, Modifier.alpha(0.8F)) } @@ -297,7 +309,7 @@ fun MyScaffold( scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -329,7 +341,7 @@ fun MyLazyScaffold( scrollBehavior = sb ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> LazyColumn(Modifier.fillMaxSize().padding(paddingValues), content = content) } @@ -351,7 +363,7 @@ fun MySmallTitleScaffold( colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) ) }, - contentWindowInsets = WindowInsets.ime + contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( modifier = Modifier @@ -366,15 +378,6 @@ fun MySmallTitleScaffold( } } -@Composable -fun ExpandExposedTextFieldIcon(active: Boolean) { - val degrees by animateFloatAsState(if(active) 180F else 0F) - Icon( - imageVector = Icons.Default.ArrowDropDown, contentDescription = null, - modifier = Modifier.rotate(degrees) - ) -} - @Composable fun ErrorDialog(message: String?, onDismiss: () -> Unit) { if(!message.isNullOrEmpty()) AlertDialog( diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt b/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt new file mode 100644 index 0000000..d9dd4df --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt @@ -0,0 +1,45 @@ +package com.bintianqi.owndroid.ui + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally + +/** + * Learned from AOSP's Activity animation + * `frameworks/base/core/res/res/anim/activity_xxx_xxx.xml` + */ +object NavTransition { + val StandardAccelerateEasing = CubicBezierEasing(0.3F, 0F, 1F, 1F) + + val enterTransition: EnterTransition = slideInHorizontally( + tween(450, easing = FastOutSlowInEasing), + { 96 } + ) + fadeIn( + tween(83, 50, LinearEasing) + ) + + val exitTransition: ExitTransition = slideOutHorizontally( + tween(450, easing = StandardAccelerateEasing), + { -96 } + ) + fadeOut(tween(100, 200, LinearEasing)) + + val popEnterTransition: EnterTransition = slideInHorizontally( + tween(450, easing = FastOutSlowInEasing), + { -96 } + ) + + val popExitTransition: ExitTransition = + slideOutHorizontally( + tween(450, easing = FastOutSlowInEasing), + { 96 } + ) + fadeOut( + tween(83, 35, LinearEasing) + ) +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6e2ede2..258abcd 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -35,7 +35,6 @@ Время (мс) Длина Нет - Нет журналов По умолчанию Применить Определять пользователем @@ -75,7 +74,6 @@ Владелец профиля Владелец устройства Делегированные администраторы - делегированные возможности Управление ограничениями для приложений Управление сертификатами Выберите сертификат связки ключей @@ -101,7 +99,6 @@ Отключить управление аккаунтами Тип аккаунта Передача прав владения - Имя целевого компонента Информация на экране блокировки Сообщение поддержки Краткое сообщение @@ -115,7 +112,6 @@ Не удалось инициализировать Dhizuku Разрешение Dhizuku не предоставлено - Режим Dhizuku отключен Система @@ -129,7 +125,7 @@ Служба резервного копирования Отключить обмен контактами по Bluetooth Режим общих критериев - Отключить USB-сигнал + Enable USB signal Блокировка экрана (Keyguard) Заблокировать сейчас Удалить ключ шифрования учетных данных @@ -166,7 +162,6 @@ Политика потоковой передачи уведомлений Nearby Только для одной управляемой учетной записи Режим закрепления задачи - Указать Acitvity Приложение не разрешено Отключить все @@ -212,8 +207,6 @@ Время получения обновления: %1$s Установить системное обновление Выберите OTA-пакет... - Начать установку системного обновления - Установка системного обновления не удалась: Низкий заряд батареи Файл обновления недействителен Неверная версия ОС @@ -258,7 +251,6 @@ Тип сети Мобильный Ethernet - ID подписчика Всё Удалённый Режим модема @@ -273,13 +265,9 @@ Роуминг Измеренный Частный DNS - Укажите имя хоста - Хост не обслуживает DNS через TLS - Установить в оппортунистический режим Имя хоста DNS Неверное имя хоста Исключение безопасности - Установить хост DNS Рекомендуемый глобальный прокси Без прокси PAC-прокси @@ -288,7 +276,6 @@ Неверная конфигурация Исключить хосты Сетевой журнал - Размер файла журнала: %1$s Удалить журналы Экспортировать журналы Пара ключей Wi-Fi @@ -300,27 +287,15 @@ Включенные UID Исключенные UID Один UID на строку - + Override APN APN setting - APN name - Entry name Имя - Идентификатор профиля - Тип аутентификации Описание - MMS-прокси Адрес Порт Прокси Постоянный - Протокол - Протокол роуминга - - Carrier ID - MVNO type - Network type bitmask - Always on Обновить @@ -339,10 +314,7 @@ Максимальное время отключения Личные приложения будут приостановлены после закрытия рабочего профиля на указанное время. 0 означает отсутствие ограничения. Личное приложение приостановлено по причине: %1$s - Не может быть меньше 72 часов Фильтр намерений - Добавить (из рабочего в личный) - Добавить (из личного в рабочий) Очистить все фильтры Идентификатор организации Длина должна быть от 6 до 64 символов @@ -489,7 +461,6 @@ Пропустить мастер настройки Временный пользователь Включить все системные приложения - Серийный номер этого пользователя: %1$d Аффилированный идентификатор Изменить значок пользователя Select an image @@ -502,7 +473,6 @@ Пароль и блокировка экрана Информация о пароле - Оставьте пустым, чтобы удалить пароль Максимальное количество неудачных попыток ввода пароля Максимальное количество неудачных попыток Время истечения срока действия пароля @@ -518,17 +488,14 @@ Единый пароль Сбросить токен пароля Токен - Токен должен быть длиннее 32 байт - Токен уже активирован + Тoken activated Очистить Установить - Установите токен Токен будет автоматически активирован, если пароль не установлен. Сбросить пароль Подтвердите пароль Не запрашивать учетные данные при загрузке Требовать ввод - Сбросить пароль с помощью токена Требуемая сложность пароля Функции блокировки экрана (Keyguard) Включить все @@ -550,7 +517,6 @@ Биометрия (слабая) Сложный числовой (без повторений) Требуемое качество пароля - Активируйте токен сброса пароля здесь. @@ -632,7 +598,7 @@ Указывает, поддерживает ли устройство проверку идентификаторов устройств в дополнение к проверке ключей. Да, если реализация StrongBox Keymaster на устройстве была обеспечена индивидуальным сертификатом аттестации и может использовать его для подписи записей аттестации (индивидуальный сертификат аттестации могут использовать только Keymaster с уровнем безопасности StrongBox). - Устанавливает идентификатор предприятия (Enterprise ID). Это необходимо для создания идентификатора устройства, специфичного для регистрации. + Устанавливает идентификатор предприятия (Enterprise ID). Это необходимо для создания идентификатора устройства, специфичного для регистрации. Идентификатор останется неизменным, даже если рабочий профиль будет удален и создан заново (для той же организации), или если устройство будет сброшено до заводских настроек и перерегистрировано Отобразить краткое сообщение на экране блокировки. Переопределяет любую информацию о владельце, установленную пользователем вручную, и предотвращает ее дальнейшее изменение. Это будет отображено пользователю на экранах настроек, функциональность которых была отключена администратором. Если длина сообщения превышает 200 символов, оно может быть обрезано. @@ -657,8 +623,6 @@ Все данные этого пользователя будут стерты, но сам пользователь не будет удален. Контролировать, может ли пользователь изменять сети, настроенные администратором.\nКогда эта блокировка включена, пользователь по-прежнему может настраивать другие сети Wi-Fi и подключаться к ним, а также использовать другие возможности Wi-Fi, такие как режим модема. Указать минимальный уровень безопасности, требуемый для сетей Wi-Fi. Устройство может не подключаться к сетям, которые не соответствуют минимальному уровню безопасности. Если текущая сеть не соответствует установленному минимальному уровню безопасности, соединение будет разорвано. - В этом режиме подсистема DNS попытается установить TLS-соединение с DNS-сервером, предоставленным сетью, прежде чем пытаться разрешить имена в открытом виде. - Будет выполнена проверка соединения с DNS-сервером, чтобы убедиться в его работоспособности.\nВ случае использования VPN совместно с частным DNS-сервером, частный DNS-сервер должен быть доступен как изнутри, так и снаружи VPN. В противном случае устройство может потерять возможность разрешать имена хостов, так как системный трафик к DNS-серверу может не проходить через VPN. Настроить постоянное VPN-соединение через определенное приложение для текущего пользователя. Это соединение автоматически предоставляется и сохраняется после перезагрузки.\nВключить блокировку: Запретить сетевое подключение, когда VPN не подключен. Этот прокси-сервер является только рекомендацией, и некоторые приложения могут его игнорировать. Журналы сети содержат события поиска DNS и вызовы библиотеки connect().\nИспользование этой функции в рабочем профиле позволит получить журналы сети только в рабочем профиле.\nНа этом устройстве не должно быть неаффилированных пользователей, если оно используется владельцем устройства. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cff76b6..33d5f12 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -35,7 +35,6 @@ Zaman (ms) Uzunluk Yok - Kayıt Yok Varsayılan Uygula Kullanıcı Tarafından Karar Ver @@ -76,7 +75,6 @@ Profil Sahibi Cihaz Sahibi Yetkilendirilmiş Yöneticiler - Yetkilendirilmiş Kapsam Uygulama Kısıtlamalarını Yönet Sertifikaları Yönet KeyChain Sertifikasını Seç @@ -102,7 +100,6 @@ Hesap Yönetimini Devre Dışı Bırak Hesap Türü Sahipliği Devret - Hedef Bileşen Adı Kilit Ekranı Bilgisi Destek Mesajları Kısa Mesaj @@ -121,7 +118,6 @@ Dhizuku Başlatılamadı Dhizuku İzni Verilmedi - Dhizuku Modu Devre Dışı Sistem @@ -138,7 +134,7 @@ Yedekleme Servisi Bluetooth Kişi Paylaşımını Devre Dışı Bırak Ortak Kriterler Modu - USB Sinyalini Devre Dışı Bırak + Enable USB signal Kilit Ekranı Ekranı Şimdi Kilitle Kilit Ekranı @@ -197,7 +193,6 @@ Yakındaki Bildirim Akışı Yalnızca aynı yönetilen hesap Görev Kilitleme Modu - Etkinliği Belirt Uygulamaya izin verilmiyor Hepsini Devre Dışı Bırak @@ -243,8 +238,6 @@ Sistem Güncellemesini Yükle OTA paketini seç... - Sistem güncellemesini yüklemeye başla - "Sistem güncellemesi yüklenemedi: " Pil seviyesi düşük Güncelleme dosyası geçersiz Yanlış işletim sistemi sürümü @@ -287,7 +280,6 @@ Ağ Türü Mobil Ethernet - Abone Kimliği Tümü Kaldırılmış Bağlantı Paylaşımı @@ -302,13 +294,9 @@ Dolaşım Ölçülü Özel DNS - Ana bilgisayar adı sağla - Ana bilgisayar DNS TLS sunmuyor - Fırsatçı olarak ayarla DNS Ana Bilgisayar Adı Geçersiz ana bilgisayar adı Güvenlik İstisnası - DNS Ana Bilgisayarı Ayarla Önerilen küresel vekil Vekil yok PAC vekil @@ -317,7 +305,6 @@ Geçersiz yapılandırma Hariç tutulan ana bilgisayarlar Ağ Kayıtları - Kayıt dosyası boyutu: %1$s Kayıtları Sil Kayıtları Dışa Aktar Wi-Fi Kimlik Doğrulama Anahtar Çifti @@ -331,22 +318,12 @@ Satır başına bir UID APN\'yi Geçersiz Kıl APN Ayarı - APN Adı - Giriş Adı Ad - Profil Kimliği - Kimlik Doğrulama Türü Açıklama - MMS Vekil Adres Port Vekil Kalıcı - Protokol - Dolaşım Protokolü - Operatör Kimliği - MVNO Türü - Ağ Türü Bitmask Her Zaman Açık Güncelle @@ -365,10 +342,7 @@ Maksimum Kapalı Kalma Süresi İş profili kapatıldıktan sonra kişisel uygulamalar bu süre boyunca askıya alınacak. 0, sınırsız anlamına gelir. Kişisel uygulama şu nedenle askıya alındı: %1$s - 72 saatten az olamaz Niyet Filtresi - Ekle (işten kişisel profile) - Ekle (kişisel profilden işe) Tüm filtreleri temizle Kurum Kimliği Uzunluk 6 ile 64 karakter arasında olmalıdır @@ -514,7 +488,6 @@ Sihirbazı Atla Geçici Kullanıcı Tüm Sistem Uygulamalarını Etkinleştir - Bu kullanıcının seri numarası: %1$d Bağlılık Kimliği Kullanıcı Simgesini Değiştir Bir görüntü seç @@ -525,7 +498,6 @@ Parola ve Kilit Ekranı Parola Bilgisi - Parolayı kaldırmak için boş bırakın Maksimum Başarısız Parola Maksimum başarısız deneme sayısı Parola Son Kullanma Zaman Aşımı @@ -540,17 +512,14 @@ Birleşik Parola Parola Sıfırlama Jetonu Jeton - Jeton 32 bayttan uzun olmalıdır - Jeton zaten etkinleştirildi + Token activated Temizle Ayarla - Lütfen bir jeton ayarlayın Parola ayarlanmamışsa jeton otomatik olarak etkinleştirilir. Parolayı Sıfırla Parolayı Onayla Başlangıçta kimlik bilgisi sorma Giriş gerektir - Jeton ile parolayı sıfırla Gerekli Parola Karmaşıklığı Kilit Ekranı Özelliklerini Devre Dışı Bırak Hepsini Etkinleştir @@ -572,7 +541,6 @@ Biyometrik (Zayıf) Sayısal karmaşık (tekrar eden karakterler olmadan) Gerekli Parola Kalitesi - Parola sıfırlama jetonunu burada etkinleştir. Ayarlar @@ -671,7 +639,7 @@ Cihazın, anahtar doğrulamasına ek olarak cihaz kimlik doğrulamalarını destekleyip desteklemediğini belirtir. Evet, eğer cihazdaki StrongBox Keymaster uygulaması bireysel bir doğrulama sertifikasıyla sağlanmışsa ve bunu kullanarak doğrulama kayıtlarını imzalayabiliyorsa (yalnızca StrongBox güvenlik seviyesine sahip Keymaster bireysel doğrulama sertifikası kullanabilir). - Kurumsal Kimliği ayarlar. Bu, cihaz için kayıt özel bir kimlik oluşturmak için bir gerekliliktir. + Kurumsal Kimliği ayarlar. Bu, cihaz için kayıt özel bir kimlik oluşturmak için bir gerekliliktir. Kimlik, iş profili kaldırılsa ve aynı Kurum Kimliği ile yeniden oluşturulsa veya cihaz fabrika ayarlarına sıfırlanıp yeniden kaydedilse bile tutarlı kalır. Kilit ekranında kısa bir mesaj gösterir.\nKullanıcı tarafından manuel olarak ayarlanan herhangi bir sahip bilgisini geçersiz kılar ve kullanıcının bunu daha fazla değiştirmesini engeller. Bu, yönetici tarafından devre dışı bırakılan işlevlerin bulunduğu ayar ekranlarında kullanıcıya gösterilecektir. Mesaj 200 karakterden uzunsa kesilebilir. @@ -696,8 +664,6 @@ Bu kullanıcının tüm verileri silinecek, ancak kullanıcı kaldırılmayacak. Kullanıcının yönetici tarafından yapılandırılan ağları değiştirip değiştiremeyeceğini kontrol eder.\nBu kilit etkinleştirildiğinde, kullanıcı hala diğer Wi-Fi ağlarını yapılandırabilir ve bağlanabilir veya bağlantı paylaşımı gibi diğer Wi-Fi özelliklerini kullanabilir. Wi-Fi ağları için gereken minimum güvenlik seviyesini belirtir. Cihaz, minimum güvenlik seviyesini karşılamayan ağlara bağlanmayabilir. Mevcut ağ minimum güvenlik seviyesini karşılamıyorsa bağlantısı kesilecektir. - Bu modda, DNS alt sistemi, açık metinle ad çözümlemesi yapmadan önce ağ tarafından sağlanan çözücüye TLS el sıkışması yapmaya çalışır. - Çözücünün geçerli olduğunu doğrulamak için bir bağlantı kontrolü gerçekleştirir.\nÖzel DNS çözücüsü ile birlikte bir VPN kullanıldığında, Özel DNS çözücüsüne hem VPN içinden hem de dışından erişilebilir olmalıdır. Aksi takdirde, sistem trafiği VPN üzerinden çözücüye gitmeyebilir ve cihaz ana bilgisayar adlarını çözümleme yeteneğini kaybedebilir. Geçerli kullanıcı için belirli bir uygulama aracılığıyla her zaman açık bir VPN bağlantısı yapılandırır. Bu bağlantı otomatik olarak verilir ve yeniden başlatma sonrası kalıcıdır.\nKilitlemeyi etkinleştir: VPN bağlı değilken ağ kullanımını engelle. Bu vekil yalnızca bir öneridir ve bazı uygulamalar bunu yok sayabilir. Ağ kayıtları, DNS aramalarını ve connect() kütüphane çağrı olaylarını içerir.\nBu işlev iş profilinde kullanıldığında yalnızca iş profilindeki ağ kayıtlarını alır.\nCihaz sahibi tarafından kullanılıyorsa, cihazda bağlı olmayan kullanıcı olmamalıdır. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index abd9192..18c10cc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -35,7 +35,6 @@ 时间(毫秒) 长度 - 无日志 默认 应用 由用户决定 @@ -74,7 +73,6 @@ Profile owner Device owner 委托管理员 - 委托作用域 管理应用限制 管理证书 选择密钥链证书 @@ -99,7 +97,6 @@ 禁用账号管理 账号类型 转移所有权 - 目标组件名 锁屏提示信息 提供支持的消息 提供支持的短消息 @@ -118,7 +115,6 @@ Dhizuku初始化失败 Dhizuku未授权 - Dhizuku模式已禁用 系统 @@ -126,6 +122,7 @@ 启用相机 禁止屏幕捕获 禁用状态栏 + 状态栏 自动设置时间 自动设置时区 要求自动时间 @@ -135,7 +132,7 @@ 备份服务 禁止蓝牙分享联系人 通用标准模式 - 禁用USB信号 + 启用USB信号 锁屏 立即锁屏 锁屏 @@ -159,6 +156,7 @@ 手动输入 日期 时间 + 使用当前时区 更改时区 时区ID 在设置时区前需要关闭自动时区 @@ -175,7 +173,6 @@ 附近通知传输 在足够安全时启用 锁定任务模式 - 指定Activity 应用未被允许 禁用全部 允许状态栏信息 @@ -195,6 +192,8 @@ 卸载所有用户证书 安全日志 重启前安全日志 + 安全日志已收集 + 总共 %1$d 条日志 清除数据 清除外部存储 清除受保护的数据 @@ -221,8 +220,6 @@ 系统更新接收时间: %1$s 安装系统更新 选择OTA包... - 开始安装系统更新 - 安装系统更新失败: 电量低 OTA包无效 系统版本错误 @@ -250,6 +247,7 @@ Invalid configuration 最低Wi-Fi安全等级 开放 + 不更改 锁定由管理员配置的网络 Wi-Fi SSID策略 已经存在 @@ -265,13 +263,13 @@ 网络类型 移动 以太网 - 订阅者ID 全部 已卸载 热点 选择一个app... 输入 查询 + 无数据 发送 接收 状态 @@ -280,13 +278,10 @@ 漫游 按量计费 私人DNS - 指定主机名 - 主机不支持 - 设为自动 + 自动 DNS主机名 无效主机名 安全错误 - 设置DNS主机 建议的全局代理 无代理 PAC代理 @@ -295,7 +290,7 @@ 无效配置 排除的主机 网络日志 - 日志文件大小:%1$s + 网络日志已收集 删除日志 导出日志 Wi-Fi密钥对 @@ -309,22 +304,12 @@ 每行一个UID 覆盖APN APN设置 - APN名称 - 条目名称 名称 - 资料ID - 认证类型 描述 - MMS代理 地址 端口 代理 持久化 - 协议 - 漫游协议 - 运营商ID - MVNO类型 - 网络类型位掩码 总是开启 更新 @@ -343,10 +328,11 @@ 资料关闭时间 工作资料处于关闭状态的时间达到该限制后会挂起个人应用,0为无限制 个人应用已经因此挂起:%1$s - 不能少于72小时 Intent过滤器 - 添加(工作到个人) - 添加(个人到工作) + 方向 + 双向 + 工作到个人 + 个人到工作 清除所有过滤器 组织ID 长度应在6~64个字符之间 @@ -383,9 +369,15 @@ 启用系统应用 卸载后保留 搜索 + 应用组 + 管理组 + 编辑组 + 添加到列表 + 从列表中移除 用户限制 + 长按一个条目以创建快捷方式 Profile owner无法使用部分功能 打开开关后会禁用对应的功能 工作资料中部分功能无效 @@ -481,17 +473,21 @@ 次要用户 无次要用户 用户操作 + 启动用户 %1$d + 切换到用户 %1$d + 停止用户 %1$d 用户不存在 序列号 登出 在后台启动 切换 + 已达到上限 + 你确定要删除用户%1$s吗? 创建用户 用户名 跳过创建用户向导 临时用户 启用所有系统应用 - 新用户的序列号:%1$d 附属用户ID 更换用户头像 选择一个图片 @@ -502,7 +498,6 @@ 密码与锁屏 密码信息 - 留空以清除密码 最大密码错误次数 错误次数 密码失效超时 @@ -517,17 +512,15 @@ 一致的密码 密码重置令牌 令牌 - 令牌必须大于32字节 - 令牌已经激活 + 激活密码重置令牌 + 令牌已激活 清除 设置 - 请先设置令牌 没有密码时会自动激活令牌 重置密码 确认密码 启动(boot)时不要求密码 不允许其他设备管理员重置密码直至用户输入一次密码 - 使用令牌重置密码 密码复杂度要求 锁屏功能 启用全部 @@ -538,7 +531,7 @@ 禁用可信代理 禁用指纹解锁 禁用人脸解锁 - 禁用虹膜解锁(?) + 禁用虹膜解锁 禁用生物识别 禁用快捷方式 未指定 @@ -549,7 +542,6 @@ 生物识别(弱) 复杂数字(无连续性) 密码质量要求 - 在这里激活密码重置令牌 设置 @@ -647,7 +639,7 @@ 指示设备是否除了密钥证明之外还支持设备标识符证明 如果设备上的StrongBox Keymaster可以配置单独的证明证书并且可以使用该证书签署证明记录,则返回true(只有StrongBox安全级别的Keymaster才能使用单独的证明证书进行证明) - 设置组织ID后才能获取设备注册专用ID + 设置组织ID后才能获取设备注册专用ID。ID只能设置一次。 不同组织ID的设备注册专用ID不同,恢复出厂设置或删除工作资料后不变 在锁屏界面上显示的一段简短的消息。将会覆盖用户当前设置的锁屏信息,并且防止用户在系统设置中设置新的锁屏信息 用户试图使用被管理员禁用的功能时会显示此消息。不应多于200字 @@ -672,8 +664,6 @@ 此用户的所有数据将会被清除,但是用户不会被删除。 控制用户是否可以更改管理员配置的网络。启用此锁定后,用户仍然可以配置和连接到其他Wi-Fi,或使用其他Wi-Fi功能(如网络共享)。 指定Wi-Fi网络所需的最低安全等级。设备将无法连接到低于最低安全等级的网络。如果当前网络不满足要求,则会断开连接。 - 在此模式下,DNS子系统将在尝试以明文形式进行域名解析之前,尝试与网络提供的DNS服务器进行TLS握手。 - 将对DNS服务器执行连接检查,以确保其有效。\n如果将VPN与私人DNS结合使用,则私人DNS必须可从VPN内部和外部访问。否则设备可能会失去解析域名的能力,因为到DNS服务器的系统流量可能不会通过VPN。 通过一个指定的app,为当前用户设置一个保持打开的VPN连接。自动授权连接并在重启后保留。\n启用锁定:如果VPN未连接,则禁止使用网络。 这个代理只是一个建议,一些app有可能忽略它。 网络日志包含DNS查询和connect()库调用记录 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b963b9..e3baaaf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,6 @@ Time(ms) Length None - No logs Default Apply Decide by user @@ -79,7 +78,6 @@ Profile owner Device owner Delegated admins - delegated scope Manage application restrictions Manage certificates Select KeyChain certificate @@ -105,7 +103,6 @@ Disable account management Account type Transfer Ownership - Target component name Lock screen info Support Messages Short message @@ -125,7 +122,6 @@ Dhizuku Failed to initialize Dhizuku Dhizuku permission not granted - Dhizuku mode disabled Shizuku @@ -135,6 +131,7 @@ Enable camera Disable screen capture Disable status bar + Status bar Auto time Require auto time Auto timezone @@ -144,7 +141,7 @@ Backup service Disable bluetooth contact sharing Common criteria mode - Disable USB signal + Enable USB signal Keyguard Lock screen now Lock screen @@ -168,6 +165,7 @@ Manually input Date Time + Use current timezone Change timezone Timezone ID Auto timezone should be disabled before set a custom timezone. @@ -203,7 +201,6 @@ Nearby notification streaming policy Same managed account only Lock task mode - Specify Activity App is not allowed Disable all @@ -222,6 +219,8 @@ Uninstall all user CA certificate Security logging Pre-reboot security logs + Security logs collected + %1$d logs in total Wipe data Wipe external storage Wipe protected data @@ -249,8 +248,6 @@ Install system update Select OTA package... - Start installing system update - "Install system update failed: " Battery is low Update file is invalid Incorrect OS version @@ -279,6 +276,10 @@ Invalid configuration Minimum Wi-Fi security level Open + PSK + DHCP + HTTP + Unchanged Lockdown admin configured network Wi-Fi SSID policy Already exist @@ -296,13 +297,13 @@ Mobile Ethernet VPN - Subscriber ID All Uninstalled Tethering Choose an app... Input Query + No data Transmitted Received State @@ -311,13 +312,10 @@ Roaming Metered Private DNS - Provide hostname - Host not serving - Set to opportunistic + Automatic DNS hostname Invalid hostname Security Exception - Set DNS host Recommended global proxy No proxy PAC proxy @@ -326,7 +324,7 @@ Invalid config Excluded hosts Network logging - Log file size: %1$s + Network logs collected Delete logs Export logs Wi-Fi keypair @@ -340,22 +338,12 @@ One UID per line Override APN APN setting - APN name - Entry name Name - Profile ID - Auth type Description - MMS proxy Address Port Proxy Persistent - Protocol - Roaming protocol - Carrier ID - MVNO type - Network type bitmask Always on Update @@ -374,10 +362,11 @@ Max time off Personal apps will be suspended after the work profile is closed for this amount of time. 0 means no limit. Personal app suspended because of this: %1$s - Cannot less than 72 hours Intent filter - Add(work to personal) - Add(personal to work) + Direction + Both direction + Work to personal + Personal to work Clear all filters Organization ID The length should be between 6~64 characters @@ -414,9 +403,15 @@ Install existing app Keep after uninstall Search + App group + Manage groups + Edit group + Add to list + Remove from list User restriction + Long press an item to create a shortcut Profile owner can use limited function Turn on a switch to disable that function. Functions in work profile is limited. @@ -514,17 +509,21 @@ Secondary users No secondary users User operation + Start user %1$d + Switch to user %1$d + Stop user %1$d User does not exist Serial number Logout Start in background Switch + Limit reached + Are you sure you want to delete user %1$s ? Create user Username Skip wizard Ephemeral user Enable all system app - Serial number of this user: %1$d Affiliation ID Change user icon Select an image @@ -535,7 +534,6 @@ Password and keyguard Password Info - Keep empty to remove password Max failed passwords Maximum failed attempts Password expiration timeout @@ -550,17 +548,15 @@ Unified password Reset password token Token - The token must be longer than 32-byte - Token already activated + Activate reset password token + Token activated Clear Set - Please set a token Token will be automatically activated if no password is set. Reset password Confirm password Do not ask credentials on boot Require entry - Reset password with token Required password complexity Keyguard features Enable all @@ -582,7 +578,6 @@ Biometrics (Weak) Numeric complex (No repeating characters) Required password quality - Activate reset password token here. Settings @@ -681,7 +676,7 @@ Indicates if the device supports attestation of device identifiers in addition to key attestation. Yes if the StrongBox Keymaster implementation on the device was provisioned with an individual attestation certificate and can sign attestation records using it (only Keymaster with StrongBox security level can use an individual attestation certificate). - Sets the Enterprise ID. This is a requirement for generating an enrollment-specific ID for the device. + Sets the Enterprise ID. This is a requirement for generating an enrollment-specific ID for the device. The ID can only be set once. The identifier would be consistent even if the work profile is removed and create again (to the same Organization ID), or the device is factory reset and re-enrolled. Show a brief message on your lock screen.\nOverrides any owner information manually set by the user and prevents the user from further changing it. This will be displayed to the user in settings screens where functionality has been disabled by the admin. If the message is longer than 200 characters it may be truncated @@ -706,8 +701,6 @@ All data of this user will be wiped, but that user won\'t be removed. Control whether the user can change networks configured by the admin.\nWhen this lockdown is enabled, the user can still configure and connect to other Wi-Fi networks, or use other Wi-Fi capabilities such as tethering. Specify the minimum security level required for Wi-Fi networks. The device may not connect to networks that do not meet the minimum security level. If the current network does not meet the minimum security level set, it will be disconnected. - In this mode, the DNS subsystem will attempt a TLS handshake to the network-supplied resolver prior to attempting name resolution in cleartext. - It will perform a connectivity check to the resolver, to ensure it is valid.\nIn case a VPN is used in conjunction with Private DNS resolver, the Private DNS resolver must be reachable both from within and outside the VPN. Otherwise, the device may lose the ability to resolve hostnames as system traffic to the resolver may not go through the VPN. Configure an always-on VPN connection through a specific application for the current user. This connection is automatically granted and persisted after a reboot.\nEnable lockdown: Disallow networking when the VPN is not connected. This proxy is only a recommendation and it is possible that some apps will ignore it. Network logs contain DNS lookup and connect() library call events.\nUse this function in work profile will only retrieve network logs in work profile.\nThere shouldn\'t be unaffiliated user on this device if used by Device owner. diff --git a/gradle.properties b/gradle.properties index 643d242..7d47327 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,3 +7,4 @@ kotlin.code.style=official org.gradle.parallel=true org.gradle.caching=true org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx1536M" +org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 267a1b5..1a68b36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -agp = "8.12.2" -kotlin = "2.2.10" +agp = "8.13.1" +kotlin = "2.2.21" -navigation-compose = "2.9.3" -composeBom = "2025.08.01" +navigation-compose = "2.9.6" +composeBom = "2025.11.00" accompanist-drawablepainter = "0.37.3" accompanist-permissions = "0.37.3" shizuku = "13.1.5" fragment = "1.8.9" -dhizuku = "2.5.3" -dhizuku-server = "0.0.5" +dhizuku = "2.5.4" +dhizuku-server = "0.0.10" hiddenApiBypass = "6.1" libsu = "6.0.0" serialization = "1.9.0" @@ -22,6 +22,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose" } androidx-material3 = { module = "androidx.compose.material3:material3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" } +material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist-drawablepainter" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist-permissions" } @@ -39,4 +40,4 @@ serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.10" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.21" }