diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5eb926a..eaf90d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '21' @@ -34,13 +34,13 @@ jobs: run: ./gradlew build - name: Upload Debug APK (testkey) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: OwnDroid-CI-${{ env.SHORT_SHA }}-debug-testkey path: app/build/outputs/apk/debug/app-debug.apk - name: Upload Release APK (testkey) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: OwnDroid-CI-${{ env.SHORT_SHA }}-release-testkey path: app/build/outputs/apk/release/app-release.apk @@ -51,13 +51,13 @@ jobs: ./gradlew build -PStoreFile="$(pwd)/app/release.jks" -PStorePassword="${{ secrets.KEYSTORE_PASSWORD }}" -PKeyPassword="${{ secrets.KEY_PASSWORD }}" -PKeyAlias="${{ secrets.KEY_ALIAS }}" - name: Upload Debug APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: OwnDroid-CI-${{ env.SHORT_SHA }}-debug-signed path: app/build/outputs/apk/debug/app-debug.apk - name: Upload Release APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: OwnDroid-CI-${{ env.SHORT_SHA }}-release-signed path: app/build/outputs/apk/release/app-release.apk @@ -68,10 +68,16 @@ jobs: needs: build steps: - name: Download Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts + - name: Download telegram-bot-api + run: | + mkdir ./binaries + wget "https://github.com/jakbin/telegram-bot-api-binary/releases/download/latest/telegram-bot-api" -O ./binaries/telegram-bot-api + chmod +x ./binaries/telegram-bot-api + - name: Start API Server & Upload env: COMMIT_MESSAGE: |+ @@ -85,7 +91,8 @@ jobs: mv ./$RELEASE_TEST_PWD/app-release.apk ./$RELEASE_TEST_PWD.apk && rm -rf ./$RELEASE_TEST_PWD export RELEASE_SIGNED_PWD=$(find . -name "*release-signed*") mv ./$RELEASE_SIGNED_PWD/app-release.apk ./$RELEASE_SIGNED_PWD.apk && rm -rf ./$RELEASE_SIGNED_PWD + ../binaries/telegram-bot-api --api-id=${{ secrets.TELEGRAM_API_APP_ID }} --api-hash=${{ secrets.TELEGRAM_API_HASH }} --local 2>&1 > /dev/null & export token=${{ secrets.TELEGRAM_BOT_KEY }} - curl -v "http://api.telegram.org/bot$token/sendMediaGroup?chat_id=-1002203528169&media=%5B%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseTest%22%7D%2C%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseSigned%22%2C%22parse_mode%22%3A%22HTML%22%2C%22caption%22%3A${ESCAPED}%7D%5D" \ + curl -v "http://127.0.0.1:8081/bot$token/sendMediaGroup?chat_id=-1002203528169&media=%5B%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseTest%22%7D%2C%7B%22type%22%3A%22document%22%2C%22media%22%3A%22attach%3A%2F%2FreleaseSigned%22%2C%22parse_mode%22%3A%22HTML%22%2C%22caption%22%3A${ESCAPED}%7D%5D" \ -F releaseTest="@$RELEASE_TEST_PWD.apk" \ -F releaseSigned="@$RELEASE_SIGNED_PWD.apk" diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml new file mode 100644 index 0000000..c3ae2f0 --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,22 @@ +name: Dependency submission + +on: + push: + branches: [ "master" ] + +jobs: + dependency-submission: + name: Dependency submission + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e2288c..3218d34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,12 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 - with: - ref: 'master' + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '21' @@ -33,7 +31,4 @@ jobs: - name: Create release run: git tag -l ${{ github.ref_name }} --format="%(contents:body)" | gh release create ${{ github.ref_name }} -t ${{ github.ref_name }} *.apk -d -F - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate and submit dependency graph - uses: gradle/actions/dependency-submission@v4 \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/Readme-ja.md b/Readme-ja.md index 4a8c3d5..eb017c8 100644 --- a/Readme-ja.md +++ b/Readme-ja.md @@ -1,7 +1,8 @@ [English](Readme.md) | [简体中文](Readme-zh_CN.md) > [!important] -> The Japanese readme need update +> The Japanese readme is outdated +> 日语的Readme已经过时 # OwnDroid diff --git a/Readme-zh_CN.md b/Readme-zh_CN.md index 349d306..1bd036f 100644 --- a/Readme-zh_CN.md +++ b/Readme-zh_CN.md @@ -35,27 +35,21 @@ ## FAQ -### 设备上有账号 +### 设备上已有账号 ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already some accounts on the device ``` -解决办法: -- 冻结持有这些账号的app。 -- 删除这些账号。 +解决办法:冻结持有这些账号的app,或删除这些账号。 -### 设备上有多个用户 +### 设备上已有多个用户 ```text java.lang.IllegalStateException: Not allowed to set the device owner because there are already several users on the device ``` -解决办法: -- 删除次级用户。 - -> [!NOTE] -> 一些系统有应用克隆、儿童空间等功能,它们通常是用户。 +解决办法:删除其他用户,包括工作资料、私密空间和应用分身。 ### Device owner 已存在 @@ -89,7 +83,25 @@ user limit reached 三星限制了多用户功能,暂无解决办法。 -## API + +### 创建工作资料/用户 + +在大部分设备上,设置device owner后不能创建工作资料,因为系统在设置device owner时会添加`no_add_managed_profile`等用户限制。 +Device owner不能修改系统设置的用户限制,但如果你有root权限,你可以在adb shell中执行以下命令以关闭这个限制。 + +```shell +pm set-user-restriction no_add_user 0 +pm set-user-restriction no_add_managed_profile 0 +pm set-user-restriction no_add_private_profile 0 +pm set-user-restriction no_add_clone_profile 0 +``` + +一些系统在设置了device owner后不允许在安卓设置中创建用户,你可以在OwnDroid中创建用户。 +如果你有root,你也可以在adb shell中运行以上命令以解除限制。 + +## 高级用户 + +### API OwnDroid提供了一个基于Intent的API。你需要在设置中设置密钥并启用API。括号中的数字是最小的安卓版本。 @@ -127,7 +139,9 @@ context.sendBroadcast(intent) [可用的用户限制](https://developer.android.google.cn/reference/android/os/UserManager#constants_1) -## 构建 +## 开发者 + +### 构建 你可以在命令行中使用Gradle以构建OwnDroid ```shell @@ -138,6 +152,10 @@ context.sendBroadcast(intent) ``` (在Windows系统中应使用`./gradlew.bat`) +### 贡献 + +请使用`dev`分支。 + ## 许可证 [License.md](LICENSE.md) diff --git a/Readme.md b/Readme.md index a462f4d..961e254 100644 --- a/Readme.md +++ b/Readme.md @@ -41,9 +41,7 @@ Use Android's DevicePolicyManager API to manage your device. 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. +Solutions: freeze the accounts' holder apps, or delete those accounts. ### Already several users on the device @@ -51,11 +49,7 @@ Solutions: 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. +Solution: Delete secondary users, including work profile, private space and app cloning. ### Device owner is already set @@ -63,7 +57,7 @@ Solutions: 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. +Only one device owner can exist on a device. Please deactivate the existing device owner first. ### MIUI & HyperOS @@ -91,7 +85,25 @@ user limit reached Samsung restricts Android's multiple users feature. There is currently no solution. -## API +### Create work profile / user + +On most devices, creating work profile is not allowed by the system when the device owner exist. +Because the system add `no_add_managed_profile` user restriction when a device owner is set. +Device owner can't modify user restrictions set by the system, but if your device is rooted, you can disable this restriction by executing the following commands in adb shell. + +```shell +pm set-user-restriction no_add_user 0 +pm set-user-restriction no_add_managed_profile 0 +pm set-user-restriction no_add_private_profile 0 +pm set-user-restriction no_add_clone_profile 0 +``` + +Some systems disable the feature of adding users in Android settings once a device owner is set. +You have to create users in OwnDroid. Or if you have root, run the above command in adb shell to remove that restriction. + +## For advanced users + +### API OwnDroid provides an Intent-based API. You need to set the API key in settings and enable the API. The numbers in brackets represent the minimum Android version required. @@ -129,7 +141,9 @@ context.sendBroadcast(intent) [Available user restrictions](https://developer.android.com/reference/android/os/UserManager#constants_1) -## Build +## For developers + +### Build You can use Gradle in command line to build OwnDroid. ```shell @@ -140,6 +154,10 @@ You can use Gradle in command line to build OwnDroid. ``` (Use `./gradlew.bat` instead on Windows) +### Contribute + +Please use the `dev` branch. + ## License [License.md](LICENSE.md) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa9225d..354a9f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.compose) alias(libs.plugins.serialization) } @@ -26,8 +25,8 @@ android { applicationId = "com.bintianqi.owndroid" minSdk = 23 targetSdk = 36 - versionCode = 42 - versionName = "7.3" + versionCode = 43 + versionName = "8.0" multiDexEnabled = false } @@ -63,10 +62,11 @@ android { dependenciesInfo { includeInApk = false } - composeCompiler { - includeSourceInformation = false - includeTraceMarkers = false - } +} + +composeCompiler { + includeSourceInformation = false + includeTraceMarkers = false } kotlin { @@ -89,12 +89,14 @@ gradle.taskGraph.whenReady { dependencies { implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.nav3.runtime) + implementation(libs.androidx.nav3.ui) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.accompanist.drawablepainter) 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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 48b55e8..b19edd8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,5 +24,3 @@ -dontwarn android.app.ActivityThread -dontwarn android.app.ContextImpl -dontwarn android.app.LoadedApk - --keep class com.bintianqi.owndroid.MyViewModel { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e328d50..38c707b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,72 +1,79 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + android:windowSoftInputMode="adjustResize|stateHidden"> - - - - + + + + + - + android:name=".activity.ManageSpaceActivity" + android:theme="@style/Theme.Transparent"> - - - - - + + + + + + + + + + + + + + + + + + + + + + + + android:exported="false" + android:permission="android.permission.BIND_DEVICE_ADMIN"> + android:resource="@xml/device_admin" /> - - - - - + + + + + - + android:exported="true"> - + android:exported="true"> diff --git a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt index eca67d7..a224a5b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt @@ -5,70 +5,95 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log +import com.bintianqi.owndroid.utils.hash -class ApiReceiver: BroadcastReceiver() { +class ApiReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val requestKey = intent.getStringExtra("key") var log = "OwnDroid API request received. action: ${intent.action}" - val key = SP.apiKeyHash - if(!key.isNullOrEmpty() && key == requestKey?.hash()) { + val myApp = context.applicationContext as MyApplication + val key = myApp.container.settingsRepo.data.apiKeyHash + if (key.isNotEmpty() && key == requestKey?.hash()) { val app = intent.getStringExtra("package") val permission = intent.getStringExtra("permission") val restriction = intent.getStringExtra("restriction") if (!app.isNullOrEmpty()) log += "\npackage: $app" if (!permission.isNullOrEmpty()) log += "\npermission: $permission" try { - @SuppressWarnings("NewApi") - when(intent.action?.removePrefix("com.bintianqi.owndroid.action.")) { - "HIDE" -> Privilege.DPM.setApplicationHidden(Privilege.DAR, app, true) - "UNHIDE" -> Privilege.DPM.setApplicationHidden(Privilege.DAR, app, false) - "SUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), true) - "UNSUSPEND" -> Privilege.DPM.setPackagesSuspended(Privilege.DAR, arrayOf(app), false) - "ADD_USER_RESTRICTION" -> { Privilege.DPM.addUserRestriction(Privilege.DAR, restriction) } - "CLEAR_USER_RESTRICTION" -> { Privilege.DPM.clearUserRestriction(Privilege.DAR, restriction) } - "SET_PERMISSION_DEFAULT" -> { - Privilege.DPM.setPermissionGrantState( - Privilege.DAR, app!!, permission!!, - DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT - ) - } - "SET_PERMISSION_GRANTED" -> { - Privilege.DPM.setPermissionGrantState( - Privilege.DAR, app!!, permission!!, - DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED - ) - } - "SET_PERMISSION_DENIED" -> { - Privilege.DPM.setPermissionGrantState( - Privilege.DAR, app!!, permission!!, - DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED - ) - } - "LOCK" -> { Privilege.DPM.lockNow() } - "REBOOT" -> { Privilege.DPM.reboot(Privilege.DAR) } - "SET_CAMERA_DISABLED" -> { - Privilege.DPM.setCameraDisabled(Privilege.DAR, true) - } - "SET_CAMERA_ENABLED" -> { - Privilege.DPM.setCameraDisabled(Privilege.DAR, false) - } - "SET_USB_DISABLED" -> { - Privilege.DPM.isUsbDataSignalingEnabled = false - } - "SET_USB_ENABLED" -> { - Privilege.DPM.isUsbDataSignalingEnabled = true - } - "SET_SCREEN_CAPTURE_DISABLED" -> { - Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, true) - } - "SET_SCREEN_CAPTURE_ENABLED" -> { - Privilege.DPM.setScreenCaptureDisabled(Privilege.DAR, false) - } - else -> { - log += "\nInvalid action" + myApp.container.privilegeHelper.safeDpmCall { + @SuppressWarnings("NewApi") + when (intent.action?.removePrefix("com.bintianqi.owndroid.action.")) { + "HIDE" -> dpm.setApplicationHidden(dar, app, true) + "UNHIDE" -> dpm.setApplicationHidden(dar, app, false) + "SUSPEND" -> dpm.setPackagesSuspended(dar, arrayOf(app), true) + "UNSUSPEND" -> dpm.setPackagesSuspended(dar, arrayOf(app), false) + "ADD_USER_RESTRICTION" -> { + dpm.addUserRestriction(dar, restriction) + } + + "CLEAR_USER_RESTRICTION" -> { + dpm.clearUserRestriction(dar, restriction) + } + + "SET_PERMISSION_DEFAULT" -> { + dpm.setPermissionGrantState( + dar, app!!, permission!!, + DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT + ) + } + + "SET_PERMISSION_GRANTED" -> { + dpm.setPermissionGrantState( + dar, app!!, permission!!, + DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED + ) + } + + "SET_PERMISSION_DENIED" -> { + dpm.setPermissionGrantState( + dar, app!!, permission!!, + DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED + ) + } + + "LOCK" -> { + dpm.lockNow() + } + + "REBOOT" -> { + dpm.reboot(dar) + } + + "SET_CAMERA_DISABLED" -> { + dpm.setCameraDisabled(dar, true) + } + + "SET_CAMERA_ENABLED" -> { + dpm.setCameraDisabled(dar, false) + } + + "SET_USB_DISABLED" -> { + dpm.isUsbDataSignalingEnabled = false + } + + "SET_USB_ENABLED" -> { + dpm.isUsbDataSignalingEnabled = true + } + + "SET_SCREEN_CAPTURE_DISABLED" -> { + dpm.setScreenCaptureDisabled(dar, true) + } + + "SET_SCREEN_CAPTURE_ENABLED" -> { + dpm.setScreenCaptureDisabled(dar, false) + } + + else -> { + log += "\nInvalid action" + } } } - } catch(e: Exception) { + } catch (e: Exception) { e.printStackTrace() val message = (e::class.qualifiedName ?: "Exception") + ": " + (e.message ?: "") log += "\n$message" @@ -78,6 +103,7 @@ class ApiReceiver: BroadcastReceiver() { } Log.d(TAG, log) } + companion object { private const val TAG = "API" } diff --git a/app/src/main/java/com/bintianqi/owndroid/AppContainer.kt b/app/src/main/java/com/bintianqi/owndroid/AppContainer.kt new file mode 100644 index 0000000..23da9ce --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/AppContainer.kt @@ -0,0 +1,38 @@ +package com.bintianqi.owndroid + +import com.bintianqi.owndroid.feature.applications.AppGroupRepository +import com.bintianqi.owndroid.feature.network.NetworkLoggingRepository +import com.bintianqi.owndroid.feature.privilege.DhizukuServerRepository +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.feature.system.SecurityLoggingRepository +import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterRepository +import com.bintianqi.owndroid.utils.DhizukuError +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow + +class AppContainer(val app: MyApplication) { + val dbHelper = MyDbHelper(app) + val networkLoggingRepo = NetworkLoggingRepository(dbHelper) + val securityLoggingRepo = SecurityLoggingRepository(dbHelper) + val appGroupRepo = AppGroupRepository(dbHelper) + val dhizukuServerRepo = DhizukuServerRepository(dbHelper) + val cpifRepo = CrossProfileIntentFilterRepository(dbHelper) + val settingsRepo = SettingsRepository(app.filesDir.resolve("settings.json")) + val dhizukuErrorState = MutableStateFlow(null) + val privilegeHelper = PrivilegeHelper( + app, settingsRepo.data.privilege.dhizuku, dhizukuErrorState + ) + val privilegeState = MutableStateFlow(PrivilegeStatus()) + val appGroupsState = MutableStateFlow(appGroupRepo.getAppGroups()) + val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST) + val themeState = MutableStateFlow(settingsRepo.data.theme) + val toastChannel = ToastChannel(app) + val viewModelFactory = MyViewModelFactory( + app, privilegeHelper, settingsRepo, networkLoggingRepo, dhizukuServerRepo, + securityLoggingRepo, appGroupRepo, cpifRepo, appGroupsState, dhizukuErrorState, + privilegeState, themeState, toastChannel + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt index 7fbb400..47161e9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt @@ -7,23 +7,27 @@ import androidx.activity.viewModels import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.fragment.app.FragmentActivity -import com.bintianqi.owndroid.ui.AppInstaller +import com.bintianqi.owndroid.feature.applications.AppInstaller +import com.bintianqi.owndroid.feature.applications.AppInstallerViewModel import com.bintianqi.owndroid.ui.theme.OwnDroidTheme +import com.bintianqi.owndroid.utils.viewModelFactory -class AppInstallerActivity:FragmentActivity() { +class AppInstallerActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - val vm by viewModels() + val myApp = application as MyApplication + val vm by viewModels { + viewModelFactory { + AppInstallerViewModel(myApp, myApp.container.settingsRepo) + } + } vm.initialize(intent) - vm.registerInstallerReceiver(this) - val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) + val themeState = myApp.container.themeState setContent { + val theme by themeState.collectAsState() OwnDroidTheme(theme) { - val uiState by vm.uiState.collectAsState() - AppInstaller( - uiState, vm::onPackagesAdd, vm::onPackageRemove, vm::startInstall, vm::closeResultDialog - ) + AppInstaller(vm) } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt index 51bce41..0701c4c 100644 --- a/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt +++ b/app/src/main/java/com/bintianqi/owndroid/DhizukuServer.kt @@ -24,7 +24,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.feature.privilege.DhizukuClientInfo +import com.bintianqi.owndroid.ui.screen.AppLockDialog import com.bintianqi.owndroid.ui.theme.OwnDroidTheme +import com.bintianqi.owndroid.utils.MyAdminComponent +import com.bintianqi.owndroid.utils.getPackageSignature import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.rosan.dhizuku.aidl.IDhizukuClient import com.rosan.dhizuku.aidl.IDhizukuRequestPermissionListener @@ -32,21 +36,23 @@ import com.rosan.dhizuku.server_api.DhizukuProvider import com.rosan.dhizuku.server_api.DhizukuService import com.rosan.dhizuku.shared.DhizukuVariables import kotlinx.coroutines.delay -import kotlinx.serialization.Serializable private const val TAG = "DhizukuServer" -class MyDhizukuProvider(): DhizukuProvider() { +class MyDhizukuProvider : DhizukuProvider() { override fun onCreateService(client: IDhizukuClient): DhizukuService? { Log.d(TAG, "Creating MyDhizukuService") - return if (SP.dhizukuServer) MyDhizukuService(context!!, MyAdminComponent, client) else null + val settingsRepo = (context!!.applicationContext as MyApplication).container.settingsRepo + return if (settingsRepo.data.privilege.dhizukuServer) + MyDhizukuService(context!!, MyAdminComponent, client) else null } } class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuClient) : DhizukuService(context, admin, client) { override fun checkCallingPermission(func: String?, callingUid: Int, callingPid: Int): Boolean { - if (!SP.dhizukuServer) return false + val settingsRepo = (mContext.applicationContext as MyApplication).container.settingsRepo + if (!settingsRepo.data.privilege.dhizukuServer) return false val pm = mContext.packageManager val packageInfo = pm.getPackageInfo( pm.getNameForUid(callingUid) ?: return false, @@ -59,11 +65,14 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC "get_delegated_scopes", "set_delegated_scopes" -> "delegated_scopes" else -> "other" } - val hasPermission = (mContext.applicationContext as MyApplication).myRepo - .checkDhizukuClientPermission( - callingUid, signature, requiredPermission + val hasPermission = (mContext.applicationContext as MyApplication) + .container.dhizukuServerRepo.checkDhizukuClientPermission( + callingUid, signature, requiredPermission + ) + Log.d( + TAG, + "UID $callingUid, PID $callingPid, required permission: $requiredPermission, has permission: $hasPermission" ) - Log.d(TAG, "UID $callingUid, PID $callingPid, required permission: $requiredPermission, has permission: $hasPermission") return hasPermission } @@ -71,17 +80,20 @@ class MyDhizukuService(context: Context, admin: ComponentName, client: IDhizukuC } class DhizukuActivity : ComponentActivity() { + val settingsRepo = (application as MyApplication).container.settingsRepo + @OptIn(ExperimentalStdlibApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!SP.dhizukuServer) { + if (!settingsRepo.data.privilege.dhizukuServer) { finish() return } val bundle = intent.extras ?: return val uid = bundle.getInt(DhizukuVariables.PARAM_CLIENT_UID, -1) if (uid == -1) return - val binder = bundle.getBinder(DhizukuVariables.PARAM_CLIENT_REQUEST_PERMISSION_BINDER) ?: return + val binder = + bundle.getBinder(DhizukuVariables.PARAM_CLIENT_REQUEST_PERMISSION_BINDER) ?: return val listener = IDhizukuRequestPermissionListener.Stub.asInterface(binder) val packageName = packageManager.getPackagesForUid(uid)?.first() ?: return val packageInfo = packageManager.getPackageInfo( @@ -93,19 +105,19 @@ class DhizukuActivity : ComponentActivity() { val label = appInfo.loadLabel(packageManager).toString() fun close(grantPermission: Boolean) { val clientInfo = DhizukuClientInfo( - uid, getPackageSignature(packageInfo), if (grantPermission) DhizukuPermissions else emptyList() + uid, getPackageSignature(packageInfo), + if (grantPermission) DhizukuPermissions else emptyList() ) - (application as MyApplication).myRepo.setDhizukuClient(clientInfo) + (application as MyApplication).container.dhizukuServerRepo.setDhizukuClient(clientInfo) finish() listener.onRequestPermission( if (grantPermission) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED ) } enableEdgeToEdge() - val theme = ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme) setContent { var appLockDialog by rememberSaveable { mutableStateOf(false) } - OwnDroidTheme(theme) { + OwnDroidTheme(settingsRepo.data.theme) { if (!appLockDialog) AlertDialog( icon = { Image(rememberDrawablePainter(icon), null, Modifier.size(35.dp)) @@ -125,7 +137,7 @@ class DhizukuActivity : ComponentActivity() { } } TextButton({ - if (SP.lockPasswordHash.isNullOrEmpty()) { + if (settingsRepo.data.appLock.passwordHash.isEmpty()) { close(true) } else { appLockDialog = true @@ -144,17 +156,11 @@ class DhizukuActivity : ComponentActivity() { }, onDismissRequest = { close(false) } ) - else AppLockDialog({ close(true) }) { close(false) } + else AppLockDialog(settingsRepo.data.appLock, { close(true) }) { close(false) } } } } } -val DhizukuPermissions = listOf("remote_transact", "remote_process", "user_service", "delegated_scopes", "other") - -@Serializable -data class DhizukuClientInfo( - val uid: Int, - val signature: String?, - val permissions: List = emptyList() -) \ No newline at end of file +val DhizukuPermissions = + listOf("remote_transact", "remote_process", "user_service", "delegated_scopes", "other") diff --git a/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt b/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt index 301117d..7eddb49 100644 --- a/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt +++ b/app/src/main/java/com/bintianqi/owndroid/LockTaskService.kt @@ -7,13 +7,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.ServiceInfo -import android.os.Build import android.os.IBinder import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import com.bintianqi.owndroid.utils.MyNotificationChannel +import com.bintianqi.owndroid.utils.NotificationType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -50,10 +49,7 @@ class LockTaskService: Service() { .setOngoing(true) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .build() - ServiceCompat.startForeground( - this, NotificationType.LockTaskMode.id, notification, - if (Build.VERSION.SDK_INT < 34) 0 else ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST - ) + startForeground(NotificationType.LockTaskMode.id, notification) coroutineScope.launch { val am = getSystemService(ActivityManager::class.java) delay(3000) @@ -71,14 +67,17 @@ class LockTaskService: Service() { } fun stopLockTask() { - val features = Privilege.DPM.getLockTaskFeatures(Privilege.DAR) - val packages = Privilege.DPM.getLockTaskPackages(Privilege.DAR) - Privilege.DPM.setLockTaskPackages(Privilege.DAR, arrayOf()) - Privilege.DPM.setLockTaskPackages(Privilege.DAR, packages) - Privilege.DPM.setLockTaskFeatures(Privilege.DAR, features) + val ph = (application as MyApplication).container.privilegeHelper + ph.safeDpmCall { + val features = dpm.getLockTaskFeatures(dar) + val packages = dpm.getLockTaskPackages(dar) + dpm.setLockTaskPackages(dar, arrayOf()) + dpm.setLockTaskPackages(dar, packages) + dpm.setLockTaskFeatures(dar, features) + } } companion object { const val STOP_ACTION = "com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 5aedebe..c890352 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -9,30 +9,13 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.Scaffold +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -41,823 +24,165 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -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 import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute -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.AddPreferentialNetworkServiceConfig -import com.bintianqi.owndroid.dpm.AddPreferentialNetworkServiceConfigScreen -import com.bintianqi.owndroid.dpm.AffiliationId -import com.bintianqi.owndroid.dpm.AffiliationIdScreen -import com.bintianqi.owndroid.dpm.AlwaysOnVpnPackage -import com.bintianqi.owndroid.dpm.AlwaysOnVpnPackageScreen -import com.bintianqi.owndroid.dpm.ApplicationDetails -import com.bintianqi.owndroid.dpm.ApplicationDetailsScreen -import com.bintianqi.owndroid.dpm.ApplicationsFeatures -import com.bintianqi.owndroid.dpm.ApplicationsFeaturesScreen -import com.bintianqi.owndroid.dpm.AutoTimePolicy -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.CaCert -import com.bintianqi.owndroid.dpm.CaCertScreen -import com.bintianqi.owndroid.dpm.ChangeTime -import com.bintianqi.owndroid.dpm.ChangeTimeScreen -import com.bintianqi.owndroid.dpm.ChangeTimeZone -import com.bintianqi.owndroid.dpm.ChangeTimeZoneScreen -import com.bintianqi.owndroid.dpm.ChangeUsername -import com.bintianqi.owndroid.dpm.ChangeUsernameScreen -import com.bintianqi.owndroid.dpm.ClearAppStorage -import com.bintianqi.owndroid.dpm.ClearAppStorageScreen -import com.bintianqi.owndroid.dpm.ContentProtectionPolicy -import com.bintianqi.owndroid.dpm.ContentProtectionPolicyScreen -import com.bintianqi.owndroid.dpm.CreateUser -import com.bintianqi.owndroid.dpm.CreateUserScreen -import com.bintianqi.owndroid.dpm.CreateWorkProfile -import com.bintianqi.owndroid.dpm.CreateWorkProfileScreen -import com.bintianqi.owndroid.dpm.CredentialManagerPolicy -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.CrossProfileWidgetProviders -import com.bintianqi.owndroid.dpm.DelegatedAdmins -import com.bintianqi.owndroid.dpm.DelegatedAdminsScreen -import com.bintianqi.owndroid.dpm.DeleteWorkProfile -import com.bintianqi.owndroid.dpm.DeleteWorkProfileScreen -import com.bintianqi.owndroid.dpm.DeviceInfo -import com.bintianqi.owndroid.dpm.DeviceInfoScreen -import com.bintianqi.owndroid.dpm.DhizukuServerSettings -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.DisableUserControl -import com.bintianqi.owndroid.dpm.EditAppGroup -import com.bintianqi.owndroid.dpm.EditAppGroupScreen -import com.bintianqi.owndroid.dpm.EnableSystemApp -import com.bintianqi.owndroid.dpm.EnableSystemAppScreen -import com.bintianqi.owndroid.dpm.FrpPolicy -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.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.Keyguard -import com.bintianqi.owndroid.dpm.KeyguardDisabledFeatures -import com.bintianqi.owndroid.dpm.KeyguardDisabledFeaturesScreen -import com.bintianqi.owndroid.dpm.KeyguardScreen -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.ManagedConfiguration -import com.bintianqi.owndroid.dpm.ManagedConfigurationScreen -import com.bintianqi.owndroid.dpm.MtePolicy -import com.bintianqi.owndroid.dpm.MtePolicyScreen -import com.bintianqi.owndroid.dpm.NearbyStreamingPolicy -import com.bintianqi.owndroid.dpm.NearbyStreamingPolicyScreen -import com.bintianqi.owndroid.dpm.Network -import com.bintianqi.owndroid.dpm.NetworkLogging -import com.bintianqi.owndroid.dpm.NetworkLoggingScreen -import com.bintianqi.owndroid.dpm.NetworkOptions -import com.bintianqi.owndroid.dpm.NetworkOptionsScreen -import com.bintianqi.owndroid.dpm.NetworkScreen -import com.bintianqi.owndroid.dpm.NetworkStatsScreen -import com.bintianqi.owndroid.dpm.NetworkStatsViewer -import com.bintianqi.owndroid.dpm.NetworkStatsViewerScreen -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.Password -import com.bintianqi.owndroid.dpm.PasswordInfo -import com.bintianqi.owndroid.dpm.PasswordInfoScreen -import com.bintianqi.owndroid.dpm.PasswordScreen -import com.bintianqi.owndroid.dpm.PermissionPolicy -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.PermittedAsAndImPackages -import com.bintianqi.owndroid.dpm.PermittedInputMethods -import com.bintianqi.owndroid.dpm.PreferentialNetworkService -import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceInfo -import com.bintianqi.owndroid.dpm.PreferentialNetworkServiceScreen -import com.bintianqi.owndroid.dpm.PrivateDns -import com.bintianqi.owndroid.dpm.PrivateDnsScreen -import com.bintianqi.owndroid.dpm.QueryNetworkStats -import com.bintianqi.owndroid.dpm.RecommendedGlobalProxy -import com.bintianqi.owndroid.dpm.RecommendedGlobalProxyScreen -import com.bintianqi.owndroid.dpm.RequiredPasswordComplexity -import com.bintianqi.owndroid.dpm.RequiredPasswordComplexityScreen -import com.bintianqi.owndroid.dpm.RequiredPasswordQuality -import com.bintianqi.owndroid.dpm.RequiredPasswordQualityScreen -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.SecurityLogging -import com.bintianqi.owndroid.dpm.SecurityLoggingScreen -import com.bintianqi.owndroid.dpm.SetDefaultDialer -import com.bintianqi.owndroid.dpm.SetDefaultDialerScreen -import com.bintianqi.owndroid.dpm.SetSystemUpdatePolicy -import com.bintianqi.owndroid.dpm.SupportMessage -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.SystemManager -import com.bintianqi.owndroid.dpm.SystemManagerScreen -import com.bintianqi.owndroid.dpm.SystemOptions -import com.bintianqi.owndroid.dpm.SystemOptionsScreen -import com.bintianqi.owndroid.dpm.SystemUpdatePolicyScreen -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 -import com.bintianqi.owndroid.dpm.UserOperationScreen -import com.bintianqi.owndroid.dpm.UserRestriction -import com.bintianqi.owndroid.dpm.UserRestrictionEditor -import com.bintianqi.owndroid.dpm.UserRestrictionEditorScreen -import com.bintianqi.owndroid.dpm.UserRestrictionOptions -import com.bintianqi.owndroid.dpm.UserRestrictionOptionsScreen -import com.bintianqi.owndroid.dpm.UserRestrictionScreen -import com.bintianqi.owndroid.dpm.UserSessionMessage -import com.bintianqi.owndroid.dpm.UserSessionMessageScreen -import com.bintianqi.owndroid.dpm.Users -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.WifiScreen -import com.bintianqi.owndroid.dpm.WifiSecurityLevel -import com.bintianqi.owndroid.dpm.WifiSecurityLevelScreen -import com.bintianqi.owndroid.dpm.WifiSsidPolicyScreen -import com.bintianqi.owndroid.dpm.WipeData -import com.bintianqi.owndroid.dpm.WipeDataScreen -import com.bintianqi.owndroid.dpm.WorkModes -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 androidx.lifecycle.lifecycleScope +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.bintianqi.owndroid.feature.applications.AppChooserViewModel import com.bintianqi.owndroid.ui.NavTransition +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.ui.navigation.myEntryProvider +import com.bintianqi.owndroid.ui.navigation.rememberSharedViewModelStoreNavEntryDecorator +import com.bintianqi.owndroid.ui.screen.AppLockDialog import com.bintianqi.owndroid.ui.theme.OwnDroidTheme -import kotlinx.serialization.Serializable -import java.util.Locale +import com.bintianqi.owndroid.utils.DhizukuError +import com.bintianqi.owndroid.utils.popToast +import com.bintianqi.owndroid.utils.registerPackageRemovedReceiver +import com.bintianqi.owndroid.utils.viewModelFactory +import kotlinx.coroutines.launch @ExperimentalMaterial3Api class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - val context = applicationContext - val locale = context.resources?.configuration?.locale - zhCN = locale == Locale.SIMPLIFIED_CHINESE || locale == Locale.CHINESE || locale == Locale.CHINA - val vm by viewModels() + val context = this + val myApp = (application as MyApplication) + val settingsRepo = myApp.container.settingsRepo if ( VERSION.SDK_INT >= 33 && - checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED ) { val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } + val appChooserVm: AppChooserViewModel by viewModels( + factoryProducer = { + viewModelFactory { AppChooserViewModel(myApp) } + } + ) registerPackageRemovedReceiver(this) { - vm.onPackageRemoved(it) + appChooserVm.onPackageRemoved(it) } - setContent { - var appLockDialog by rememberSaveable { mutableStateOf(false) } - val theme by vm.theme.collectAsStateWithLifecycle() - OwnDroidTheme(theme) { - Home(vm) { appLockDialog = true } - if (appLockDialog) { - AppLockDialog({ appLockDialog = false }) { moveTaskToBack(true) } - } + if ( + myApp.container.privilegeState.value.work && + !settingsRepo.data.privilege.managedProfileActivated + ) { + myApp.container.privilegeHelper.dpm.setProfileEnabled( + myApp.container.privilegeHelper.dar + ) + settingsRepo.update { + it.privilege.managedProfileActivated = true } - } - } - -} - -@ExperimentalMaterial3Api -@Composable -fun Home(vm: MyViewModel, onLock: () -> Unit) { - val navController = rememberNavController() - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - val lifecycleOwner = LocalLifecycleOwner.current - fun navigateUp() { navController.navigateUp() } - fun navigate(destination: Any) { - navController.navigate(destination) { - launchSingleTop = true - } - } - fun choosePackage() { - navController.navigate(ApplicationsList(false, true)) - } - fun chooseSinglePackage() { - navController.navigate(ApplicationsList(false, false)) - } - fun navigateToAppGroups() { - navController.navigate(ManageAppGroups) - } - LaunchedEffect(Unit) { - if(!Privilege.status.value.activated) { - navController.navigate(WorkModes(false)) { - popUpTo { inclusive = true } - } - } - } - @Suppress("NewApi") NavHost( - navController = navController, - startDestination = Home, - modifier = Modifier - .fillMaxSize() - .background(colorScheme.background) - .pointerInput(Unit) { detectTapGestures(onTap = { focusMgr.clearFocus() }) }, - enterTransition = { NavTransition.enterTransition }, - exitTransition = { NavTransition.exitTransition }, - popEnterTransition = { NavTransition.popEnterTransition }, - popExitTransition = { NavTransition.popExitTransition } - ) { - composable { HomeScreen(::navigate) } - composable { - WorkModesScreen(vm, it.toRoute(), ::navigateUp, { - navController.navigate(Home) { - popUpTo { inclusive = true } - } - }, { - navController.navigate(WorkModes(false)) { - popUpTo(Home) { inclusive = true } - } - }, ::navigate) - } - composable { - DhizukuServerSettingsScreen(vm.dhizukuClients, vm::getDhizukuClients, - vm::updateDhizukuClient, vm::getDhizukuServerEnabled, vm::setDhizukuServerEnabled, - ::navigateUp) - } - - composable { - DelegatedAdminsScreen(vm.delegatedAdmins, vm::getDelegatedAdmins, ::navigateUp, ::navigate) - } - composable{ - AddDelegatedAdminScreen(vm.chosenPackage, ::chooseSinglePackage, 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(vm.deviceAdminReceivers, vm::getDeviceAdminReceivers, - vm::transferOwnership, ::navigateUp) { - navController.navigate(WorkModes(false)) { - popUpTo(Home) { inclusive = true } - } - } - } - - composable { SystemManagerScreen(vm, ::navigateUp, ::navigate) } - composable { SystemOptionsScreen(vm, ::navigateUp) } - composable { - KeyguardScreen(vm::setKeyguardDisabled, vm::lockScreen, ::navigateUp) - } - composable { - HardwareMonitorScreen(vm.hardwareProperties, vm::getHardwareProperties, - vm::setHpRefreshInterval, ::navigateUp) - } - composable { ChangeTimeScreen(vm::setTime, ::navigateUp) } - composable { ChangeTimeZoneScreen(vm::setTimeZone, ::navigateUp) } - composable { - AutoTimePolicyScreen(vm::getAutoTimePolicy, vm::setAutoTimePolicy, ::navigateUp) - } - composable { - AutoTimeZonePolicyScreen(vm::getAutoTimeZonePolicy, vm::setAutoTimeZonePolicy, - ::navigateUp) - } - //composable<> { KeyPairs(::navigateUp) } - composable { - ContentProtectionPolicyScreen(vm::getContentProtectionPolicy, - vm::setContentProtectionPolicy, ::navigateUp) - } - composable { - PermissionPolicyScreen(vm::getPermissionPolicy, vm::setPermissionPolicy, ::navigateUp) - } - composable { - MtePolicyScreen(vm::getMtePolicy, vm::setMtePolicy, ::navigateUp) - } - composable { - NearbyStreamingPolicyScreen(vm::getNsAppPolicy, vm::setNsAppPolicy, - vm::getNsNotificationPolicy, vm::setNsNotificationPolicy, ::navigateUp) - } - composable { - LockTaskModeScreen( - vm.chosenPackage, ::chooseSinglePackage, ::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(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, ::chooseSinglePackage, 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, ::chooseSinglePackage, ::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 { WorkProfileScreen(::navigateUp, ::navigate) } - composable { - OrganizationOwnedProfileScreen(vm::activateOrgProfileByShizuku, ::navigateUp) - } - composable { - CreateWorkProfileScreen(vm::createWorkProfile, ::navigateUp) - } - composable { - SuspendPersonalAppScreen( - vm::getPersonalAppsSuspendedReason, vm::setPersonalAppsSuspended, - vm::getProfileMaxTimeOff, vm::setProfileMaxTimeOff, ::navigateUp - ) - } - composable { - CrossProfileIntentFilterScreen(vm::addCrossProfileIntentFilter, ::navigateUp) - } - composable { DeleteWorkProfileScreen(vm::wipeData, ::navigateUp) } - - composable { - val params = it.toRoute() - AppChooserScreen( - params, vm.installedPackages, vm.refreshPackagesProgress, { name -> - if (params.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, vm::setPackageSuspended, vm::setPackageHidden) - } - composable { - ApplicationsFeaturesScreen(::navigateUp, ::navigate) { - SP.applicationsListView = true - navController.navigate(ApplicationsList(true, true)) { - popUpTo(Home) - } - } - } - 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 { - PackageFunctionScreen( - R.string.block_uninstall, vm.ubPackages, vm::getUbPackages, vm::setPackageUb, - ::navigateUp, vm.chosenPackage, ::choosePackage, ::navigateToAppGroups, vm.appGroups - ) - } - composable { - PackageFunctionScreen( - R.string.disable_user_control, vm.ucdPackages, vm::getUcdPackages, - vm::setPackageUcd, ::navigateUp, vm.chosenPackage, ::choosePackage, - ::navigateToAppGroups, vm.appGroups, R.string.info_disable_user_control - ) - } - composable { - PermissionsManagerScreen( - vm.packagePermissions, vm::getPackagePermissions, vm::setPackagePermission, - ::navigateUp, it.toRoute(), vm.chosenPackage, ::chooseSinglePackage - ) - } - composable { - PackageFunctionScreen( - R.string.disable_metered_data, vm.mddPackages, vm::getMddPackages, - vm::setPackageMdd, ::navigateUp, vm.chosenPackage, ::choosePackage, - ::navigateToAppGroups, vm.appGroups - ) - } - composable { - ClearAppStorageScreen( - vm.chosenPackage, ::chooseSinglePackage, vm::clearAppData, ::navigateUp - ) - } - composable { - UninstallAppScreen( - vm.chosenPackage, ::chooseSinglePackage, vm::uninstallPackage, ::navigateUp - ) - } - composable { - PackageFunctionScreen( - R.string.keep_uninstalled_packages, vm.kuPackages, vm::getKuPackages, - vm::setPackageKu, ::navigateUp, vm.chosenPackage, ::choosePackage, - ::navigateToAppGroups, vm.appGroups, R.string.info_keep_uninstalled_apps - ) - } - composable { - InstallExistingAppScreen( - vm.chosenPackage, ::chooseSinglePackage, vm::installExistingApp, ::navigateUp - ) - } - composable { - PackageFunctionScreen( - 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, ::chooseSinglePackage, vm::enableSystemApp, ::navigateUp - ) - } - composable { - SetDefaultDialerScreen( - vm.chosenPackage, ::chooseSinglePackage, vm::setDefaultDialer, ::navigateUp - ) - } - composable { - ManagedConfigurationScreen( - it.toRoute(), vm.appRestrictions, vm::setAppRestrictions, - vm::clearAppRestrictions, ::navigateUp - ) - } - composable { - ManageAppGroupsScreen( - vm.appGroups, vm::exportAppGroups, vm::importAppGroups, - { id, name, apps -> navController.navigate(EditAppGroup(id, name, apps)) }, - ::navigateUp - ) - } - composable { - EditAppGroupScreen( - it.toRoute(), vm::getAppInfo, ::navigateUp, vm::setAppGroup, - vm::deleteAppGroup, ::choosePackage, vm.chosenPackage - ) - } - - composable { - UserRestrictionScreen(vm::getUserRestrictions, ::navigateUp, ::navigate) - } - composable { - UserRestrictionEditorScreen(vm.userRestrictions, vm::setUserRestriction, ::navigateUp) - } - composable { - UserRestrictionOptionsScreen(it.toRoute(), vm.userRestrictions, - vm::setUserRestriction, vm::createUserRestrictionShortcut, ::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(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(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 { AboutScreen(::navigateUp) } - } - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if ( - (event == Lifecycle.Event.ON_CREATE && !SP.lockPasswordHash.isNullOrEmpty()) || - (event == Lifecycle.Event.ON_RESUME && SP.lockWhenLeaving) - ) { - onLock() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - LaunchedEffect(Unit) { - val profileNotActivated = !SP.managedProfileActivated && Privilege.status.value.work - if(profileNotActivated) { - Privilege.DPM.setProfileEnabled(Privilege.DAR) - SP.managedProfileActivated = true context.popToast(R.string.work_profile_activated) } - } - DhizukuErrorDialog { - dhizukuErrorStatus.value = 0 - Privilege.updateStatus() - navController.navigate(WorkModes(false)) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } -} - -@Serializable private object Home - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HomeScreen(onNavigate: (Any) -> Unit) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - Scaffold( - Modifier.nestedScroll(sb.nestedScrollConnection), - topBar = { - LargeTopAppBar( - { Text(stringResource(R.string.app_name)) }, - actions = { - IconButton({ onNavigate(WorkModes(true)) }) { Icon(painterResource(R.drawable.security_fill0), null) } - IconButton({ onNavigate(Settings) }) { Icon(Icons.Default.Settings, null) } - }, - scrollBehavior = sb - ) - }, - contentWindowInsets = adaptiveInsets() - ) { - 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) } + lifecycleScope.launch { + while (true) { + val text = myApp.container.toastChannel.channel.receive() + context.popToast(text) } - if(privilege.work) { - HomePageItem(R.string.work_profile, R.drawable.work_fill0) { - onNavigate(WorkProfile) - } - } - if(privilege.device || privilege.profile) { - HomePageItem(R.string.applications, R.drawable.apps_fill0) { - onNavigate( - if (SP.applicationsListView) ApplicationsList(true, true) - else ApplicationsFeatures - ) - } - if(VERSION.SDK_INT >= 24) { - HomePageItem(R.string.user_restriction, R.drawable.person_off) { onNavigate(UserRestriction) } - } - 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.height(BottomPadding)) } - } -} - -@Composable -fun HomePageItem(name: Int, imgVector: Int, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(Modifier.padding(start = 30.dp)) - Icon( - painter = painterResource(imgVector), - contentDescription = null - ) - Spacer(Modifier.padding(start = 15.dp)) - Text( - text = stringResource(name), - style = typography.headlineSmall, - modifier = Modifier.padding(bottom = if(zhCN) { 2 } else { 0 }.dp) - ) - } -} - -@Composable -private fun DhizukuErrorDialog(onClose: () -> Unit) { - val status by dhizukuErrorStatus.collectAsState() - if (status != 0) { - LaunchedEffect(Unit) { - SP.dhizuku = false - } - AlertDialog( - onDismissRequest = {}, - confirmButton = { - TextButton(onClose) { - Text(stringResource(R.string.confirm)) - } - }, - title = { Text(stringResource(R.string.dhizuku)) }, - text = { - val text = stringResource( - when(status){ - 1 -> R.string.failed_to_init_dhizuku - 2 -> R.string.dhizuku_permission_not_granted - else -> R.string.failed_to_init_dhizuku - } + setContent { + val dhizukuError by myApp.container.dhizukuErrorState.collectAsState() + var appLockDialog by rememberSaveable { mutableStateOf(false) } + val theme by myApp.container.themeState.collectAsState() + OwnDroidTheme(theme) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) ) - Text(text) - }, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) - ) + val backstack = rememberNavBackStack(Destination.Home) + LaunchedEffect(Unit) { + if (!myApp.container.privilegeState.value.activated) { + backstack.add(Destination.WorkingModes(false)) + backstack.removeFirstOrNull() + } + } + NavDisplay( + backstack, + onBack = { + backstack.removeLastOrNull() + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberSharedViewModelStoreNavEntryDecorator() + ), + transitionSpec = { + NavTransition.transition + }, + popTransitionSpec = { + NavTransition.popTransition + }, + predictivePopTransitionSpec = { + NavTransition.popTransition + } + ) { + myEntryProvider(it as Destination, backstack, appChooserVm, myApp.container) + } + val lifecycleOwner = LocalLifecycleOwner.current + if (appLockDialog) { + AppLockDialog( + myApp.container.settingsRepo.data.appLock, { appLockDialog = false } + ) { moveTaskToBack(true) } + } + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if ( + settingsRepo.data.appLock.passwordHash.isNotEmpty() && + (event == Lifecycle.Event.ON_CREATE || + (event == Lifecycle.Event.ON_RESUME && + settingsRepo.data.appLock.lockWhenLeaving)) + ) { + appLockDialog = true + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + if (dhizukuError != null) DhizukuErrorDialog(dhizukuError!!) { + myApp.container.dhizukuErrorState.value = null + /*backstack += Destination.WorkingModes(false) + repeat(backstack.size - 1) { + backstack.removeFirstOrNull() + }*/ + } + } + } } } + +@Composable +private fun DhizukuErrorDialog(error: DhizukuError, onClose: () -> Unit) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClose) { + Text(stringResource(R.string.confirm)) + } + }, + title = { Text(stringResource(R.string.dhizuku)) }, + text = { + val text = stringResource( + when (error) { + DhizukuError.Init -> R.string.failed_to_init_dhizuku + DhizukuError.Permission -> R.string.dhizuku_permission_not_granted + else -> R.string.failed_to_init_dhizuku + } + ) + Text(text) + }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt index d6105c3..dea9196 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt @@ -2,20 +2,18 @@ package com.bintianqi.owndroid import android.app.Application import android.os.Build.VERSION +import com.bintianqi.owndroid.utils.NotificationUtils +import com.bintianqi.owndroid.utils.getPrivilegeStatus import org.lsposed.hiddenapibypass.HiddenApiBypass class MyApplication : Application() { - lateinit var myRepo: MyRepository + lateinit var container: AppContainer + override fun onCreate() { super.onCreate() if (VERSION.SDK_INT >= 28) HiddenApiBypass.setHiddenApiExemptions("") - SP = SharedPrefs(applicationContext) - val dbHelper = MyDbHelper(this) - myRepo = MyRepository(dbHelper) - Privilege.initialize(applicationContext) + container = AppContainer(this) + container.privilegeState.value = getPrivilegeStatus(container.privilegeHelper) NotificationUtils.createChannels(this) } } - -lateinit var SP: SharedPrefs - private set diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt index dfa0805..53770ab 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -4,12 +4,13 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { +class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 5) { 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) + db.execSQL(CP_INTENTS_TABLE) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (oldVersion < 2) { @@ -21,6 +22,9 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { if (oldVersion < 4) { db.execSQL(APP_GROUPS_TABLE) } + if (oldVersion < 5) { + db.execSQL(CP_INTENTS_TABLE) + } } companion object { const val DHIZUKU_CLIENTS_TABLE = "CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + @@ -33,5 +37,7 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { const val APP_GROUPS_TABLE = "CREATE TABLE app_groups(" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT, apps TEXT)" + const val CP_INTENTS_TABLE = "CREATE TABLE cross_profile_intent_filters (" + + "action_str TEXT, category TEXT, mime_type TEXT, direction INTEGER)" } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt deleted file mode 100644 index 62f7c57..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt +++ /dev/null @@ -1,251 +0,0 @@ -package com.bintianqi.owndroid - -import android.app.admin.SecurityLog -import android.content.ContentValues -import android.database.DatabaseUtils -import android.database.sqlite.SQLiteDatabase -import android.os.Build.VERSION -import androidx.annotation.RequiresApi -import androidx.core.database.getIntOrNull -import androidx.core.database.getLongOrNull -import androidx.core.database.getStringOrNull -import com.bintianqi.owndroid.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 deleted file mode 100644 index 47b1b95..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ /dev/null @@ -1,2040 +0,0 @@ -package com.bintianqi.owndroid - -import android.accounts.Account -import android.annotation.SuppressLint -import android.app.ActivityOptions -import android.app.Application -import android.app.KeyguardManager -import android.app.PendingIntent -import android.app.admin.DeviceAdminInfo -import android.app.admin.DeviceAdminReceiver -import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback -import android.app.admin.FactoryResetProtectionPolicy -import android.app.admin.IDevicePolicyManager -import android.app.admin.PackagePolicy -import android.app.admin.PreferentialNetworkServiceConfig -import android.app.admin.SecurityLog -import android.app.admin.SystemUpdateInfo -import android.app.admin.SystemUpdatePolicy -import android.app.admin.WifiSsidPolicy -import android.app.usage.NetworkStats -import android.app.usage.NetworkStatsManager -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.RestrictionEntry -import android.content.RestrictionsManager -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInstaller -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.net.IpConfiguration -import android.net.LinkAddress -import android.net.ProxyInfo -import android.net.StaticIpConfiguration -import android.net.Uri -import android.net.wifi.WifiConfiguration -import android.net.wifi.WifiManager -import android.net.wifi.WifiSsid -import android.os.Binder -import android.os.Build.VERSION -import android.os.Bundle -import android.os.HardwarePropertiesManager -import android.os.UserHandle -import android.os.UserManager -import android.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.AppRestriction -import com.bintianqi.owndroid.dpm.AppStatus -import com.bintianqi.owndroid.dpm.BasicAppGroup -import com.bintianqi.owndroid.dpm.CaCertInfo -import com.bintianqi.owndroid.dpm.CreateUserResult -import com.bintianqi.owndroid.dpm.CreateWorkProfileOptions -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 kotlinx.serialization.json.Json -import kotlinx.serialization.json.addJsonObject -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.put -import java.net.InetAddress -import java.security.MessageDigest -import java.security.cert.CertificateException -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import java.time.ZoneId -import java.time.ZonedDateTime -import java.util.concurrent.Executors -import kotlin.reflect.jvm.jvmErasure - -class MyViewModel(application: Application): AndroidViewModel(application) { - val myRepo = getApplication().myRepo - val PM = application.packageManager - - val theme = MutableStateFlow(ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme)) - fun changeTheme(newTheme: ThemeSettings) { - theme.value = newTheme - SP.materialYou = newTheme.materialYou - SP.darkTheme = newTheme.darkTheme - SP.blackTheme = newTheme.blackTheme - } - fun getDisplayDangerousFeatures(): Boolean { - return SP.displayDangerousFeatures - } - fun getShortcutsEnabled(): Boolean { - return SP.shortcuts - } - fun setDisplayDangerousFeatures(state: Boolean) { - SP.displayDangerousFeatures = state - } - fun setShortcutsEnabled(enabled: Boolean) { - SP.shortcuts = enabled - ShortcutUtils.setAllShortcuts(application, enabled) - } - fun getAppLockConfig(): AppLockConfig { - val passwordHash = SP.lockPasswordHash - return AppLockConfig(passwordHash?.ifEmpty { null }, SP.biometricsUnlock, SP.lockWhenLeaving) - } - fun setAppLockConfig(config: AppLockConfig) { - if (config.password == null) { - SP.lockPasswordHash = "" - } else if (!config.password.isEmpty()) { - SP.lockPasswordHash = config.password.hash() - } - SP.biometricsUnlock = config.biometrics - SP.lockWhenLeaving = config.whenLeaving - } - fun getApiEnabled(): Boolean { - return SP.apiKeyHash?.isNotEmpty() ?: false - } - fun setApiKey(key: String) { - SP.apiKeyHash = if (key.isEmpty()) "" else key.hash() - } - val enabledNotifications = MutableStateFlow(emptyList()) - fun getEnabledNotifications() { - val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } - enabledNotifications.value = list ?: NotificationType.entries.map { it.id } - } - fun setNotificationEnabled(type: NotificationType, enabled: Boolean) { - enabledNotifications.update { list -> - if (enabled) list.plus(type.id) else list.minus(type.id) - } - SP.notifications = enabledNotifications.value.joinToString(",") { it.toString() } - } - - val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST) - - val installedPackages = MutableStateFlow(emptyList()) - val refreshPackagesProgress = MutableStateFlow(0F) - fun refreshPackageList() { - viewModelScope.launch(Dispatchers.IO) { - installedPackages.value = emptyList() - val apps = PM.getInstalledApplications(getInstalledAppsFlags) - apps.forEachIndexed { index, info -> - installedPackages.update { - it + getAppInfo(info) - } - refreshPackagesProgress.value = (index + 1).toFloat() / apps.size - } - } - } - fun onPackageRemoved(name: String) { - installedPackages.update { list -> - list.filter { it.name != name } - } - } - fun getAppInfo(info: ApplicationInfo) = - AppInfo(info.packageName, info.loadLabel(PM).toString(), info.loadIcon(PM), info.flags) - fun getAppInfo(name: String): AppInfo { - return try { - getAppInfo(PM.getApplicationInfo(name, getInstalledAppsFlags)) - } catch (_: PackageManager.NameNotFoundException) { - AppInfo(name, "???", Color.Transparent.toArgb().toDrawable(), 0) - } - } - - val suspendedPackages = MutableStateFlow(emptyList()) - @RequiresApi(24) - fun getSuspendedPackaged() { - val packages = PM.getInstalledApplications(getInstalledAppsFlags).filter { - DPM.isPackageSuspended(DAR, it.packageName) - } - suspendedPackages.value = packages.map { getAppInfo(it) } - } - @RequiresApi(24) - fun setPackageSuspended(packages: List, status: Boolean) { - DPM.setPackagesSuspended(DAR, packages.toTypedArray(), status) - getSuspendedPackaged() - } - - val hiddenPackages = MutableStateFlow(emptyList()) - fun getHiddenPackages() { - hiddenPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { - DPM.isApplicationHidden(DAR, it.packageName) - }.map { getAppInfo(it) } - } - fun setPackageHidden(packages: List, status: Boolean) { - for (name in packages) { - DPM.setApplicationHidden(DAR, name, status) - } - getHiddenPackages() - } - - // Uninstall blocked packages - val ubPackages = MutableStateFlow(emptyList()) - fun getUbPackages() { - ubPackages.value = PM.getInstalledApplications(getInstalledAppsFlags).filter { - DPM.isUninstallBlocked(DAR, it.packageName) - }.map { getAppInfo(it) } - } - fun setPackageUb(packages: List, status: Boolean) { - for (name in packages) { - DPM.setUninstallBlocked(DAR, name, status) - } - getUbPackages() - } - - // User control disabled packages - val ucdPackages = MutableStateFlow(emptyList()) - @RequiresApi(30) - fun getUcdPackages() { - ucdPackages.value = DPM.getUserControlDisabledPackages(DAR).distinct().map { - getAppInfo(it) - } - } - @RequiresApi(30) - fun setPackageUcd(packages: List, status: Boolean) { - DPM.setUserControlDisabledPackages( - DAR, - ucdPackages.value.map { it.name }.run { - if (status) plus(packages) else minus(packages) - } - ) - getUcdPackages() - } - - val packagePermissions = MutableStateFlow(emptyMap()) - fun getPackagePermissions(name: String) { - if (name.isValidPackageName) { - packagePermissions.value = runtimePermissions.associate { - it.id to DPM.getPermissionGrantState(DAR, name, it.id) - } - } else { - packagePermissions.value = emptyMap() - } - } - 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(packages: List, status: Boolean) { - DPM.setMeteredDataDisabledPackages( - DAR, mddPackages.value.map { it.name }.run { - if (status) plus(packages) else minus(packages) - } - ) - getMddPackages() - } - - // Keep uninstalled packages - val kuPackages = MutableStateFlow(emptyList()) - @RequiresApi(28) - fun getKuPackages() { - kuPackages.value = DPM.getKeepUninstalledPackages(DAR)?.distinct()?.map { getAppInfo(it) } ?: emptyList() - } - @RequiresApi(28) - fun setPackageKu(packages: List, status: Boolean) { - DPM.setKeepUninstalledPackages( - DAR, kuPackages.value.map { it.name }.run { - if (status) plus(packages) else minus(packages) - } - ) - getKuPackages() - } - - // Cross profile packages - val cpPackages = MutableStateFlow(emptyList()) - @RequiresApi(30) - fun getCpPackages() { - cpPackages.value = DPM.getCrossProfilePackages(DAR).map { getAppInfo(it) } - } - @RequiresApi(30) - fun setPackageCp(packages: List, status: Boolean) { - DPM.setCrossProfilePackages( - DAR, - cpPackages.value.map { it.name }.toSet().run { - if (status) plus(packages) else minus(packages) - } - ) - getCpPackages() - } - - // Cross-profile widget providers - val cpwProviders = MutableStateFlow(emptyList()) - fun getCpwProviders() { - cpwProviders.value = DPM.getCrossProfileWidgetProviders(DAR).distinct().map { getAppInfo(it) } - } - fun setCpwProvider(packages: List, status: Boolean) { - for (name in packages) { - if (status) { - DPM.addCrossProfileWidgetProvider(DAR, name) - } else { - DPM.removeCrossProfileWidgetProvider(DAR, name) - } - } - getCpwProviders() - } - - @RequiresApi(28) - fun clearAppData(name: String, callback: (Boolean) -> Unit) { - DPM.clearApplicationUserData(DAR, name, Executors.newSingleThreadExecutor()) { _, result -> - callback(result) - } - } - - fun uninstallPackage(packageName: String, onComplete: (String?) -> Unit) { - val action = "com.bintianqi.owndroid.action.PACKAGE_UNINSTALLED" - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { - @SuppressWarnings("UnsafeIntentLaunch") - context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) - } else { - context.unregisterReceiver(this) - if (statusExtra == PackageInstaller.STATUS_SUCCESS) { - onComplete(null) - } else { - onComplete(parsePackageInstallerMessage(context, intent)) - } - } - } - } - ContextCompat.registerReceiver( - application, receiver, IntentFilter(action), null, - null, ContextCompat.RECEIVER_NOT_EXPORTED - ) - val intent = Intent(action).setPackage(application.packageName) - val pi = if(VERSION.SDK_INT >= 34) { - PendingIntent.getBroadcast( - application, 0, intent, - PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE - ).intentSender - } else { - PendingIntent.getBroadcast(application, 0, intent, PendingIntent.FLAG_MUTABLE).intentSender - } - application.getPackageInstaller().uninstall(packageName, pi) - } - - @RequiresApi(28) - fun installExistingApp(name: String): Boolean { - return DPM.installExistingPackage(DAR, name) - } - - // Credential manager policy - val cmPackages = MutableStateFlow(emptyList()) - @RequiresApi(34) - fun getCmPolicy(): Int { - return DPM.credentialManagerPolicy?.let { policy -> - cmPackages.value = policy.packageNames.distinct().map { getAppInfo(it) } - policy.policyType - } ?: -1 - } - fun setCmPackage(packages: List, status: Boolean) { - cmPackages.update { - updateAppInfoList(it, packages, status) - } - } - @RequiresApi(34) - fun setCmPolicy(type: Int) { - DPM.credentialManagerPolicy = if (type != -1 && cmPackages.value.isNotEmpty()) { - PackagePolicy(type, cmPackages.value.map { it.name }.toSet()) - } else null - getCmPolicy() - } - - fun updateAppInfoList( - origin: List, input: List, status: Boolean - ): List { - return if (status) { - origin + input.map { getAppInfo(it) } - } else { - origin.filter { it.name !in input } - } - } - - // Permitted input method - val pimPackages = MutableStateFlow(emptyList()) - fun getPimPackages(): Boolean { - return DPM.getPermittedInputMethods(DAR).let { packages -> - pimPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList() - packages == null - } - } - fun setPimPackage(packages: List, status: Boolean) { - pimPackages.update { - updateAppInfoList(it, packages, status) - } - } - fun setPimPolicy(allowAll: Boolean): Boolean { - val result = DPM.setPermittedInputMethods( - DAR, if (allowAll) null else pimPackages.value.map { it.name }) - getPimPackages() - return result - } - - // Permitted accessibility services - val pasPackages = MutableStateFlow(emptyList()) - fun getPasPackages(): Boolean { - return DPM.getPermittedAccessibilityServices(DAR).let { packages -> - pasPackages.value = packages?.distinct()?.map { getAppInfo(it) } ?: emptyList() - packages == null - } - } - fun setPasPackage(packages: List, status: Boolean) { - pasPackages.update { - updateAppInfoList(it, packages, status) - } - } - fun setPasPolicy(allowAll: Boolean): Boolean { - val result = DPM.setPermittedAccessibilityServices( - DAR, if (allowAll) null else pasPackages.value.map { it.name }) - getPasPackages() - return result - } - - fun enableSystemApp(name: String) { - DPM.enableSystemApp(DAR, name) - } - - val appStatus = MutableStateFlow(AppStatus(false, false, false, false, false, false)) - fun getAppStatus(name: String) { - appStatus.value = AppStatus( - if (VERSION.SDK_INT >= 24) DPM.isPackageSuspended(DAR, name) else false, - DPM.isApplicationHidden(DAR, name), - DPM.isUninstallBlocked(DAR, name), - if (VERSION.SDK_INT >= 30) name in DPM.getUserControlDisabledPackages(DAR) else false, - if (VERSION.SDK_INT >= 28) name in DPM.getMeteredDataDisabledPackages(DAR) else false, - if (VERSION.SDK_INT >= 28 && Privilege.status.value.device) - DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true - else false - ) - } - // Application details - @RequiresApi(24) - fun adSetPackageSuspended(name: String, status: Boolean) { - try { - DPM.setPackagesSuspended(DAR, arrayOf(name), status) - appStatus.update { it.copy(suspend = DPM.isPackageSuspended(DAR, name)) } - } catch (_: Exception) {} - } - fun adSetPackageHidden(name: String, status: Boolean) { - DPM.setApplicationHidden(DAR, name, status) - appStatus.update { it.copy(hide = DPM.isApplicationHidden(DAR, name)) } - } - fun adSetPackageUb(name: String, status: Boolean) { - DPM.setUninstallBlocked(DAR, name, status) - appStatus.update { it.copy(uninstallBlocked = DPM.isUninstallBlocked(DAR, name)) } - } - @RequiresApi(30) - fun adSetPackageUcd(name: String, status: Boolean) { - DPM.setUserControlDisabledPackages(DAR, - DPM.getUserControlDisabledPackages(DAR).run { if (status) plus(name) else minus(name) }) - appStatus.update { - it.copy(userControlDisabled = name in DPM.getUserControlDisabledPackages(DAR)) - } - } - @RequiresApi(28) - fun adSetPackageMdd(name: String, status: Boolean) { - DPM.setMeteredDataDisabledPackages(DAR, - DPM.getMeteredDataDisabledPackages(DAR).run { if (status) plus(name) else minus(name) }) - appStatus.update { - it.copy(meteredDataDisabled = name in DPM.getMeteredDataDisabledPackages(DAR)) - } - } - @RequiresApi(28) - fun adSetPackageKu(name: String, status: Boolean) { - DPM.setKeepUninstalledPackages(DAR, - DPM.getKeepUninstalledPackages(DAR)?.run { if (status) plus(name) else minus(name) } ?: emptyList()) - appStatus.update { - it.copy(keepUninstalled = DPM.getKeepUninstalledPackages(DAR)?.contains(name) == true ) - } - } - - @RequiresApi(34) - fun setDefaultDialer(name: String): Boolean { - return try { - DPM.setDefaultDialerApplication(name) - true - } catch (e: IllegalArgumentException) { - e.printStackTrace() - false - } - } - - val appRestrictions = MutableStateFlow(emptyList()) - - fun getAppRestrictions(name: String) { - val rm = application.getSystemService(RestrictionsManager::class.java) - try { - val bundle = DPM.getApplicationRestrictions(DAR, name) - appRestrictions.value = rm.getManifestRestrictions(name)?.mapNotNull { - transformRestrictionEntry(it) - }?.map { - if (bundle.containsKey(it.key)) { - when (it) { - is AppRestriction.BooleanItem -> it.value = bundle.getBoolean(it.key) - is AppRestriction.StringItem -> it.value = bundle.getString(it.key) - is AppRestriction.IntItem -> it.value = bundle.getInt(it.key) - is AppRestriction.ChoiceItem -> it.value = bundle.getString(it.key) - is AppRestriction.MultiSelectItem -> it.value = bundle.getStringArray(it.key) - } - } - it - } ?: emptyList() - } catch (e: Exception) { - e.printStackTrace() - appRestrictions.value = emptyList() - } - } - - fun setAppRestrictions(name: String, item: AppRestriction) { - viewModelScope.launch(Dispatchers.IO) { - val bundle = transformAppRestriction( - appRestrictions.value.filter { it.key != item.key }.plus(item) - ) - DPM.setApplicationRestrictions(DAR, name, bundle) - getAppRestrictions(name) - } - } - - fun clearAppRestrictions(name: String) { - viewModelScope.launch(Dispatchers.IO) { - DPM.setApplicationRestrictions(DAR, name, Bundle()) - getAppRestrictions(name) - } - } - - fun transformRestrictionEntry(e: RestrictionEntry): AppRestriction? { - return when (e.type) { - RestrictionEntry.TYPE_INTEGER -> - AppRestriction.IntItem(e.key, e.title, e.description, null) - RestrictionEntry.TYPE_STRING -> - AppRestriction.StringItem(e.key, e.title, e.description, null) - RestrictionEntry.TYPE_BOOLEAN -> - AppRestriction.BooleanItem(e.key, e.title, e.description, null) - RestrictionEntry.TYPE_CHOICE -> AppRestriction.ChoiceItem(e.key, e.title, - e.description, e.choiceEntries, e.choiceValues, null) - RestrictionEntry.TYPE_MULTI_SELECT -> AppRestriction.MultiSelectItem(e.key, e.title, - e.description, e.choiceEntries, e.choiceValues, null) - else -> null - } - } - - fun transformAppRestriction(list: List): Bundle { - val b = Bundle() - for (r in list) { - when (r) { - is AppRestriction.IntItem -> r.value?.let { b.putInt(r.key, it) } - is AppRestriction.StringItem -> r.value?.let { b.putString(r.key, it) } - is AppRestriction.BooleanItem -> r.value?.let { b.putBoolean(r.key, it) } - is AppRestriction.ChoiceItem -> r.value?.let { b.putString(r.key, it) } - is AppRestriction.MultiSelectItem -> r.value?.let { b.putStringArray(r.key, r.value) } - } - } - return b - } - - val appGroups = MutableStateFlow(emptyList()) - init { - getAppGroups() - } - fun getAppGroups() { - appGroups.value = myRepo.getAppGroups() - } - fun setAppGroup(id: Int?, name: String, apps: List) { - myRepo.setAppGroup(id, name, apps) - getAppGroups() - } - fun deleteAppGroup(id: Int) { - myRepo.deleteAppGroup(id) - appGroups.update { group -> - group.filter { it.id != id } - } - } - fun exportAppGroups(uri: Uri) { - application.contentResolver.openOutputStream(uri)!!.use { - val list: List = appGroups.value - it.write(Json.encodeToString(list).encodeToByteArray()) - } - } - fun importAppGroups(uri: Uri) { - application.contentResolver.openInputStream(uri)!!.use { - Json.decodeFromString>(it.readBytes().decodeToString()) - }.forEach { - myRepo.setAppGroup(null, it.name, it.apps) - } - getAppGroups() - } - - @RequiresApi(24) - fun reboot() { - DPM.reboot(DAR) - } - @RequiresApi(24) - fun requestBugReport(): Boolean { - return try { - DPM.requestBugreport(DAR) - } catch (e: Exception) { - e.printStackTrace() - false - } - } - @SuppressLint("PrivateApi") - @RequiresApi(24) - fun getOrgName(): String { - return try { - DPM.getOrganizationName(DAR)?.toString() ?: "" - } catch (_: Exception) { - try { - val method = DevicePolicyManager::class.java.getDeclaredMethod( - "getDeviceOwnerOrganizationName" - ) - method.isAccessible = true - (method.invoke(DPM) as CharSequence).toString() - } catch (_: Exception) { - "" - } - } - } - @RequiresApi(24) - fun setOrgName(name: String) { - DPM.setOrganizationName(DAR, name) - } - @RequiresApi(31) - fun setOrgId(id: String): Boolean { - return try { - DPM.setOrganizationId(id) - true - } catch (_: IllegalStateException) { - false - } - } - @RequiresApi(31) - fun getEnrollmentSpecificId(): String { - return DPM.enrollmentSpecificId - } - val systemOptionsStatus = MutableStateFlow(SystemOptionsStatus()) - fun getSystemOptionsStatus() { - val privilege = Privilege.status.value - systemOptionsStatus.value = SystemOptionsStatus( - cameraDisabled = DPM.getCameraDisabled(null), - screenCaptureDisabled = DPM.getScreenCaptureDisabled(null), - statusBarDisabled = if (VERSION.SDK_INT >= 34 && - privilege.run { device || (profile && affiliated) }) - DPM.isStatusBarDisabled else false, - autoTimeEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) - DPM.getAutoTimeEnabled(DAR) else false, - autoTimeZoneEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) - DPM.getAutoTimeZoneEnabled(DAR) else false, - autoTimeRequired = if (VERSION.SDK_INT < 30) DPM.autoTimeRequired else false, - masterVolumeMuted = DPM.isMasterVolumeMuted(DAR), - backupServiceEnabled = if (VERSION.SDK_INT >= 26) DPM.isBackupServiceEnabled(DAR) else false, - btContactSharingDisabled = if (privilege.work) - DPM.getBluetoothContactSharingDisabled(DAR) else false, - commonCriteriaMode = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) - DPM.isCommonCriteriaModeEnabled(DAR) else false, - usbSignalEnabled = if (VERSION.SDK_INT >= 31) DPM.isUsbDataSignalingEnabled else false, - canDisableUsbSignal = if (VERSION.SDK_INT >= 31) DPM.canUsbDataSignalingBeDisabled() else false - ) - } - fun setCameraDisabled(disabled: Boolean) { - DPM.setCameraDisabled(DAR, disabled) - ShortcutUtils.setShortcut(application, MyShortcut.DisableCamera, !disabled) - systemOptionsStatus.update { it.copy(cameraDisabled = DPM.getCameraDisabled(null)) } - } - fun setScreenCaptureDisabled(disabled: Boolean) { - DPM.setScreenCaptureDisabled(DAR, disabled) - systemOptionsStatus.update { - it.copy(screenCaptureDisabled = DPM.getScreenCaptureDisabled(null)) - } - } - fun setStatusBarDisabled(disabled: Boolean) { - val result = DPM.setStatusBarDisabled(DAR, disabled) - if (result) systemOptionsStatus.update { it.copy(statusBarDisabled = disabled) } - } - @RequiresApi(30) - fun setAutoTimeEnabled(enabled: Boolean) { - DPM.setAutoTimeEnabled(DAR, enabled) - systemOptionsStatus.update { it.copy(autoTimeEnabled = DPM.getAutoTimeEnabled(DAR)) } - } - @RequiresApi(30) - fun setAutoTimeZoneEnabled(enabled: Boolean) { - DPM.setAutoTimeZoneEnabled(DAR, enabled) - systemOptionsStatus.update { - it.copy(autoTimeZoneEnabled = DPM.getAutoTimeZoneEnabled(DAR)) - } - } - @Suppress("DEPRECATION") - fun setAutoTimeRequired(required: Boolean) { - DPM.setAutoTimeRequired(DAR, required) - systemOptionsStatus.update { it.copy(autoTimeRequired = DPM.autoTimeRequired) } - } - fun setMasterVolumeMuted(muted: Boolean) { - DPM.setMasterVolumeMuted(DAR, muted) - ShortcutUtils.setShortcut(application, MyShortcut.Mute, !muted) - systemOptionsStatus.update { it.copy(masterVolumeMuted = DPM.isMasterVolumeMuted(DAR)) } - } - @RequiresApi(26) - fun setBackupServiceEnabled(enabled: Boolean) { - DPM.setBackupServiceEnabled(DAR, enabled) - systemOptionsStatus.update { - it.copy(backupServiceEnabled = DPM.isBackupServiceEnabled(DAR)) - } - } - fun setBtContactSharingDisabled(disabled: Boolean) { - DPM.setBluetoothContactSharingDisabled(DAR, disabled) - systemOptionsStatus.update { - it.copy(btContactSharingDisabled = DPM.getBluetoothContactSharingDisabled(DAR)) - } - } - @RequiresApi(30) - fun setCommonCriteriaModeEnabled(enabled: Boolean) { - DPM.setCommonCriteriaModeEnabled(DAR, enabled) - systemOptionsStatus.update { - it.copy(commonCriteriaMode = DPM.isCommonCriteriaModeEnabled(DAR)) - } - } - @RequiresApi(31) - fun setUsbSignalEnabled(enabled: Boolean) { - DPM.isUsbDataSignalingEnabled = enabled - systemOptionsStatus.update { it.copy(usbSignalEnabled = DPM.isUsbDataSignalingEnabled) } - } - fun 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) - } - fun getPermissionPolicy(): Int { - return DPM.getPermissionPolicy(DAR) - } - fun setPermissionPolicy(policy: Int) { - DPM.setPermissionPolicy(DAR, policy) - } - @RequiresApi(34) - fun getMtePolicy(): Int { - return DPM.mtePolicy - } - @RequiresApi(34) - fun setMtePolicy(policy: Int): Boolean { - return try { - DPM.mtePolicy = policy - true - } catch (_: UnsupportedOperationException) { - false - } - } - @RequiresApi(31) - fun getNsAppPolicy(): Int { - return DPM.nearbyAppStreamingPolicy - } - @RequiresApi(31) - fun setNsAppPolicy(policy: Int) { - DPM.nearbyAppStreamingPolicy = policy - } - @RequiresApi(31) - fun getNsNotificationPolicy(): Int { - return DPM.nearbyNotificationStreamingPolicy - } - @RequiresApi(31) - fun setNsNotificationPolicy(policy: Int) { - DPM.nearbyNotificationStreamingPolicy = policy - } - val lockTaskPackages = MutableStateFlow(emptyList()) - @RequiresApi(26) - fun getLockTaskPackages() { - lockTaskPackages.value = DPM.getLockTaskPackages(DAR).map { getAppInfo(it) } - } - @RequiresApi(26) - fun setLockTaskPackage(name: String, status: Boolean) { - DPM.setLockTaskPackages(DAR, - lockTaskPackages.value.map { it.name } - .run { if (status) plus(name) else minus(name) } - .toTypedArray() - ) - getLockTaskPackages() - } - @RequiresApi(28) - fun startLockTaskMode( - packageName: String, activity: String, clearTask: Boolean, showNotification: Boolean - ): Boolean { - if (!DPM.isLockTaskPermitted(packageName)) { - val list = lockTaskPackages.value.map { it.name } + packageName - DPM.setLockTaskPackages(DAR, list.toTypedArray()) - getLockTaskPackages() - } - if (showNotification) { - DPM.setLockTaskFeatures( - DAR, - DPM.getLockTaskFeatures(DAR) or - DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS or - DevicePolicyManager.LOCK_TASK_FEATURE_HOME - ) - } - val options = ActivityOptions.makeBasic().setLockTaskEnabled(true) - val intent = if(activity.isNotEmpty()) { - Intent().setComponent(ComponentName(packageName, activity)) - } else PM.getLaunchIntentForPackage(packageName) - if (intent != null) { - intent.addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or (if (clearTask) Intent.FLAG_ACTIVITY_CLEAR_TASK else 0) - ) - application.startActivity(intent, options.toBundle()) - if (showNotification) { - application.startForegroundService(Intent(application, LockTaskService::class.java)) - } - return true - } else { - return false - } - } - @RequiresApi(28) - fun getLockTaskFeatures(): Int { - return DPM.getLockTaskFeatures(DAR) - } - @RequiresApi(28) - fun setLockTaskFeatures(flags: Int): String? { - try { - DPM.setLockTaskFeatures(DAR, flags) - return null - } catch (e: IllegalArgumentException) { - return e.message - } - } - val installedCaCerts = MutableStateFlow(emptyList()) - val selectedCaCert = MutableStateFlow(null) - fun getCaCerts() { - installedCaCerts.value = DPM.getInstalledCaCerts(DAR).mapNotNull { parseCaCert(it) } - } - fun selectCaCert(cert: CaCertInfo) { - selectedCaCert.value = cert - } - fun parseCaCert(uri: Uri) { - try { - application.contentResolver.openInputStream(uri)?.use { - selectedCaCert.value = parseCaCert(it.readBytes()) - } - } catch(e: Exception) { - e.printStackTrace() - } - } - fun parseCaCert(bytes: ByteArray): CaCertInfo? { - val hash = MessageDigest.getInstance("SHA-256").digest(bytes).toHexString() - return try { - val factory = CertificateFactory.getInstance("X.509") - val cert = factory.generateCertificate(bytes.inputStream()) as X509Certificate - CaCertInfo( - hash, cert.serialNumber.toString(16), - cert.issuerX500Principal.name, cert.subjectX500Principal.name, - cert.notBefore.time, cert.notAfter.time, bytes - ) - } catch (e: CertificateException) { - e.printStackTrace() - null - } - } - fun installCaCert(): Boolean { - val result = DPM.installCaCert(DAR, selectedCaCert.value!!.bytes) - if (result) getCaCerts() - return result - } - fun uninstallCaCert() { - DPM.uninstallCaCert(DAR, selectedCaCert.value!!.bytes) - getCaCerts() - } - fun uninstallAllCaCerts() { - DPM.uninstallAllUserCaCerts(DAR) - getCaCerts() - } - fun exportCaCert(uri: Uri) { - application.contentResolver.openOutputStream(uri)?.use { - it.write(selectedCaCert.value!!.bytes) - } - } - val mdAccountTypes = MutableStateFlow(emptyList()) - fun getMdAccountTypes() { - mdAccountTypes.value = DPM.accountTypesWithManagementDisabled?.toList() ?: emptyList() - } - fun setMdAccountType(type: String, disabled: Boolean) { - DPM.setAccountManagementDisabled(DAR, type, disabled) - getMdAccountTypes() - } - @RequiresApi(30) - fun getFrpPolicy(): FrpPolicyInfo { - return try { - val policy = DPM.getFactoryResetProtectionPolicy(DAR) - FrpPolicyInfo( - true, policy != null, policy?.isFactoryResetProtectionEnabled ?: false, - policy?.factoryResetProtectionAccounts ?: emptyList() - ) - } catch (_: UnsupportedOperationException) { - FrpPolicyInfo(false, false, false, emptyList()) - } - } - @RequiresApi(30) - fun setFrpPolicy(info: FrpPolicyInfo) { - val policy = if (info.usePolicy) { - FactoryResetProtectionPolicy.Builder() - .setFactoryResetProtectionEnabled(info.enabled) - .setFactoryResetProtectionAccounts(info.accounts) - .build() - } else null - DPM.setFactoryResetProtectionPolicy(DAR, policy) - } - fun wipeData(wipeDevice: Boolean, flags: Int, reason: String) { - if (wipeDevice && VERSION.SDK_INT >= 34) { - DPM.wipeDevice(flags) - } else { - if(VERSION.SDK_INT >= 28 && reason.isNotEmpty()) { - DPM.wipeData(flags, reason) - } else { - DPM.wipeData(flags) - } - } - } - fun getSystemUpdatePolicy(): SystemUpdatePolicyInfo { - val policy = DPM.systemUpdatePolicy - return SystemUpdatePolicyInfo( - policy?.policyType ?: -1, policy?.installWindowStart ?: 0, policy?.installWindowEnd ?: 0 - ) - } - fun setSystemUpdatePolicy(info: SystemUpdatePolicyInfo) { - val policy = when (info.type) { - SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC -> SystemUpdatePolicy.createAutomaticInstallPolicy() - SystemUpdatePolicy.TYPE_INSTALL_WINDOWED -> - SystemUpdatePolicy.createWindowedInstallPolicy(info.start, info.end) - SystemUpdatePolicy.TYPE_POSTPONE -> SystemUpdatePolicy.createPostponeInstallPolicy() - else -> null - } - DPM.setSystemUpdatePolicy(DAR, policy) - } - @RequiresApi(26) - fun getPendingSystemUpdate(): PendingSystemUpdateInfo { - val update = DPM.getPendingSystemUpdate(DAR) - return PendingSystemUpdateInfo(update != null, update?.receivedTime ?: 0, - update?.securityPatchState == SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE) - } - @RequiresApi(29) - fun installSystemUpdate(uri: Uri, callback: (String) -> Unit) { - val callback = object: InstallSystemUpdateCallback() { - override fun onInstallUpdateError(errorCode: Int, errorMessage: String) { - super.onInstallUpdateError(errorCode, errorMessage) - val errDetail = when(errorCode) { - UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low - UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid - UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver - UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist - else -> R.string.unknown_error - } - callback(application.getString(errDetail) + "\n$errorMessage") - } - } - DPM.installSystemUpdate(DAR, uri, application.mainExecutor, callback) - } - @RequiresApi(24) - fun getSecurityLoggingEnabled(): Boolean { - return DPM.isSecurityLoggingEnabled(DAR) - } - @RequiresApi(24) - fun setSecurityLoggingEnabled(enabled: Boolean) { - DPM.setSecurityLoggingEnabled(DAR, enabled) - } - fun exportSecurityLogs(uri: Uri, callback: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - application.contentResolver.openOutputStream(uri)?.use { - myRepo.exportSecurityLogs(it) - } - withContext(Dispatchers.Main) { - callback() - } - } - } - fun getSecurityLogsCount(): Int { - return myRepo.getSecurityLogsCount().toInt() - } - fun deleteSecurityLogs() { - myRepo.deleteSecurityLogs() - } - var preRebootSecurityLogs = emptyList() - @RequiresApi(24) - fun getPreRebootSecurityLogs(): Boolean { - if (preRebootSecurityLogs.isNotEmpty()) return true - return try { - val logs = DPM.retrievePreRebootSecurityLogs(DAR) - if (logs != null && logs.isNotEmpty()) { - preRebootSecurityLogs = logs - true - } else false - } catch (_: SecurityException) { - false - } - } - @RequiresApi(24) - fun exportPreRebootSecurityLogs(uri: Uri, callback: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val stream = application.contentResolver.openOutputStream(uri) ?: return@launch - myRepo.exportPRSecurityLogs(preRebootSecurityLogs, stream) - stream.close() - withContext(Dispatchers.Main) { callback() } - } - } - - @RequiresApi(24) - fun isCreatingWorkProfileAllowed(): Boolean { - return DPM.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) - } - fun activateDoByShizuku(callback: (Boolean, String?) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - useShizuku(application) { service -> - try { - val result = IUserService.Stub.asInterface(service) - .execute(ACTIVATE_DEVICE_OWNER_COMMAND) - if (result == null || result.getInt("code", -1) != 0) { - callback(false, null) - } else { - Privilege.updateStatus() - handlePrivilegeChange(application) - callback( - true, result.getString("output") + "\n" + result.getString("error") - ) - } - } catch (e: Exception) { - e.printStackTrace() - callback(false, null) - } - } - } - } - fun activateDoByRoot(callback: (Boolean, String?) -> Unit) { - Shell.getShell { shell -> - if(shell.isRoot) { - val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec() - val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n") - if (result.isSuccess) { - Privilege.updateStatus() - handlePrivilegeChange(application) - } - callback(result.isSuccess, output) - } else { - callback(false, application.getString(R.string.permission_denied)) - } - } - } - @RequiresApi(28) - fun activateDoByDhizuku(callback: (Boolean, String?) -> Unit) { - DPM.transferOwnership(DAR, MyAdminComponent, null) - SP.dhizuku = false - Privilege.initialize(application) - handlePrivilegeChange(application) - callback(true, null) - } - fun activateDhizukuMode(callback: (Boolean, String?) -> Unit) { - fun onSucceed() { - SP.dhizuku = true - Privilege.initialize(application) - handlePrivilegeChange(application) - callback(true, null) - } - if (Dhizuku.init(application)) { - if (Dhizuku.isPermissionGranted()) { - onSucceed() - } else { - Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { - override fun onRequestPermission(grantResult: Int) { - if (grantResult == PackageManager.PERMISSION_GRANTED) onSucceed() - else callback(false, application.getString(R.string.dhizuku_permission_not_granted)) - } - }) - } - } else { - callback(false, application.getString(R.string.failed_to_init_dhizuku)) - } - } - fun clearDeviceOwner() { - DPM.clearDeviceOwnerApp(application.packageName) - } - @RequiresApi(24) - fun clearProfileOwner() { - DPM.clearProfileOwner(MyAdminComponent) - } - fun deactivateDhizukuMode() { - SP.dhizuku = false - Privilege.initialize(application) - } - val dhizukuClients = MutableStateFlow(emptyList>()) - fun getDhizukuClients() { - viewModelScope.launch(Dispatchers.IO) { - dhizukuClients.value = myRepo.getDhizukuClients().mapNotNull { - val packageName = PM.getNameForUid(it.uid) - if (packageName == null) { - myRepo.deleteDhizukuClient(it) - null - } else { - it to getAppInfo(packageName) - } - } - } - } - fun getDhizukuServerEnabled(): Boolean { - return SP.dhizukuServer - } - fun setDhizukuServerEnabled(status: Boolean) { - SP.dhizukuServer = status - } - fun updateDhizukuClient(info: DhizukuClientInfo) { - myRepo.setDhizukuClient(info) - dhizukuClients.update { list -> - val ml = list.toMutableList() - val index = ml.indexOfFirst { it.first.uid == info.uid } - ml[index] = info to ml[index].second - ml - } - } - @RequiresApi(24) - fun getLockScreenInfo(): String { - return DPM.deviceOwnerLockScreenInfo?.toString() ?: "" - } - @RequiresApi(24) - fun setLockScreenInfo(text: String) { - DPM.setDeviceOwnerLockScreenInfo(DAR, text) - } - val delegatedAdmins = MutableStateFlow(emptyList()) - @RequiresApi(26) - fun getDelegatedAdmins() { - val list = mutableListOf() - delegatedScopesList.forEach { scope -> - DPM.getDelegatePackages(DAR, scope.id)?.forEach { pkg -> - val index = list.indexOfFirst { it.app.name == pkg } - if (index == -1) { - list += DelegatedAdmin(getAppInfo(pkg), listOf(scope.id)) - } else { - list[index] = DelegatedAdmin(list[index].app, list[index].scopes + scope.id) - } - } - } - delegatedAdmins.value = list - } - @RequiresApi(26) - fun setDelegatedAdmin(name: String, scopes: List) { - DPM.setDelegatedScopes(DAR, name, scopes) - getDelegatedAdmins() - } - @RequiresApi(34) - fun getDeviceFinanced(): Boolean { - return DPM.isDeviceFinanced - } - @RequiresApi(33) - fun getDpmRh(): String? { - return DPM.devicePolicyManagementRoleHolderPackage - } - fun getStorageEncryptionStatus(): Int { - return DPM.storageEncryptionStatus - } - @RequiresApi(28) - fun getDeviceIdAttestationSupported(): Boolean { - return DPM.isDeviceIdAttestationSupported - } - @RequiresApi(30) - fun getUniqueDeviceAttestationSupported(): Boolean { - return DPM.isUniqueDeviceAttestationSupported - } - fun getActiveAdmins(): String { - return DPM.activeAdmins?.joinToString("\n") { - it.flattenToShortString() - } ?: application.getString(R.string.none) - } - @RequiresApi(24) - fun getShortSupportMessage(): String { - return DPM.getShortSupportMessage(DAR)?.toString() ?: "" - } - @RequiresApi(24) - fun getLongSupportMessage(): String { - return DPM.getLongSupportMessage(DAR)?.toString() ?: "" - } - @RequiresApi(24) - fun setShortSupportMessage(text: String?) { - DPM.setShortSupportMessage(DAR, text) - } - @RequiresApi(24) - fun setLongSupportMessage(text: String?) { - DPM.setLongSupportMessage(DAR, text) - } - val deviceAdminReceivers = MutableStateFlow(emptyList()) - fun getDeviceAdminReceivers() { - viewModelScope.launch(Dispatchers.IO) { - deviceAdminReceivers.value = PM.queryBroadcastReceivers( - Intent(DeviceAdminReceiver.ACTION_DEVICE_ADMIN_ENABLED), - PackageManager.GET_META_DATA - ).mapNotNull { - try { - DeviceAdminInfo(application, it) - } catch(_: Exception) { - null - } - }.filter { - it.isVisible && it.packageName != "com.bintianqi.owndroid" && - it.activityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 0 - }.map { - DeviceAdmin(getAppInfo(it.packageName), it.component) - } - } - } - @RequiresApi(28) - fun transferOwnership(component: ComponentName) { - DPM.transferOwnership(DAR, component, null) - Privilege.updateStatus() - } - val userRestrictions = MutableStateFlow(emptyMap()) - @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) - intent.putExtra( - DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, - MyAdminComponent - ) - if (options.migrateAccount) { - intent.putExtra( - DevicePolicyManager.EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE, - Account(options.accountName, options.accountType) - ) - if (VERSION.SDK_INT >= 26) { - intent.putExtra( - DevicePolicyManager.EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION, - options.keepAccount - ) - } - } - if (VERSION.SDK_INT >= 24) { - intent.putExtra( - DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, - options.skipEncrypt - ) - } - if (VERSION.SDK_INT >= 33) { - intent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_ALLOW_OFFLINE, options.offline) - } - return intent - } - fun activateOrgProfileByShizuku(callback: (Boolean) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - var succeed = false - useShizuku(application) { service -> - val result = IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand) - succeed = result?.getInt("code", -1) == 0 - callback(succeed) - } - if (succeed) Privilege.updateStatus() - } - } - @RequiresApi(30) - fun getPersonalAppsSuspendedReason(): Int { - return DPM.getPersonalAppsSuspendedReasons(DAR) - } - @RequiresApi(30) - fun setPersonalAppsSuspended(suspended: Boolean) { - DPM.setPersonalAppsSuspended(DAR, suspended) - } - @RequiresApi(30) - fun getProfileMaxTimeOff(): Long { - return DPM.getManagedProfileMaximumTimeOff(DAR) - } - @RequiresApi(30) - fun setProfileMaxTimeOff(time: Long) { - DPM.setManagedProfileMaximumTimeOff(DAR, time) - } - fun addCrossProfileIntentFilter(options: IntentFilterOptions) { - val filter = IntentFilter(options.action) - if (options.category.isNotEmpty()) filter.addCategory(options.category) - if (options.mimeType.isNotEmpty()) filter.addDataType(options.mimeType) - 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, - UM.isSystemUser, - if (VERSION.SDK_INT >= 34) UM.isAdminUser else false, - if (VERSION.SDK_INT >= 25) UM.isDemoUser else false, - UM.getUserCreationTime(uh), - if (VERSION.SDK_INT >= 28) DPM.isLogoutEnabled else false, - if (VERSION.SDK_INT >= 28) DPM.isEphemeralUser(DAR) else false, - if (VERSION.SDK_INT >= 28) DPM.isAffiliatedUser else false, - UM.getSerialNumberForUser(uh) - ) - } - @Suppress("PrivateApi") - @RequiresApi(28) - fun getUserIdentifiers(): List { - return DPM.getSecondaryUsers(DAR)?.mapNotNull { - try { - val field = UserHandle::class.java.getDeclaredField("mHandle") - field.isAccessible = true - UserIdentifier(field.get(it) as Int, UM.getSerialNumberForUser(it)) - } catch (e: Exception) { - e.printStackTrace() - null - } - } ?: emptyList() - } - fun doUserOperation(type: UserOperationType, id: Int, isUserId: Boolean): Boolean { - return doUserOperationWithContext(application, type, id, isUserId) - } - fun createUserOperationShortcut(type: UserOperationType, id: Int, isUserId: Boolean): Boolean { - val serial = if (isUserId && VERSION.SDK_INT >= 24) { - UM.getSerialNumberForUser(UserHandle.getUserHandleForUid(id * 100000)) - } else id - return ShortcutUtils.setUserOperationShortcut(application, type, serial.toInt()) - } - fun getUserOperationResultText(code: Int): Int { - return when (code) { - UserManager.USER_OPERATION_SUCCESS -> R.string.success - UserManager.USER_OPERATION_ERROR_UNKNOWN -> R.string.unknown_error - UserManager.USER_OPERATION_ERROR_MANAGED_PROFILE-> R.string.fail_managed_profile - UserManager.USER_OPERATION_ERROR_MAX_RUNNING_USERS -> R.string.limit_reached - UserManager.USER_OPERATION_ERROR_MAX_USERS -> R.string.limit_reached - UserManager.USER_OPERATION_ERROR_CURRENT_USER -> R.string.fail_current_user - else -> R.string.unknown - } - } - @RequiresApi(24) - fun createUser(name: String, flags: Int, callback: (CreateUserResult) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - try { - val uh = DPM.createAndManageUser(DAR, name, DAR, null, flags) - if (uh == null) { - callback(CreateUserResult(R.string.failed)) - } else { - callback(CreateUserResult(R.string.succeeded, UM.getSerialNumberForUser(uh))) - } - } catch (e: Exception) { - e.printStackTrace() - if (VERSION.SDK_INT >= 28 && e is UserManager.UserOperationException) { - callback(CreateUserResult(getUserOperationResultText(e.userOperationResult))) - } else { - callback(CreateUserResult(R.string.error)) - } - } - } - } - val affiliationIds = MutableStateFlow(emptyList()) - @RequiresApi(26) - fun getAffiliationIds() { - affiliationIds.value = DPM.getAffiliationIds(DAR).toList() - } - @RequiresApi(26) - fun setAffiliationId(id: String, state: Boolean) { - val newList = affiliationIds.value.run { if (state) plus(id) else minus(id) } - DPM.setAffiliationIds(DAR, newList.toSet()) - affiliationIds.value = newList - } - fun setProfileName(name: String) { - DPM.setProfileName(DAR, name) - } - fun setUserIcon(bitmap: Bitmap) { - DPM.setUserIcon(DAR, bitmap) - } - @RequiresApi(28) - fun getUserSessionMessages(): Pair { - return (DPM.getStartUserSessionMessage(DAR)?.toString() ?: "") to - (DPM.getEndUserSessionMessage(DAR)?.toString() ?: "") - } - @RequiresApi(28) - fun setStartUserSessionMessage(message: String?) { - DPM.setStartUserSessionMessage(DAR, message) - } - @RequiresApi(28) - fun setEndUserSessionMessage(message: String?) { - DPM.setEndUserSessionMessage(DAR, message) - } - @RequiresApi(28) - fun logoutUser(): Int { - return getUserOperationResultText(DPM.logoutUser(DAR)) - } - - val WM = application.getSystemService(Context.WIFI_SERVICE) as WifiManager - // Lockdown admin configured networks - @RequiresApi(30) - fun getLanEnabled(): Boolean { - return DPM.hasLockdownAdminConfiguredNetworks(DAR) - } - @RequiresApi(30) - fun setLanEnabled(state: Boolean) { - DPM.setConfiguredNetworksLockdownState(DAR, state) - } - fun setWifiEnabled(enabled: Boolean): Boolean { - return WM.setWifiEnabled(enabled) - } - fun disconnectWifi(): Boolean { - return WM.disconnect() - } - fun reconnectWifi(): Boolean { - return WM.reconnect() - } - @RequiresApi(24) - fun getWifiMac(): String? { - return DPM.getWifiMacAddress(DAR) - } - val configuredNetworks = MutableStateFlow(emptyList()) - fun getConfiguredNetworks() { - configuredNetworks.value = WM.configuredNetworks.distinctBy { it.networkId }.map { conf -> - WifiInfo( - conf.networkId, conf.SSID.removeSurrounding("\""), null, conf.BSSID ?: "", null, - WifiStatus.entries.find { it.id == conf.status }!!, null, "", null, null, null, null - ) - } - } - fun enableNetwork(id: Int): Boolean { - return WM.enableNetwork(id, false) - } - fun disableNetwork(id: Int): Boolean { - return WM.disableNetwork(id) - } - fun removeNetwork(id: Int): Boolean{ - return WM.removeNetwork(id) - } - fun setWifi(info: WifiInfo): Boolean { - val conf = WifiConfiguration() - conf.SSID = "\"" + info.ssid + "\"" - info.hiddenSsid?.let { conf.hiddenSSID = it } - if (VERSION.SDK_INT >= 30) info.security?.let { conf.setSecurityParams(it.id) } - if (info.security == WifiSecurity.Psk) conf.preSharedKey = info.password - if (VERSION.SDK_INT >= 33) info.macRandomization?.let { conf.macRandomizationSetting = it.id } - if (VERSION.SDK_INT >= 33 && info.ipMode != null) { - val ipConf = if (info.ipMode == IpMode.Static && info.ipConf != null) { - val constructor = LinkAddress::class.constructors.find { - it.parameters.size == 1 && it.parameters[0].type.jvmErasure == String::class - } - val address = constructor!!.call(info.ipConf.address) - val staticIpConf = StaticIpConfiguration.Builder() - .setIpAddress(address) - .setGateway(InetAddress.getByName(info.ipConf.gateway)) - .setDnsServers(info.ipConf.dns.map { InetAddress.getByName(it) }) - .build() - IpConfiguration.Builder().setStaticIpConfiguration(staticIpConf).build() - } else null - conf.setIpConfiguration(ipConf) - } - if (VERSION.SDK_INT >= 26 && info.proxyMode != null) { - val proxy = if (info.proxyMode == ProxyMode.Http) { - info.proxyConf?.let { - ProxyInfo.buildDirectProxy(it.host, it.port, it.exclude) - } - } else null - conf.httpProxy = proxy - } - val result = if (info.id != -1) { - conf.networkId = info.id - WM.updateNetwork(conf) - } else { - WM.addNetwork(conf) - } - if (result != -1) { - when (info.status) { - WifiStatus.Current -> WM.enableNetwork(result, true) - WifiStatus.Enabled -> WM.enableNetwork(result, false) - WifiStatus.Disabled -> WM.disableNetwork(result) - } - } - return result != -1 - } - @RequiresApi(33) - fun getMinimumWifiSecurityLevel(): Int { - return DPM.minimumRequiredWifiSecurityLevel - } - @RequiresApi(33) - fun setMinimumWifiSecurityLevel(level: Int) { - DPM.minimumRequiredWifiSecurityLevel = level - } - @RequiresApi(33) - fun getSsidPolicy(): SsidPolicy { - val policy = DPM.wifiSsidPolicy - return SsidPolicy( - SsidPolicyType.entries.find { it.id == policy?.policyType } ?: SsidPolicyType.None, - policy?.ssids?.map { it.bytes.decodeToString() } ?: emptyList() - ) - } - @RequiresApi(33) - fun setSsidPolicy(policy: SsidPolicy) { - val newPolicy = if (policy.type != SsidPolicyType.None) { - WifiSsidPolicy( - policy.type.id, policy.list.map { WifiSsid.fromBytes(it.encodeToByteArray()) }.toSet() - ) - } else null - DPM.wifiSsidPolicy = newPolicy - } - @RequiresApi(24) - fun getPackageUid(name: String): Int { - return PM.getPackageUid(name, 0) - } - var networkStatsData = emptyList() - fun readNetworkStats(stats: NetworkStats): List { - val list = mutableListOf() - while (stats.hasNextBucket()) { - val bucket = NetworkStats.Bucket() - stats.getNextBucket(bucket) - list += readNetworkStatsBucket(bucket) - } - stats.close() - return list - } - fun readNetworkStatsBucket(bucket: NetworkStats.Bucket): NetworkStatsData { - return NetworkStatsData( - bucket.rxBytes, bucket.rxPackets, bucket.txBytes, bucket.txPackets, - bucket.uid, bucket.state, bucket.startTimeStamp, bucket.endTimeStamp, - if (VERSION.SDK_INT >= 24) bucket.tag else null, - if (VERSION.SDK_INT >= 24) bucket.roaming else null, - if (VERSION.SDK_INT >= 26) bucket.metered else null - ) - } - @Suppress("NewApi") - fun queryNetworkStats(params: QueryNetworkStatsParams, callback: (String?) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val nsm = application.getSystemService(NetworkStatsManager::class.java) - try { - val data = when (params.target) { - NetworkStatsTarget.Device -> listOf(readNetworkStatsBucket( - nsm.querySummaryForDevice( - params.networkType.type, null, params.startTime, params.endTime - ) - )) - NetworkStatsTarget.User -> listOf(readNetworkStatsBucket( - nsm.querySummaryForUser( - params.networkType.type, null, params.startTime, params.endTime - ) - )) - NetworkStatsTarget.Uid -> readNetworkStats(nsm.queryDetailsForUid( - params.networkType.type, null, params.startTime, params.endTime, params.uid - )) - NetworkStatsTarget.UidTag -> readNetworkStats(nsm.queryDetailsForUidTag( - params.networkType.type, null, params.startTime, params.endTime, - params.uid, params.tag - )) - NetworkStatsTarget.UidTagState -> readNetworkStats( - nsm.queryDetailsForUidTagState( - params.networkType.type, null, params.startTime, params.endTime, - params.uid, params.tag, params.state.id - ) - ) - } - networkStatsData = data - withContext(Dispatchers.Main) { - if (data.isEmpty()) { - callback(application.getString(R.string.no_data)) - } else { - callback(null) - } - } - } catch(e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - callback(e.message ?: "") - } - } - } - } - fun clearNetworkStats() { - networkStatsData = emptyList() - } - @RequiresApi(29) - fun getPrivateDns(): PrivateDnsConfiguration { - val mode = DPM.getGlobalPrivateDnsMode(DAR) - return PrivateDnsConfiguration( - PrivateDnsMode.entries.find { it.id == mode }, DPM.getGlobalPrivateDnsHost(DAR) ?: "" - ) - } - @Suppress("PrivateApi") - @RequiresApi(29) - fun setPrivateDns(conf: PrivateDnsConfiguration): Boolean { - return try { - val field = DevicePolicyManager::class.java.getDeclaredField("mService") - field.isAccessible = true - val dpm = field.get(DPM) as IDevicePolicyManager - val host = if (conf.mode == PrivateDnsMode.Host) conf.host else null - val result = dpm.setGlobalPrivateDns(DAR, conf.mode!!.id, host) - result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR - } catch (e: Exception) { - e.printStackTrace() - false - } - } - @RequiresApi(24) - fun getAlwaysOnVpnPackage(): String { - return DPM.getAlwaysOnVpnPackage(DAR) ?: "" - } - @RequiresApi(29) - fun getAlwaysOnVpnLockdown(): Boolean { - return DPM.isAlwaysOnVpnLockdownEnabled(DAR) - } - @RequiresApi(24) - fun setAlwaysOnVpn(name: String?, lockdown: Boolean): Int { - return try { - DPM.setAlwaysOnVpnPackage(DAR, name, lockdown) - R.string.succeeded - } catch (_: UnsupportedOperationException) { - R.string.unsupported - } catch (_: PackageManager.NameNotFoundException) { - R.string.not_installed - } - } - fun setRecommendedGlobalProxy(conf: RecommendedProxyConf) { - val info = when (conf.type) { - ProxyType.Off -> null - ProxyType.Pac -> { - if (VERSION.SDK_INT >= 30 && conf.specifyPort) { - ProxyInfo.buildPacProxy(conf.url.toUri(), conf.port) - } else { - ProxyInfo.buildPacProxy(conf.url.toUri()) - } - } - ProxyType.Direct -> { - ProxyInfo.buildDirectProxy(conf.host, conf.port, conf.exclude) - } - } - DPM.setRecommendedGlobalProxy(DAR, info) - } - // PNS: preferential network service - @RequiresApi(31) - fun getPnsEnabled(): Boolean { - return DPM.isPreferentialNetworkServiceEnabled - } - @RequiresApi(31) - fun setPnsEnabled(enabled: Boolean) { - DPM.isPreferentialNetworkServiceEnabled = enabled - } - val pnsConfigs = MutableStateFlow(emptyList()) - @RequiresApi(33) - fun getPnsConfigs() { - pnsConfigs.value = DPM.preferentialNetworkServiceConfigs.map { - PreferentialNetworkServiceInfo( - it.isEnabled, it.networkId, it.isFallbackToDefaultConnectionAllowed, - if (VERSION.SDK_INT >= 34) it.shouldBlockNonMatchingNetworks() else false, - it.excludedUids.toList(), it.includedUids.toList() - ) - } - } - @RequiresApi(33) - fun buildPnsConfig( - info: PreferentialNetworkServiceInfo - ): PreferentialNetworkServiceConfig { - return PreferentialNetworkServiceConfig.Builder().apply { - setEnabled(info.enabled) - @Suppress("WrongConstant") - setNetworkId(info.id) - setFallbackToDefaultConnectionAllowed(info.allowFallback) - if (VERSION.SDK_INT >= 34) setShouldBlockNonMatchingNetworks(info.blockNonMatching) - setIncludedUids(info.includedUids.toIntArray()) - setExcludedUids(info.excludedUids.toIntArray()) - }.build() - } - @RequiresApi(33) - fun setPnsConfig(info: PreferentialNetworkServiceInfo, state: Boolean) { - val configs = pnsConfigs.value.run { - if (state) plus(info) else minus(info) - }.map { buildPnsConfig(it) } - DPM.preferentialNetworkServiceConfigs = configs - } - val apnConfigs = MutableStateFlow(listOf()) - @RequiresApi(28) - fun getApnEnabled(): Boolean { - return DPM.isOverrideApnEnabled(DAR) - } - @RequiresApi(28) - fun setApnEnabled(enabled: Boolean) { - DPM.setOverrideApnsEnabled(DAR, enabled) - } - @RequiresApi(28) - fun getApnConfigs() { - apnConfigs.value = DPM.getOverrideApns(DAR).map { - val proxy = if (VERSION.SDK_INT >= 29) it.proxyAddressAsString else it.proxyAddress.hostName - val mmsProxy = if (VERSION.SDK_INT >= 29) it.mmsProxyAddressAsString else it.mmsProxyAddress.hostName - ApnConfig( - it.isEnabled, it.entryName, it.apnName, proxy, it.proxyPort, - it.user, it.password, it.apnTypeBitmask, it.mmsc.toString(), - mmsProxy, it.mmsProxyPort, - ApnAuthType.entries.find { type -> type.id == it.authType }!!, - ApnProtocol.entries.find { protocol -> protocol.id == it.protocol }!!, - ApnProtocol.entries.find { protocol -> protocol.id == it.roamingProtocol }!!, - it.networkTypeBitmask, - if (VERSION.SDK_INT >= 33) it.profileId else 0, - if (VERSION.SDK_INT >= 29) it.carrierId else 0, - if (VERSION.SDK_INT >= 33) it.mtuV4 else 0, - if (VERSION.SDK_INT >= 33) it.mtuV6 else 0, - ApnMvnoType.entries.find { type -> type.id == it.mvnoType }!!, - it.operatorNumeric, - if (VERSION.SDK_INT >= 33) it.isPersistent else true, - if (VERSION.SDK_INT >= 35) it.isAlwaysOn else true, - it.id - ) - } - } - @RequiresApi(28) - fun buildApnSetting(config: ApnConfig): ApnSetting? { - val builder = ApnSetting.Builder() - builder.setCarrierEnabled(config.enabled) - builder.setEntryName(config.name) - builder.setApnName(config.apn) - if (VERSION.SDK_INT >= 29) builder.setProxyAddress(config.proxy) - else builder.setProxyAddress(InetAddress.getByName(config.proxy)) - config.port?.let { builder.setProxyPort(it) } - builder.setUser(config.username) - builder.setPassword(config.password) - builder.setApnTypeBitmask(config.apnType) - builder.setMmsc(config.mmsc.toUri()) - if (VERSION.SDK_INT >= 29) builder.setMmsProxyAddress(config.mmsProxy) - else builder.setMmsProxyAddress(InetAddress.getByName(config.mmsProxy)) - builder.setAuthType(config.authType.id) - builder.setProtocol(config.protocol.id) - builder.setRoamingProtocol(config.roamingProtocol.id) - builder.setNetworkTypeBitmask(config.networkType) - if (VERSION.SDK_INT >= 33) config.profileId?.let { builder.setProfileId(it) } - if (VERSION.SDK_INT >= 29) config.carrierId?.let { builder.setCarrierId(it) } - if (VERSION.SDK_INT >= 33) { - config.mtuV4?.let { builder.setMtuV4(it) } - config.mtuV6?.let { builder.setMtuV6(it) } - } - builder.setMvnoType(config.mvno.id) - builder.setOperatorNumeric(config.operatorNumeric) - if (VERSION.SDK_INT >= 33) builder.setPersistent(config.persistent) - if (VERSION.SDK_INT >= 35) builder.setAlwaysOn(config.alwaysOn) - return builder.build() - } - @RequiresApi(28) - fun setApnConfig(config: ApnConfig): Boolean { - val settings = buildApnSetting(config) - if (settings == null) return false - return if (config.id == -1) { - DPM.addOverrideApn(DAR, settings) != -1 - } else { - DPM.updateOverrideApn(DAR, config.id, settings) - } - } - @RequiresApi(28) - fun removeApnConfig(id: Int): Boolean { - return DPM.removeOverrideApn(DAR, id) - } - @RequiresApi(26) - fun getNetworkLoggingEnabled(): Boolean { - return DPM.isNetworkLoggingEnabled(DAR) - } - @RequiresApi(26) - fun setNetworkLoggingEnabled(enabled: Boolean) { - DPM.setNetworkLoggingEnabled(DAR, enabled) - } - fun getNetworkLogsCount(): Int { - return myRepo.getNetworkLogsCount().toInt() - } - fun exportNetworkLogs(uri: Uri, callback: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - application.contentResolver.openOutputStream(uri)?.use { - myRepo.exportNetworkLogs(it) - } - withContext(Dispatchers.Main) { callback() } - } - } - fun deleteNetworkLogs() { - myRepo.deleteNetworkLogs() - } - - @RequiresApi(29) - fun getPasswordComplexity(): PasswordComplexity { - val complexity = DPM.passwordComplexity - return PasswordComplexity.entries.find { it.id == complexity }!! - } - fun isPasswordComplexitySufficient(): Boolean { - return DPM.isActivePasswordSufficient - } - @RequiresApi(28) - fun isUsingUnifiedPassword(): Boolean { - return DPM.isUsingUnifiedPassword(DAR) - } - // Reset password token - @RequiresApi(26) - fun getRpTokenState(): RpTokenState { - return try { - RpTokenState(true, DPM.isResetPasswordTokenActive(DAR)) - } catch (_: IllegalStateException) { - RpTokenState(false, false) - } - } - @RequiresApi(26) - fun setRpToken(token: String): Boolean { - return try { - DPM.setResetPasswordToken(DAR, token.encodeToByteArray()) - } catch (e: Exception) { - e.printStackTrace() - false - } - } - @RequiresApi(26) - fun clearRpToken(): Boolean { - return DPM.clearResetPasswordToken(DAR) - } - @RequiresApi(26) - fun createActivateRpTokenIntent(): Intent? { - val km = application.getSystemService(KeyguardManager::class.java) - val title = application.getString(R.string.activate_reset_password_token) - return km.createConfirmDeviceCredentialIntent(title, "") - } - fun resetPassword(password: String, token: String, flags: Int): Boolean { - return if (VERSION.SDK_INT >= 26) { - DPM.resetPasswordWithToken(DAR, password, token.encodeToByteArray(), flags) - } else { - DPM.resetPassword(password, flags) - } - } - @RequiresApi(31) - fun getRequiredPasswordComplexity(): PasswordComplexity { - val complexity = DPM.requiredPasswordComplexity - return PasswordComplexity.entries.find { it.id == complexity }!! - } - @RequiresApi(31) - fun setRequiredPasswordComplexity(complexity: PasswordComplexity) { - DPM.requiredPasswordComplexity = complexity.id - } - fun getKeyguardDisableConfig(): KeyguardDisableConfig { - val flags = DPM.getKeyguardDisabledFeatures(DAR) - val mode = when (flags) { - DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -> KeyguardDisableMode.None - DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -> KeyguardDisableMode.All - else -> KeyguardDisableMode.Custom - } - return KeyguardDisableConfig(mode, flags) - } - fun setKeyguardDisableConfig(config: KeyguardDisableConfig) { - val flags = when (config.mode) { - KeyguardDisableMode.None -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE - KeyguardDisableMode.All -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL - else -> config.flags - } - DPM.setKeyguardDisabledFeatures(DAR, flags) - } - fun getMaxTimeToLock(): Long { - return DPM.getMaximumTimeToLock(DAR) - } - @RequiresApi(26) - fun getRequiredStrongAuthTimeout(): Long { - return DPM.getRequiredStrongAuthTimeout(DAR) - } - fun getPasswordExpirationTimeout(): Long { - return DPM.getPasswordExpirationTimeout(DAR) - } - fun getMaxFailedPasswordsForWipe(): Int { - return DPM.getMaximumFailedPasswordsForWipe(DAR) - } - fun getPasswordHistoryLength(): Int { - return DPM.getPasswordHistoryLength(DAR) - } - fun setMaxTimeToLock(time: Long) { - DPM.setMaximumTimeToLock(DAR, time) - } - @RequiresApi(26) - fun setRequiredStrongAuthTimeout(time: Long) { - DPM.setRequiredStrongAuthTimeout(DAR, time) - } - fun setPasswordExpirationTimeout(time: Long) { - DPM.setPasswordExpirationTimeout(DAR, time) - } - fun setMaxFailedPasswordsForWipe(times: Int) { - DPM.setMaximumFailedPasswordsForWipe(DAR, times) - } - fun setPasswordHistoryLength(length: Int) { - DPM.setPasswordHistoryLength(DAR, length) - } -} - -data class ThemeSettings( - val materialYou: Boolean = false, - val darkTheme: Int = -1, - val blackTheme: Boolean = false -) diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModelFactory.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModelFactory.kt new file mode 100644 index 0000000..7c67269 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModelFactory.kt @@ -0,0 +1,150 @@ +package com.bintianqi.owndroid + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.bintianqi.owndroid.feature.applications.AppChooserViewModel +import com.bintianqi.owndroid.feature.applications.AppFeaturesViewModel +import com.bintianqi.owndroid.feature.applications.AppGroup +import com.bintianqi.owndroid.feature.applications.AppGroupRepository +import com.bintianqi.owndroid.feature.applications.AppGroupViewModel +import com.bintianqi.owndroid.feature.network.NetworkLoggingRepository +import com.bintianqi.owndroid.feature.network.NetworkLoggingViewModel +import com.bintianqi.owndroid.feature.network.NetworkStatsViewModel +import com.bintianqi.owndroid.feature.network.NetworkViewModel +import com.bintianqi.owndroid.feature.network.OverrideApnViewModel +import com.bintianqi.owndroid.feature.network.PreferentialNetworkViewModel +import com.bintianqi.owndroid.feature.network.WifiViewModel +import com.bintianqi.owndroid.feature.password.PasswordViewModel +import com.bintianqi.owndroid.feature.privilege.DelegatedAdminsViewModel +import com.bintianqi.owndroid.feature.privilege.DhizukuServerRepository +import com.bintianqi.owndroid.feature.privilege.DhizukuServerViewModel +import com.bintianqi.owndroid.feature.privilege.TransferOwnershipViewModel +import com.bintianqi.owndroid.feature.privilege.WorkingModesViewModel +import com.bintianqi.owndroid.feature.settings.MySettings +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.feature.settings.SettingsViewModel +import com.bintianqi.owndroid.feature.system.CaCertViewModel +import com.bintianqi.owndroid.feature.system.HardwareMonitorViewModel +import com.bintianqi.owndroid.feature.system.LockTaskModeViewModel +import com.bintianqi.owndroid.feature.system.SecurityLoggingRepository +import com.bintianqi.owndroid.feature.system.SecurityLoggingViewModel +import com.bintianqi.owndroid.feature.system.SystemOptionsViewModel +import com.bintianqi.owndroid.feature.system.SystemUpdateViewModel +import com.bintianqi.owndroid.feature.system.SystemViewModel +import com.bintianqi.owndroid.feature.system.TimeViewModel +import com.bintianqi.owndroid.feature.user_restriction.UserRestrictionViewModel +import com.bintianqi.owndroid.feature.users.UsersViewModel +import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterRepository +import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterViewModel +import com.bintianqi.owndroid.feature.work_profile.WorkProfileViewModel +import com.bintianqi.owndroid.utils.DhizukuError +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.reflect.KClass + +class MyViewModelFactory( + val app: MyApplication, val ph: PrivilegeHelper, + val sr: SettingsRepository, val nlRepo: NetworkLoggingRepository, + val dsRepo: DhizukuServerRepository, val slRepo: SecurityLoggingRepository, + val agRepo: AppGroupRepository, val cpifRepo: CrossProfileIntentFilterRepository, + val agState: MutableStateFlow>, + val de: MutableStateFlow, val ps: MutableStateFlow, + val ts: MutableStateFlow, val tc: ToastChannel +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + fun checkType(cls: KClass<*>): Boolean { + return cls.java.isAssignableFrom(modelClass) + } + if (checkType(NetworkStatsViewModel::class)) { + return NetworkStatsViewModel(app, ps) as T + } + if (checkType(WifiViewModel::class)) { + return WifiViewModel(app, ph, tc, ps) as T + } + if (checkType(OverrideApnViewModel::class)) { + return OverrideApnViewModel(ph, tc) as T + } + if (checkType(PreferentialNetworkViewModel::class)) { + return PreferentialNetworkViewModel(ph) as T + } + if (checkType(NetworkLoggingViewModel::class)) { + return NetworkLoggingViewModel(app, ph, nlRepo) as T + } + if (checkType(NetworkViewModel::class)) { + return NetworkViewModel(ph, tc, ps) as T + } + + if (checkType(DelegatedAdminsViewModel::class)) { + return DelegatedAdminsViewModel(app, ph) as T + } + if (checkType(TransferOwnershipViewModel::class)) { + return TransferOwnershipViewModel(app, ph, ps) as T + } + if (checkType(WorkingModesViewModel::class)) { + return WorkingModesViewModel(app, ph, sr, ps, tc) as T + } + if (checkType(DhizukuServerViewModel::class)) { + return DhizukuServerViewModel(app, dsRepo, sr) as T + } + + if (checkType(SecurityLoggingViewModel::class)) { + return SecurityLoggingViewModel(app, ph, slRepo, tc) as T + } + if (checkType(CaCertViewModel::class)) { + return CaCertViewModel(app, ph, tc) as T + } + if (checkType(LockTaskModeViewModel::class)) { + return LockTaskModeViewModel(app, ph, tc) as T + } + if (checkType(SystemOptionsViewModel::class)) { + return SystemOptionsViewModel(app, ph, sr, ps) as T + } + if (checkType(SystemUpdateViewModel::class)) { + return SystemUpdateViewModel(app, ph, tc) as T + } + if (checkType(HardwareMonitorViewModel::class)) { + return HardwareMonitorViewModel(app) as T + } + if (checkType(TimeViewModel::class)) { + return TimeViewModel(ph, tc) as T + } + if (checkType(SystemViewModel::class)) { + return SystemViewModel(app, ph, sr, ps, tc) as T + } + + if (checkType(AppGroupViewModel::class)) { + return AppGroupViewModel(app, agRepo, agState) as T + } + if (checkType(AppFeaturesViewModel::class)) { + return AppFeaturesViewModel(app, ph, ps, tc) as T + } + if (checkType(AppChooserViewModel::class)) { + return AppChooserViewModel(app) as T + } + + if (checkType(WorkProfileViewModel::class)) { + return WorkProfileViewModel(ph, ps, tc) as T + } + if (checkType(CrossProfileIntentFilterViewModel::class)) { + return CrossProfileIntentFilterViewModel(app, ph, cpifRepo, tc) as T + } + + if (checkType(UserRestrictionViewModel::class)) { + return UserRestrictionViewModel(app, ph, sr, ps, tc) as T + } + + if (checkType(UsersViewModel::class)) { + return UsersViewModel(app, ph, tc, sr, ps) as T + } + + if (checkType(PasswordViewModel::class)) { + return PasswordViewModel(app, ph, sr, ps, tc) as T + } + + if (checkType(SettingsViewModel::class)) { + return SettingsViewModel(app, sr, ph, ps, tc, ts) as T + } + throw Exception("Unknown ViewModel") + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt b/app/src/main/java/com/bintianqi/owndroid/Privilege.kt deleted file mode 100644 index 5b2f284..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/Privilege.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.bintianqi.owndroid - -import android.app.admin.DevicePolicyManager -import android.content.ComponentName -import android.content.Context -import android.os.Binder -import android.os.Build -import com.bintianqi.owndroid.dpm.binderWrapperDevicePolicyManager -import com.bintianqi.owndroid.dpm.dhizukuErrorStatus -import com.rosan.dhizuku.api.Dhizuku -import kotlinx.coroutines.flow.MutableStateFlow - -object Privilege { - fun initialize(context: Context) { - if (SP.dhizuku) { - if (Dhizuku.init(context)) try { - if (Dhizuku.isPermissionGranted()) { - val dhizukuDpm = binderWrapperDevicePolicyManager(context) - if (dhizukuDpm != null) { - DPM = dhizukuDpm - DAR = Dhizuku.getOwnerComponent() - updateStatus() - return - } - } - } catch(e: Exception) { - e.printStackTrace() - } - dhizukuErrorStatus.value = 2 - } - DPM = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - DAR = MyAdminComponent - updateStatus() - } - lateinit var DPM: DevicePolicyManager - private set - lateinit var DAR: ComponentName - private set - - data class Status( - val device: Boolean = false, - val profile: Boolean = false, - val dhizuku: Boolean = false, - val work: Boolean = false, - val org: Boolean = false, - val affiliated: Boolean = false - ) { - val activated = device || profile - val primary = Binder.getCallingUid() / 100000 == 0 // Primary user - } - val status = MutableStateFlow(Status()) - fun updateStatus() { - val profile = DPM.isProfileOwnerApp(DAR.packageName) - val work = profile && Build.VERSION.SDK_INT >= 24 && DPM.isManagedProfile(DAR) - status.value = Status( - device = DPM.isDeviceOwnerApp(DAR.packageName), - profile = profile, - dhizuku = SP.dhizuku, - work = work, - org = work && Build.VERSION.SDK_INT >= 30 && DPM.isOrganizationOwnedDeviceWithManagedProfile, - affiliated = Build.VERSION.SDK_INT >= 28 && DPM.isAffiliatedUser - ) - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/PrivilegeHelper.kt b/app/src/main/java/com/bintianqi/owndroid/PrivilegeHelper.kt new file mode 100644 index 0000000..31eb54e --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/PrivilegeHelper.kt @@ -0,0 +1,50 @@ +package com.bintianqi.owndroid + +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Context +import com.bintianqi.owndroid.utils.DhizukuError +import com.bintianqi.owndroid.utils.DhizukuException +import com.bintianqi.owndroid.utils.binderWrapperDevicePolicyManager +import com.rosan.dhizuku.api.Dhizuku +import kotlinx.coroutines.flow.MutableStateFlow + +class PrivilegeHelper( + val context: Context, var dhizuku: Boolean, val dhizukuError: MutableStateFlow +) { + val myDpm = context.getSystemService(DevicePolicyManager::class.java)!! + val myDar = ComponentName(context, Receiver::class.java) + + val dpm: DevicePolicyManager + get() { + return if (dhizuku) getDhizukuDpm() else myDpm + } + + val dar: ComponentName + get() { + return if (dhizuku) Dhizuku.getOwnerComponent() else myDar + } + + class SafeDpmCallScope(val dpm: DevicePolicyManager, val dar: ComponentName) + + fun safeDpmCall(block: SafeDpmCallScope.() -> Unit) { + try { + SafeDpmCallScope(dpm, dar).block() + } catch (e: DhizukuException) { + dhizukuError.value = e.reason + } + } + + private fun getDhizukuDpm(): DevicePolicyManager { + try { + if (!Dhizuku.init(context)) throw DhizukuException(DhizukuError.Init) + if (!Dhizuku.isPermissionGranted()) throw DhizukuException(DhizukuError.Permission) + return binderWrapperDevicePolicyManager(context) + } catch(e: Exception) { + if (e !is DhizukuException) { + throw DhizukuException(DhizukuError.Binder, e) + } + throw e + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index f5cd676..6707725 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -3,32 +3,26 @@ package com.bintianqi.owndroid import android.app.admin.DeviceAdminReceiver import android.content.Context import android.content.Intent -import android.os.Binder import android.os.Build.VERSION import android.os.UserHandle import android.os.UserManager -import com.bintianqi.owndroid.dpm.handlePrivilegeChange -import com.bintianqi.owndroid.dpm.retrieveNetworkLogs -import com.bintianqi.owndroid.dpm.retrieveSecurityLogs +import com.bintianqi.owndroid.utils.NotificationType +import com.bintianqi.owndroid.utils.NotificationUtils +import com.bintianqi.owndroid.utils.ShortcutUtils +import com.bintianqi.owndroid.utils.formatDate +import com.bintianqi.owndroid.utils.popToast +import com.bintianqi.owndroid.utils.retrieveNetworkLogs +import com.bintianqi.owndroid.utils.retrieveSecurityLogs class Receiver : DeviceAdminReceiver() { - override fun onEnabled(context: Context, intent: Intent) { - super.onEnabled(context, intent) - Privilege.updateStatus() - if (Binder.getCallingUid() / 100000 != 0) handlePrivilegeChange(context) - } - - override fun onDisabled(context: Context, intent: Intent) { - super.onDisabled(context, intent) - Privilege.updateStatus() - } - override fun onProfileProvisioningComplete(context: Context, intent: Intent) { super.onProfileProvisioningComplete(context, intent) context.popToast(R.string.create_work_profile_success) } - override fun onNetworkLogsAvailable(context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int) { + override fun onNetworkLogsAvailable( + context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int + ) { super.onNetworkLogsAvailable(context, intent, batchToken, networkLogsCount) if (VERSION.SDK_INT >= 26) { retrieveNetworkLogs(context.applicationContext as MyApplication, batchToken) @@ -78,28 +72,34 @@ class Receiver : DeviceAdminReceiver() { override fun onBugreportShared(context: Context, intent: Intent, hash: String) { super.onBugreportShared(context, intent, hash) - NotificationUtils.notifyEvent(context, NotificationType.BugReportShared, "SHA-256 hash: $hash") + NotificationUtils.notifyEvent( + context, getSr(context), NotificationType.BugReportShared, "SHA-256 hash: $hash" + ) } override fun onBugreportSharingDeclined(context: Context, intent: Intent) { super.onBugreportSharingDeclined(context, intent) - NotificationUtils.notifyEvent(context, NotificationType.BugReportSharingDeclined, "") + NotificationUtils.notifyEvent(context, getSr(context), NotificationType.BugReportSharingDeclined, "") } override fun onBugreportFailed(context: Context, intent: Intent, failureCode: Int) { super.onBugreportFailed(context, intent, failureCode) - val message = when(failureCode) { + val message = when (failureCode) { BUGREPORT_FAILURE_FAILED_COMPLETING -> R.string.bug_report_failure_failed_completing BUGREPORT_FAILURE_FILE_NO_LONGER_AVAILABLE -> R.string.bug_report_failure_no_longer_available else -> R.string.place_holder } - NotificationUtils.notifyEvent(context, NotificationType.BugReportFailed, context.getString(message)) + NotificationUtils.notifyEvent( + context, getSr(context), NotificationType.BugReportFailed, context.getString(message) + ) } override fun onSystemUpdatePending(context: Context, intent: Intent, receivedTime: Long) { super.onSystemUpdatePending(context, intent, receivedTime) val text = context.getString(R.string.received_time) + ": " + formatDate(receivedTime) - NotificationUtils.notifyEvent(context, NotificationType.SystemUpdatePending, text) + NotificationUtils.notifyEvent( + context, getSr(context), NotificationType.SystemUpdatePending, text + ) } private fun sendUserRelatedNotification( @@ -108,6 +108,11 @@ class Receiver : DeviceAdminReceiver() { val um = context.getSystemService(Context.USER_SERVICE) as UserManager val serial = um.getSerialNumberForUser(userHandle) val text = context.getString(R.string.serial_number) + ": $serial" - NotificationUtils.notifyEvent(context, type, text) + NotificationUtils.notifyEvent(context, getSr(context), type, text) + } + + companion object { + fun getSr(context: Context) = + (context.applicationContext as MyApplication).container.settingsRepo } } diff --git a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt deleted file mode 100644 index 27a880e..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.bintianqi.owndroid - -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import androidx.core.content.edit -import kotlin.properties.ReadWriteProperty -import kotlin.reflect.KProperty - -class SharedPrefs(context: Context) { - val sharedPrefs: SharedPreferences = context.getSharedPreferences("data", Context.MODE_PRIVATE) - var managedProfileActivated by BooleanSharedPref("managed_profile_activated") - var dhizuku by BooleanSharedPref("dhizuku_mode") - var isDefaultAffiliationIdSet by BooleanSharedPref("default_affiliation_id_set") - var displayDangerousFeatures by BooleanSharedPref("display_dangerous_features") - var apiKeyHash by StringSharedPref("api_key_hash") - var materialYou by BooleanSharedPref("theme.material_you", Build.VERSION.SDK_INT >= 31) - /** -1: follow system, 0: off, 1: on */ - var darkTheme by IntSharedPref("theme.dark", -1) - var blackTheme by BooleanSharedPref("theme.black") - var lockPasswordHash by StringSharedPref("lock.password.sha256") - var biometricsUnlock by BooleanSharedPref("lock.biometrics") - var lockWhenLeaving by BooleanSharedPref("lock.onleave") - var applicationsListView by BooleanSharedPref("applications.list_view", true) - var shortcuts by BooleanSharedPref("shortcuts") - var dhizukuServer by BooleanSharedPref("dhizuku_server") - var notifications by StringSharedPref("notifications") - var shortcutKey by StringSharedPref("shortcut_key") -} - -private class BooleanSharedPref(val key: String, val defValue: Boolean = false): ReadWriteProperty { - override fun getValue(thisRef: SharedPrefs, property: KProperty<*>): Boolean = - thisRef.sharedPrefs.getBoolean(key, defValue) - override fun setValue(thisRef: SharedPrefs, property: KProperty<*>, value: Boolean) = - thisRef.sharedPrefs.edit(true) { putBoolean(key, value) } -} - -private class StringSharedPref(val key: String): ReadWriteProperty { - override fun getValue(thisRef: SharedPrefs, property: KProperty<*>): String? = - thisRef.sharedPrefs.getString(key, null) - override fun setValue(thisRef: SharedPrefs, property: KProperty<*>, value: String?) = - thisRef.sharedPrefs.edit(true) { putString(key, value) } -} - -private class IntSharedPref(val key: String, val defValue: Int = 0): ReadWriteProperty { - override fun getValue(thisRef: SharedPrefs, property: KProperty<*>): Int = - thisRef.sharedPrefs.getInt(key, defValue) - override fun setValue(thisRef: SharedPrefs, property: KProperty<*>, value: Int) = - thisRef.sharedPrefs.edit(true) { putInt(key, value) } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt index 99c5b0d..8d192c0 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShizukuService.kt @@ -7,8 +7,8 @@ import android.content.pm.PackageManager import android.os.Bundle import android.os.IBinder import androidx.annotation.Keep +import com.bintianqi.owndroid.utils.popToast import rikka.shizuku.Shizuku -import rikka.sui.Sui import kotlin.system.exitProcess @Keep @@ -54,13 +54,14 @@ fun useShizuku(context: Context, action: (IBinder?) -> Unit) { Shizuku.bindUserService(getShizukuArgs(context), connection) } else if(Shizuku.shouldShowRequestPermissionRationale()) { context.popToast(R.string.permission_denied) + action(null) } else { - Sui.init(context.packageName) fun requestPermissionResultListener(requestCode: Int, grantResult: Int) { if (grantResult == PackageManager.PERMISSION_GRANTED) { Shizuku.bindUserService(getShizukuArgs(context), connection) } else { context.popToast(R.string.permission_denied) + action(null) } Shizuku.removeRequestPermissionResultListener(::requestPermissionResultListener) } @@ -69,5 +70,6 @@ fun useShizuku(context: Context, action: (IBinder?) -> Unit) { } } catch (e: Exception) { e.printStackTrace() + action(null) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt index 5d0c1f4..7eac514 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt @@ -3,46 +3,60 @@ package com.bintianqi.owndroid import android.app.Activity import android.os.Bundle import android.util.Log -import com.bintianqi.owndroid.dpm.UserOperationType -import com.bintianqi.owndroid.dpm.doUserOperationWithContext +import com.bintianqi.owndroid.feature.users.UserOperationType +import com.bintianqi.owndroid.utils.doUserOperationWithContext +import com.bintianqi.owndroid.utils.MyShortcut +import com.bintianqi.owndroid.utils.ShortcutUtils +import com.bintianqi.owndroid.utils.showOperationResultToast class ShortcutsReceiverActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val myApp = application as MyApplication + val context = this + val sr = myApp.container.settingsRepo + val ph = myApp.container.privilegeHelper + val settings = sr.data.shortcut try { val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.") - val key = SP.shortcutKey val requestKey = intent?.getStringExtra("key") - if (action != null && key != null && requestKey == key) { - when (action) { - "LOCK" -> Privilege.DPM.lockNow() - "DISABLE_CAMERA" -> { - val state = Privilege.DPM.getCameraDisabled(Privilege.DAR) - Privilege.DPM.setCameraDisabled(Privilege.DAR, !state) - ShortcutUtils.setShortcut(this, MyShortcut.DisableCamera, state) - } - "MUTE" -> { - val state = Privilege.DPM.isMasterVolumeMuted(Privilege.DAR) - Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !state) - ShortcutUtils.setShortcut(this, MyShortcut.Mute, state) - } - "USER_RESTRICTION" -> { - val state = intent?.getBooleanExtra("state", false) - val id = intent?.getStringExtra("restriction") - if (state == null || id == null) return - if (state) { - Privilege.DPM.addUserRestriction(Privilege.DAR, id) - } else { - Privilege.DPM.clearUserRestriction(Privilege.DAR, id) + if (action != null && settings.enabled && requestKey == settings.key) { + ph.safeDpmCall { + when (action) { + "LOCK" -> dpm.lockNow() + "DISABLE_CAMERA" -> { + val state = dpm.getCameraDisabled(dar) + dpm.setCameraDisabled(dar, !state) + ShortcutUtils.setShortcut(context, sr, MyShortcut.DisableCamera, state) + } + + "MUTE" -> { + val state = dpm.isMasterVolumeMuted(dar) + dpm.setMasterVolumeMuted(dar, !state) + ShortcutUtils.setShortcut(context, sr, MyShortcut.Mute, state) + } + + "USER_RESTRICTION" -> { + val state = intent?.getBooleanExtra("state", false) + val id = intent?.getStringExtra("restriction") + if (state == null || id == null) return@safeDpmCall + if (state) { + dpm.addUserRestriction(dar, id) + } else { + dpm.clearUserRestriction(dar, id) + } + ShortcutUtils.updateUserRestrictionShortcut( + context, sr, id, !state, false + ) + } + + "USER_OPERATION" -> { + val typeName = intent.getStringExtra("operation") ?: return@safeDpmCall + val type = UserOperationType.valueOf(typeName) + val serial = intent.getIntExtra("serial", -1) + if (serial == -1) return@safeDpmCall + doUserOperationWithContext(context, ph.dpm, ph.dar, type, serial, false) } - ShortcutUtils.updateUserRestrictionShortcut(this, id, !state, false) - } - "USER_OPERATION" -> { - val typeName = intent.getStringExtra("operation") ?: return - val type = UserOperationType.valueOf(typeName) - val serial = intent.getIntExtra("serial", -1) - if (serial == -1) return - doUserOperationWithContext(this, type, serial, false) } } Log.d(TAG, "Received intent: $action") @@ -50,12 +64,13 @@ class ShortcutsReceiverActivity : Activity() { } else { showOperationResultToast(false) } - } catch(e: Exception) { + } catch (e: Exception) { e.printStackTrace() } finally { finish() } } + companion object { private const val TAG = "ShortcutsReceiver" } diff --git a/app/src/main/java/com/bintianqi/owndroid/activity/CheckPolicyComplianceActivity.kt b/app/src/main/java/com/bintianqi/owndroid/activity/CheckPolicyComplianceActivity.kt new file mode 100644 index 0000000..a19710c --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/activity/CheckPolicyComplianceActivity.kt @@ -0,0 +1,41 @@ +package com.bintianqi.owndroid.activity + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.theme.OwnDroidTheme + +class CheckPolicyComplianceActivity: ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + val myApp = application as MyApplication + setContent { + val theme by myApp.container.themeState.collectAsState() + OwnDroidTheme(theme) { + AlertDialog( + text = { + Text(stringResource(R.string.info_personal_apps_suspended)) + }, + confirmButton = { + TextButton(::finish) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { + finish() + } + ) + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt b/app/src/main/java/com/bintianqi/owndroid/activity/ManageSpaceActivity.kt similarity index 74% rename from app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt rename to app/src/main/java/com/bintianqi/owndroid/activity/ManageSpaceActivity.kt index db671b9..4600e96 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/activity/ManageSpaceActivity.kt @@ -1,10 +1,9 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.activity import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -16,20 +15,27 @@ import androidx.compose.ui.res.stringResource import androidx.core.content.edit import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.screen.AppLockDialog import com.bintianqi.owndroid.ui.theme.OwnDroidTheme +import com.bintianqi.owndroid.utils.showOperationResultToast import kotlin.system.exitProcess class ManageSpaceActivity: FragmentActivity() { + val myApp = application as MyApplication + val settingsRepo = myApp.container.settingsRepo override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - val vm by viewModels() setContent { - val theme by vm.theme.collectAsStateWithLifecycle() + val theme by myApp.container.themeState.collectAsStateWithLifecycle() OwnDroidTheme(theme) { - var appLockDialog by remember { mutableStateOf(!SP.lockPasswordHash.isNullOrEmpty()) } - if(appLockDialog) { - AppLockDialog({ appLockDialog = false }, ::finish) + var appLockDialog by remember { + mutableStateOf(settingsRepo.data.appLock.passwordHash.isNotEmpty()) + } + if (appLockDialog) { + AppLockDialog(settingsRepo.data.appLock, { appLockDialog = false }, ::finish) } else { AlertDialog( text = { @@ -57,6 +63,7 @@ class ManageSpaceActivity: FragmentActivity() { cacheDir.deleteRecursively() codeCacheDir.deleteRecursively() if(Build.VERSION.SDK_INT >= 24) { + dataDir.resolve("databases").deleteRecursively() dataDir.resolve("shared_prefs").deleteRecursively() } else { val sharedPref = applicationContext.getSharedPreferences("data", MODE_PRIVATE) @@ -66,4 +73,4 @@ class ManageSpaceActivity: FragmentActivity() { finish() exitProcess(0) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bintianqi/owndroid/activity/ProvisioningActivity.kt b/app/src/main/java/com/bintianqi/owndroid/activity/ProvisioningActivity.kt new file mode 100644 index 0000000..92105fa --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/activity/ProvisioningActivity.kt @@ -0,0 +1,32 @@ +package com.bintianqi.owndroid.activity + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.feature.provisioning.ProvisioningScreen +import com.bintianqi.owndroid.feature.provisioning.ProvisioningViewModel +import com.bintianqi.owndroid.ui.theme.OwnDroidTheme + +@RequiresApi(29) +class ProvisioningActivity: ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val myApp = application as MyApplication + val vm by viewModels() + vm.params = vm.getParamsFromIntent(intent) + setContent { + val theme by myApp.container.themeState.collectAsState() + OwnDroidTheme(theme) { + ProvisioningScreen(vm.params) { + setResult(RESULT_OK, vm.buildResultIntent(it)) + finish() + } + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/activity/ProvisioningPolicyComplianceActivity.kt b/app/src/main/java/com/bintianqi/owndroid/activity/ProvisioningPolicyComplianceActivity.kt new file mode 100644 index 0000000..e317298 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/activity/ProvisioningPolicyComplianceActivity.kt @@ -0,0 +1,15 @@ +package com.bintianqi.owndroid.activity + +import android.os.Bundle +import androidx.activity.ComponentActivity +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.utils.popToast + +class ProvisioningPolicyComplianceActivity: ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setResult(RESULT_OK) + popToast(R.string.app_name) + finish() + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt deleted file mode 100644 index 1f501dd..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ /dev/null @@ -1,1500 +0,0 @@ -package com.bintianqi.owndroid.dpm - -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.Intent -import android.net.Uri -import android.os.Build.VERSION -import android.os.Looper -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List -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.Clear -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -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 -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.AppInfo -import com.bintianqi.owndroid.AppInstallerActivity -import com.bintianqi.owndroid.BottomPadding -import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.MyViewModel -import com.bintianqi.owndroid.Privilege -import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.adaptiveInsets -import com.bintianqi.owndroid.parsePackageNames -import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem -import com.bintianqi.owndroid.ui.FunctionItem -import com.bintianqi.owndroid.ui.MyLazyScaffold -import com.bintianqi.owndroid.ui.MyScaffold -import com.bintianqi.owndroid.ui.MySmallTitleScaffold -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.coroutines.launch -import kotlinx.serialization.Serializable -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyListState - -val String.isValidPackageName - get() = Regex("""^(?:[a-zA-Z]\w*\.)+[a-zA-Z]\w*$""").matches(this) - -@Composable -fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp) - .animateItem(), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { - Image( - painter = rememberDrawablePainter(info.icon), contentDescription = null, - modifier = Modifier - .padding(start = 12.dp, end = 18.dp) - .size(30.dp) - ) - Column { - Text(info.label) - Text(info.name, Modifier.alpha(0.8F), style = typography.bodyMedium) - } - } - IconButton(onClear) { - Icon(Icons.Default.Clear, null) - } - } -} - -@Composable -fun 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(onChoosePackage) { - Icon(Icons.AutoMirrored.Default.List, null) - } - }, - isError = value.isNotEmpty() && !value.isValidPackageName, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) -} - -@Serializable object ApplicationsFeatures - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ApplicationsFeaturesScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit, onSwitchView: () -> Unit) { - val context = LocalContext.current - val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - Scaffold( - Modifier.nestedScroll(sb.nestedScrollConnection), - topBar = { - LargeTopAppBar( - { Text(stringResource(R.string.applications)) }, - navigationIcon = { NavIcon(onNavigateUp) }, - actions = { - IconButton(onSwitchView) { - Icon(painterResource(R.drawable.android_fill0), null) - } - }, - scrollBehavior = sb - ) - }, - contentWindowInsets = adaptiveInsets() - ) { paddingValues -> - Column( - Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(bottom = 80.dp) - ) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - if(VERSION.SDK_INT >= 24) FunctionItem(R.string.suspend, icon = R.drawable.block_fill0) { onNavigate(Suspend) } - FunctionItem(R.string.hide, icon = R.drawable.visibility_off_fill0) { onNavigate(Hide) } - FunctionItem(R.string.block_uninstall, icon = R.drawable.delete_forever_fill0) { onNavigate(BlockUninstall) } - if(VERSION.SDK_INT >= 30 && (privilege.device || (VERSION.SDK_INT >= 33 && privilege.profile))) { - FunctionItem(R.string.disable_user_control, icon = R.drawable.do_not_touch_fill0) { onNavigate(DisableUserControl) } - } - FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) { onNavigate(PermissionsManager()) } - if(VERSION.SDK_INT >= 28) { - FunctionItem(R.string.disable_metered_data, icon = R.drawable.money_off_fill0) { onNavigate(DisableMeteredData) } - } - if(VERSION.SDK_INT >= 28) { - FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { onNavigate(ClearAppStorage) } - } - FunctionItem(R.string.install_app, icon = R.drawable.install_mobile_fill0) { - context.startActivity(Intent(context, AppInstallerActivity::class.java)) - } - FunctionItem(R.string.uninstall_app, icon = R.drawable.delete_fill0) { onNavigate(UninstallApp) } - if(VERSION.SDK_INT >= 28 && privilege.device) { - FunctionItem(R.string.keep_uninstalled_packages, icon = R.drawable.delete_fill0) { onNavigate(KeepUninstalledPackages) } - } - if (VERSION.SDK_INT >= 28 && (privilege.device || (privilege.profile && privilege.affiliated))) { - FunctionItem(R.string.install_existing_app, icon = R.drawable.install_mobile_fill0) { - onNavigate(InstallExistingApp) - } - } - if(VERSION.SDK_INT >= 30 && privilege.work) { - FunctionItem(R.string.cross_profile_apps, icon = R.drawable.work_fill0) { onNavigate(CrossProfilePackages) } - } - if(privilege.work) { - FunctionItem(R.string.cross_profile_widget, icon = R.drawable.widgets_fill0) { onNavigate(CrossProfileWidgetProviders) } - } - if(VERSION.SDK_INT >= 34 && privilege.device) { - FunctionItem(R.string.credential_manager_policy, icon = R.drawable.license_fill0) { onNavigate(CredentialManagerPolicy) } - } - FunctionItem(R.string.permitted_accessibility_services, icon = R.drawable.settings_accessibility_fill0) { - onNavigate(PermittedAccessibilityServices) - } - FunctionItem(R.string.permitted_ime, icon = R.drawable.keyboard_fill0) { onNavigate(PermittedInputMethods) } - FunctionItem(R.string.enable_system_app, icon = R.drawable.enable_fill0) { onNavigate(EnableSystemApp) } - if(VERSION.SDK_INT >= 34 && (privilege.device || privilege.work)) { - FunctionItem(R.string.set_default_dialer, icon = R.drawable.call_fill0) { onNavigate(SetDefaultDialer) } - } - } - } -} - -@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, vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit -) { - val packageName = param.packageName - val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by rememberSaveable { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall - val info = vm.getAppInfo(packageName) - val status by vm.appStatus.collectAsStateWithLifecycle() - val appRestrictions by vm.appRestrictions.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - vm.getAppStatus(packageName) - vm.getAppRestrictions(packageName) - } - MySmallTitleScaffold(R.string.place_holder, onNavigateUp, 0.dp) { - Column(Modifier - .align(Alignment.CenterHorizontally) - .padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { - 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, state = status.suspend, - onCheckedChange = { vm.adSetPackageSuspended(packageName, it) } - ) - SwitchItem( - R.string.hide, icon = R.drawable.visibility_off_fill0, - state = status.hide, - onCheckedChange = { vm.adSetPackageHidden(packageName, it) } - ) - SwitchItem( - R.string.block_uninstall, icon = R.drawable.delete_forever_fill0, - 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, - 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, - 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, - state = status.keepUninstalled, - onCheckedChange = { vm.adSetPackageKu(packageName, it) } - ) - if (appRestrictions.isNotEmpty()) { - FunctionItem(R.string.managed_configuration, icon = R.drawable.description_fill0) { - onNavigate(ManagedConfiguration(packageName)) - } - } - if(VERSION.SDK_INT >= 28) FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { dialog = 1 } - FunctionItem(R.string.uninstall, icon = R.drawable.delete_fill0) { dialog = 2 } - Spacer(Modifier.height(BottomPadding)) - } - if(dialog == 1 && VERSION.SDK_INT >= 28) - ClearAppStorageDialog(packageName, vm::clearAppData) { dialog = 0 } - if(dialog == 2) UninstallAppDialog(packageName, vm::uninstallPackage) { - dialog = 0 - if (it) onNavigateUp() - } -} - -@Serializable object Suspend - -@Serializable object Hide - -@Serializable object BlockUninstall - -@Serializable object DisableUserControl - -@Serializable data class PermissionsManager(val packageName: String? = null) - -@Composable -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 privilege by Privilege.status.collectAsStateWithLifecycle() - var packageName by rememberSaveable { mutableStateOf(packageNameParam ?: "") } - var selectedPermission by rememberSaveable { mutableIntStateOf(-1) } - val permissions by packagePermissions.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - packageName = chosenPackage.receive() - } - LaunchedEffect(packageName) { - getPackagePermissions(packageName) - } - MyLazyScaffold(R.string.permissions, onNavigateUp) { - item { - if(packageNameParam == null) { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { packageName = it } - Spacer(Modifier.padding(vertical = 4.dp)) - } - } - itemsIndexed(runtimePermissions, { _, it -> it.id }) { index, it -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { - selectedPermission = index - } - .padding(8.dp) - ) { - Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp)) - Column { - 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 - else -> R.string.unknown - } - Text(stringResource(it.label)) - Text(stringResource(state), Modifier.alpha(0.7F), style = typography.bodyMedium) - } - } - } - item { - Spacer(Modifier.height(BottomPadding)) - } - } - if(selectedPermission != -1) { - val permission = runtimePermissions[selectedPermission] - fun changeState(state: Int) { - val result = setPackagePermission(packageName, permission.id, state) - if (result) selectedPermission = -1 - } - @Composable - fun GrantPermissionItem(label: Int, status: Int) { - val selected = permissions[permission.id] == status - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(if (selected) colorScheme.primaryContainer else Color.Transparent) - .clickable { changeState(status) } - .padding(vertical = 16.dp, horizontal = 12.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically, - ) { - Text(stringResource(label), color = if(selected) colorScheme.primary else Color.Unspecified) - if(selected) Icon(Icons.Outlined.CheckCircle, null, tint = colorScheme.primary) - } - } - AlertDialog( - onDismissRequest = { selectedPermission = -1 }, - confirmButton = { TextButton({ selectedPermission = -1 }) { Text(stringResource(R.string.cancel)) } }, - title = { Text(stringResource(permission.label)) }, - text = { - Column { - Text(permission.id) - Spacer(Modifier.padding(vertical = 4.dp)) - if(!(VERSION.SDK_INT >= 31 && permission.profileOwnerRestricted && privilege.profile)) { - GrantPermissionItem(R.string.granted, PERMISSION_GRANT_STATE_GRANTED) - } - GrantPermissionItem(R.string.denied, PERMISSION_GRANT_STATE_DENIED) - GrantPermissionItem(R.string.default_stringres, PERMISSION_GRANT_STATE_DEFAULT) - } - } - ) - } -} - -@Serializable object DisableMeteredData - -@Serializable object ClearAppStorage - -@RequiresApi(28) -@Composable -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, onChoosePackage, - Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { dialog = true }, - Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.clear)) - } - } - if(dialog) ClearAppStorageDialog(packageName, onClear) { dialog = false } -} - -@RequiresApi(28) -@Composable -private fun ClearAppStorageDialog( - packageName: String, onClear: (String, (Boolean) -> Unit) -> Unit, onClose: () -> Unit -) { - val context = LocalContext.current - var clearing by rememberSaveable { mutableStateOf(false) } - AlertDialog( - title = { Text(stringResource(R.string.clear_app_storage)) }, - text = { - if (clearing) LinearProgressIndicator(Modifier.fillMaxWidth()) - else Text(stringResource(R.string.clear_app_storage_confirmation)) - }, - confirmButton = { - TextButton( - { - clearing = true - onClear(packageName) { - Looper.prepare() - context.showOperationResultToast(it) - onClose() - } - }, - enabled = !clearing, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton(onClose, enabled = !clearing) { Text(stringResource(R.string.cancel)) } - }, - onDismissRequest = { - if (!clearing) onClose() - }, - properties = DialogProperties(false, false) - ) -} - -@Serializable object UninstallApp - -@Composable -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, onChoosePackage, - Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { dialog = true }, - Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.uninstall)) - } - } - if(dialog) UninstallAppDialog(packageName, onUninstall) { - packageName = "" - dialog = false - } -} - -@Composable -private fun UninstallAppDialog( - packageName: String, onUninstall: (String, (String?) -> Unit) -> Unit, - onClose: (Boolean) -> Unit -) { - var uninstalling by rememberSaveable { mutableStateOf(false) } - var errorMessage by rememberSaveable { mutableStateOf(null) } - AlertDialog( - title = { Text(stringResource(R.string.uninstall)) }, - text = { - if(errorMessage != null) Text(errorMessage!!) - if(uninstalling) LinearProgressIndicator(Modifier.fillMaxWidth()) - }, - confirmButton = { - TextButton( - { - if (errorMessage == null) { - uninstalling = true - onUninstall(packageName) { - uninstalling = false - if (it == null) onClose(true) else errorMessage = it - } - } else { - onClose(false) - } - }, - enabled = !uninstalling - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - if (errorMessage == null) TextButton({ - onClose(false) - }, enabled = !uninstalling) { Text(stringResource(R.string.cancel)) } - }, - onDismissRequest = { onClose(false) }, - properties = DialogProperties(false, false) - ) -} - -@Serializable object KeepUninstalledPackages - -@Serializable object InstallExistingApp - -@RequiresApi(28) -@Composable -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) { - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(vertical = 8.dp)) { packageName = it } - Button( - { - context.showOperationResultToast(onInstall(packageName)) - }, - Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.install)) - } - Notes(R.string.info_install_existing_app) - } -} - -@Serializable object CrossProfilePackages - -@Serializable object CrossProfileWidgetProviders - -@Serializable object CredentialManagerPolicy - -@RequiresApi(34) -@Composable -fun CredentialManagerPolicyScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, - cmPackages: MutableStateFlow>, getCmPolicy: () -> Int, - setCmPackage: (List, Boolean) -> Unit, setCmPolicy: (Int) -> Unit, - onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var policy by rememberSaveable { mutableIntStateOf(getCmPolicy()) } - val packages by cmPackages.collectAsStateWithLifecycle() - var input by rememberSaveable { mutableStateOf("") } - val inputPackages = parsePackageNames(input) - LaunchedEffect(Unit) { - input = chosenPackage.receive() - } - MyLazyScaffold(R.string.credential_manager_policy, onNavigateUp) { - item { - mapOf( - -1 to R.string.none, - PackagePolicy.PACKAGE_POLICY_BLOCKLIST to R.string.blacklist, - 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, policy == key) { policy = key } - } - Spacer(Modifier.padding(vertical = 4.dp)) - } - if (policy != -1) items(packages, { it.name }) { - ApplicationItem(it) { setCmPackage(listOf(it.name), false) } - } - item { - Column(Modifier.padding(horizontal = HorizontalPadding)) { - if (policy != -1) { - PackageNameTextField(input, onChoosePackage, - Modifier.padding(vertical = 8.dp)) { input = it } - Button( - { - setCmPackage(inputPackages, true) - input = "" - }, - Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.add)) - } - } - Button( - { - setCmPolicy(policy) - context.showOperationResultToast(true) - }, - Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.apply)) - } - Spacer(Modifier.height(BottomPadding)) - } - } - } -} - -@Serializable object PermittedAccessibilityServices - -@Serializable object PermittedInputMethods - -@Composable -fun PermittedAsAndImPackages( - title: Int, note: Int, chosenPackage: Channel, onChoosePackage: () -> Unit, - packagesState: MutableStateFlow>, getPackages: () -> Boolean, - setPackage: (List, Boolean) -> Unit, setPolicy: (Boolean) -> Boolean, - onNavigateUp: () -> Unit -) { - val context = LocalContext.current - val packages by packagesState.collectAsStateWithLifecycle() - var input by rememberSaveable { mutableStateOf("") } - val inputPackages = parsePackageNames(input) - var allowAll by rememberSaveable { mutableStateOf(getPackages()) } - LaunchedEffect(Unit) { - input = chosenPackage.receive() - } - MyLazyScaffold(title, onNavigateUp) { - item { - SwitchItem(R.string.allow_all, state = allowAll, onCheckedChange = { allowAll = it }) - } - if (!allowAll) items(packages, { it.name }) { - ApplicationItem(it) { setPackage(listOf(it.name), false) } - } - item { - if (!allowAll) { - PackageNameTextField(input, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { input = it } - Button( - { - setPackage(inputPackages, true) - input = "" - }, - Modifier - .fillMaxWidth() - .padding(horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.add)) - } - } - Button( - { - context.showOperationResultToast(setPolicy(allowAll)) - }, - Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .padding(horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } - Spacer(Modifier.height(10.dp)) - Notes(note, HorizontalPadding) - Spacer(Modifier.height(BottomPadding)) - } - } -} - -@Serializable object EnableSystemApp - -@Composable -fun EnableSystemAppScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, - onEnable: (String) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var packageName by rememberSaveable { mutableStateOf("") } - LaunchedEffect(Unit) { - packageName = chosenPackage.receive() - } - MyScaffold(R.string.enable_system_app, onNavigateUp) { - Spacer(Modifier.padding(vertical = 4.dp)) - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(bottom = 8.dp)) { packageName = it } - Button( - { - onEnable(packageName) - packageName = "" - context.showOperationResultToast(true) - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.enable)) - } - Notes(R.string.info_enable_system_app) - } -} - -@Serializable object SetDefaultDialer - -@RequiresApi(34) -@Composable -fun SetDefaultDialerScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, - onSet: (String) -> Unit, onNavigateUp: () -> Unit -) { - var packageName by rememberSaveable { mutableStateOf("") } - LaunchedEffect(Unit) { - packageName = chosenPackage.receive() - } - MyScaffold(R.string.set_default_dialer, onNavigateUp) { - Spacer(Modifier.padding(vertical = 4.dp)) - PackageNameTextField(packageName, onChoosePackage, - Modifier.padding(bottom = 8.dp)) { packageName = it } - Button( - { - onSet(packageName) - }, - Modifier.fillMaxWidth(), - packageName.isValidPackageName - ) { - Text(stringResource(R.string.set)) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PackageFunctionScreen( - title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, - onSet: (List, Boolean) -> Unit, onNavigateUp: () -> Unit, - chosenPackage: Channel, onChoosePackage: () -> Unit, - navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null -) { - val context = LocalContext.current - val groups by appGroups.collectAsStateWithLifecycle() - val packages by packagesState.collectAsStateWithLifecycle() - var input by rememberSaveable { mutableStateOf("") } - val inputPackages = parsePackageNames(input) - var dialog by remember { mutableStateOf(false) } - var selectedGroup by remember { mutableStateOf(null) } - val snackbar = remember { SnackbarHostState() } - val coroutine = rememberCoroutineScope() - LaunchedEffect(Unit) { - onGet() - input = chosenPackage.receive() - } - Scaffold( - topBar = { - TopAppBar( - { Text(stringResource(title)) }, - navigationIcon = { NavIcon(onNavigateUp) }, - actions = { - var expand by remember { mutableStateOf(false) } - Box { - IconButton({ - expand = true - }) { - Icon(Icons.Default.MoreVert, null) - } - DropdownMenu(expand, { expand = false }) { - groups.forEach { - DropdownMenuItem( - { Text("(${it.apps.size}) ${it.name}") }, - { - selectedGroup = it - dialog = true - expand = false - } - ) - } - if (groups.isNotEmpty()) HorizontalDivider() - DropdownMenuItem( - { Text(stringResource(R.string.manage_app_groups)) }, - { - navigateToGroups() - expand = false - } - ) - } - } - } - ) - }, - snackbarHost = { - SnackbarHost(snackbar) - } - ) { paddingValues -> - LazyColumn(Modifier.padding(paddingValues)) { - items(packages, { it.name }) { - ApplicationItem(it) { - onSet(listOf(it.name), false) - coroutine.launch { - val result = snackbar.showSnackbar( - context.getString(R.string.package_removed, it.name), - context.getString(R.string.undo), - true, SnackbarDuration.Short - ) - if (result == SnackbarResult.ActionPerformed) { - onSet(listOf(it.name), true) - } - } - } - } - item { - PackageNameTextField(input, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { input = it } - Button( - { - onSet(inputPackages, true) - input = "" - }, - Modifier - .fillMaxWidth() - .padding(horizontal = HorizontalPadding) - .padding(bottom = 10.dp), - packages.none { it.name in inputPackages } - ) { - Text(stringResource(R.string.add)) - } - if (notes != null) Notes(notes, HorizontalPadding) - Spacer(Modifier.height(BottomPadding)) - } - } - } - if (dialog) AlertDialog( - text = { - Column { - Text(selectedGroup!!.name, style = typography.titleLarge) - Spacer(Modifier.height(6.dp)) - Button({ - onSet(selectedGroup!!.apps, true) - dialog = false - }) { - Text(stringResource(R.string.add_to_list)) - } - Button({ - onSet(selectedGroup!!.apps, false) - dialog = false - }) { - Text(stringResource(R.string.remove_from_list)) - } - } - }, - confirmButton = { - TextButton({ dialog = false }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { dialog = false } - ) -} - -@Serializable -open class BasicAppGroup(open val name: String, open val apps: List) - -class AppGroup( - val id: Int, override val name: String, override val apps: List -) : BasicAppGroup(name, apps) - -@Serializable object ManageAppGroups - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ManageAppGroupsScreen( - appGroups: StateFlow>, exportData: (Uri) -> Unit, importData: (Uri) -> Unit, - navigateToEditScreen: (Int?, String, List) -> Unit, navigateUp: () -> Unit -) { - val groups by appGroups.collectAsStateWithLifecycle() - val exportLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/json") - ) { - if (it != null) exportData(it) - } - val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { - if (it != null) importData(it) - } - Scaffold( - topBar = { - TopAppBar( - { Text(stringResource(R.string.app_group)) }, - navigationIcon = { NavIcon(navigateUp) }, - actions = { - var dropdown by remember { mutableStateOf(false) } - Box { - IconButton({ - dropdown = true - }) { - Icon(Icons.Default.MoreVert, null) - } - DropdownMenu(dropdown, { dropdown = false }) { - DropdownMenuItem( - { Text(stringResource(R.string.export)) }, - { - exportLauncher.launch("owndroid_app_groups") - dropdown = false - }, - leadingIcon = { - Icon(painterResource(R.drawable.file_export_fill0), null) - } - ) - DropdownMenuItem( - { Text(stringResource(R.string.import_str)) }, - { - importLauncher.launch(arrayOf("application/json")) - dropdown = false - }, - leadingIcon = { - Icon(painterResource(R.drawable.file_open_fill0), null) - } - ) - } - } - - } - ) - }, - floatingActionButton = { - FloatingActionButton({ - navigateToEditScreen(null, "", emptyList()) - }) { - Icon(Icons.Default.Add, null) - } - } - ) { paddingValues -> - LazyColumn(Modifier.padding(paddingValues)) { - items(groups, { it.id }) { - Column( - Modifier - .fillMaxWidth() - .clickable { - navigateToEditScreen(it.id, it.name, it.apps) - } - .padding(HorizontalPadding, 8.dp) - ) { - Text(it.name) - Text( - it.apps.size.toString() + " apps", Modifier.alpha(0.7F), - style = typography.bodyMedium - ) - } - } - } - } -} - -@Serializable class EditAppGroup(val id: Int?, val name: String, val apps: List) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditAppGroupScreen( - params: EditAppGroup, getAppInfo: (String) -> AppInfo, navigateUp: () -> Unit, - setGroup: (Int?, String, List) -> Unit, deleteGroup: (Int) -> Unit, - onChoosePackage: () -> Unit, chosenPackage: Channel -) { - var name by rememberSaveable { mutableStateOf(params.name) } - val list = rememberSaveable { mutableStateListOf(*params.apps.toTypedArray()) } - val appInfoList = list.map { getAppInfo(it) } - var input by rememberSaveable { mutableStateOf("") } - val inputPackages = parsePackageNames(input) - LaunchedEffect(Unit) { - input = 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(input, onChoosePackage, - Modifier.padding(HorizontalPadding, 8.dp)) { input = it } - Button( - { - list += inputPackages - input = "" - }, - Modifier - .fillMaxWidth() - .padding(horizontal = HorizontalPadding) - .padding(bottom = 10.dp), - inputPackages.all { it !in list } - ) { - Text(stringResource(R.string.add)) - } - Spacer(Modifier.height(BottomPadding)) - } - } - } -} - -@Serializable class ManagedConfiguration(val packageName: String) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ManagedConfigurationScreen( - params: ManagedConfiguration, appRestrictions: StateFlow>, - setRestriction: (String, AppRestriction) -> Unit, clearRestriction: (String) -> Unit, - navigateUp: () -> Unit -) { - val restrictions by appRestrictions.collectAsStateWithLifecycle() - var searchMode by remember { mutableStateOf(false) } - var searchKeyword by remember { mutableStateOf("") } - val displayRestrictions = if (searchKeyword.isEmpty()) { - restrictions - } else { - restrictions.filter { - it.key.contains(searchKeyword, true) || - it.title?.contains(searchKeyword, true) ?: true - } - } - var dialog by remember { mutableStateOf(null) } - var clearRestrictionDialog by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - { - if (searchMode) { - val fr = remember { FocusRequester() } - LaunchedEffect(Unit) { - fr.requestFocus() - } - OutlinedTextField( - searchKeyword, { searchKeyword = it }, - Modifier - .fillMaxWidth() - .focusRequester(fr), - textStyle = typography.bodyLarge, - placeholder = { Text(stringResource(R.string.search)) }, - trailingIcon = { - IconButton({ - searchKeyword = "" - searchMode = false - }) { - Icon(Icons.Outlined.Clear, null) - } - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - } else { - Text(stringResource(R.string.managed_configuration)) - } - }, - navigationIcon = { NavIcon(navigateUp) }, - actions = { - if (!searchMode) { - IconButton({ - searchMode = true - }) { - Icon(Icons.Outlined.Search, null) - } - IconButton({ - clearRestrictionDialog = true - }) { - Icon(Icons.Outlined.Delete, null) - } - } - } - ) - }, - contentWindowInsets = adaptiveInsets() - ) { paddingValues -> - LazyColumn(Modifier.padding(paddingValues)) { - items(displayRestrictions, { it.key }) { entry -> - Row( - Modifier - .fillMaxWidth() - .clickable { - dialog = entry - } - .padding(HorizontalPadding, 8.dp) - .animateItem(), - verticalAlignment = Alignment.CenterVertically - ) { - val iconId = when (entry) { - is AppRestriction.IntItem -> R.drawable.number_123_fill0 - is AppRestriction.StringItem -> R.drawable.abc_fill0 - is AppRestriction.BooleanItem -> R.drawable.toggle_off_fill0 - is AppRestriction.ChoiceItem -> R.drawable.radio_button_checked_fill0 - is AppRestriction.MultiSelectItem -> R.drawable.check_box_fill0 - } - Icon(painterResource(iconId), null, Modifier.padding(end = 12.dp)) - Column { - if (entry.title != null) { - Text(entry.title!!, style = typography.labelLarge) - Text(entry.key, style = typography.bodyMedium) - } else { - Text(entry.key, style = typography.labelLarge) - } - val text = when (entry) { - is AppRestriction.IntItem -> entry.value?.toString() - is AppRestriction.StringItem -> entry.value?.take(30) - is AppRestriction.BooleanItem -> entry.value?.toString() - is AppRestriction.ChoiceItem -> entry.value - is AppRestriction.MultiSelectItem -> entry.value?.joinToString(limit = 30) - } - Text( - text ?: "null", Modifier.alpha(0.7F), - fontStyle = if(text == null) FontStyle.Italic else null, - style = typography.bodyMedium - ) - } - } - } - item { - Spacer(Modifier.height(BottomPadding)) - } - } - } - if (dialog != null) Dialog({ - dialog = null - }) { - Surface( - color = AlertDialogDefaults.containerColor, - shape = AlertDialogDefaults.shape, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - ManagedConfigurationDialog(dialog!!) { - if (it != null) { - setRestriction(params.packageName, it) - } - dialog = null - } - } - } - if (clearRestrictionDialog) AlertDialog( - text = { - Text(stringResource(R.string.clear_configurations)) - }, - confirmButton = { - TextButton({ - clearRestriction(params.packageName) - clearRestrictionDialog = false - }) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton({ - clearRestrictionDialog = false - }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { - clearRestrictionDialog = false - } - ) -} - -@Composable -fun ManagedConfigurationDialog( - restriction: AppRestriction, setRestriction: (AppRestriction?) -> Unit -) { - var specifyValue by remember { mutableStateOf(false) } - var input by remember { mutableStateOf("") } - var inputState by remember { mutableStateOf(false) } - val multiSelectList = remember { - mutableStateListOf( - *(if (restriction is AppRestriction.MultiSelectItem) { - restriction.entryValues.mapIndexed { index, value -> - MultiSelectEntry( - value, restriction.entries.getOrNull(index), - restriction.value?.contains(value) ?: false - ) - }.sortedBy { entry -> - val index = restriction.value?.indexOf(entry.value) - if (index == null || index == -1) Int.MAX_VALUE else index - } - } else emptyList()).toTypedArray() - ) - } - LaunchedEffect(Unit) { - when (restriction) { - is AppRestriction.IntItem -> restriction.value?.let { - input = it.toString() - specifyValue = true - } - is AppRestriction.StringItem -> restriction.value?.let { - input = it - specifyValue = true - } - is AppRestriction.BooleanItem -> restriction.value?.let { - inputState = it - specifyValue = true - } - is AppRestriction.ChoiceItem -> restriction.value?.let { - input = it - specifyValue = true - } - is AppRestriction.MultiSelectItem -> restriction.value?.let { - specifyValue = true - } - } - } - val listState = rememberLazyListState() - val reorderableListState = rememberReorderableLazyListState(listState) { from, to -> - // `-1` because there's an `item` before items - multiSelectList.add(from.index - 1, multiSelectList.removeAt(to.index - 1)) - } - LazyColumn(Modifier.padding(12.dp), listState) { - item { - SelectionContainer { - Column { - restriction.title?.let { - Text(it, style = typography.titleLarge) - } - Text(restriction.key, Modifier.padding(vertical = 4.dp), style = typography.labelLarge) - Spacer(Modifier.height(4.dp)) - restriction.description?.let { - Text(it, Modifier.alpha(0.8F), style = typography.bodyMedium) - } - Spacer(Modifier.height(8.dp)) - } - } - Row( - Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Text(stringResource(R.string.specify_value)) - Switch(specifyValue, { specifyValue = it }) - } - } - if (specifyValue) when (restriction) { - is AppRestriction.IntItem -> item { - OutlinedTextField( - input, { input = it }, Modifier.fillMaxWidth(), - isError = input.toIntOrNull() == null - ) - } - is AppRestriction.StringItem -> item { - OutlinedTextField( - input, { input = it }, Modifier.fillMaxWidth() - ) - } - is AppRestriction.BooleanItem -> item { - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - SegmentedButton( - inputState, { inputState = true }, - SegmentedButtonDefaults.itemShape(0, 2) - ) { - Text("true") - } - SegmentedButton( - !inputState, { inputState = false }, - SegmentedButtonDefaults.itemShape(1, 2) - ) { - Text("false") - } - } - } - is AppRestriction.ChoiceItem -> itemsIndexed(restriction.entryValues) { index, value -> - val label = restriction.entries.getOrNull(index) - Row( - Modifier - .fillMaxWidth() - .clickable { - input = value - } - .padding(8.dp, 4.dp) - ) { - RadioButton(input == value, { input = value }) - Spacer(Modifier.width(8.dp)) - if (label == null) { - Text(value) - } else { - Column { - Text(label) - Text(value, Modifier.alpha(0.7F), style = typography.bodyMedium) - } - } - } - } - is AppRestriction.MultiSelectItem -> itemsIndexed( - multiSelectList, { _, v -> v.value } - ) { index, entry -> - ReorderableItem(reorderableListState, entry.value) { - Row( - Modifier - .fillMaxWidth() - .clickable { - val old = multiSelectList[index] - multiSelectList[index] = old.copy(selected = !old.selected) - } - .padding(8.dp, 4.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { - Checkbox(entry.selected, null) - Spacer(Modifier.width(8.dp)) - if (entry.title == null) { - Text(entry.value) - } else { - Column { - Text(entry.title) - Text(entry.value, Modifier.alpha(0.7F), style = typography.bodyMedium) - } - } - } - Icon( - painterResource(R.drawable.drag_indicator_fill0), null, - Modifier.draggableHandle() - ) - } - } - } - } - item { - Row(Modifier - .fillMaxWidth() - .padding(top = 4.dp), Arrangement.End) { - TextButton({ - setRestriction(null) - }, Modifier.padding(end = 4.dp)) { - Text(stringResource(R.string.cancel)) - } - TextButton({ - val newRestriction = when (restriction) { - is AppRestriction.IntItem -> restriction.copy( - value = if (specifyValue) input.toIntOrNull() else null - ) - is AppRestriction.StringItem -> restriction.copy( - value = if (specifyValue) input else null - ) - is AppRestriction.BooleanItem -> restriction.copy( - value = if (specifyValue) inputState else null - ) - is AppRestriction.ChoiceItem -> restriction.copy( - value = if (specifyValue) input else null - ) - is AppRestriction.MultiSelectItem -> restriction.copy( - value = if (specifyValue) - multiSelectList.filter { it.selected } - .map { it.value }.toTypedArray() - else null - ) - } - setRestriction(newRestriction) - }) { - Text(stringResource(R.string.confirm)) - } - } - } - } -} - -sealed class AppRestriction( - open val key: String, open val title: String?, open val description: String? -) { - data class IntItem( - override val key: String, - override val title: String?, - override val description: String?, - var value: Int?, - ) : AppRestriction(key, title, description) - data class StringItem( - override val key: String, - override val title: String?, - override val description: String?, - var value: String? - ) : AppRestriction(key, title, description) - data class BooleanItem( - override val key: String, - override val title: String?, - override val description: String?, - var value: Boolean? - ) : AppRestriction(key, title, description) - data class ChoiceItem( - override val key: String, - override val title: String?, - override val description: String?, - val entries: Array, - val entryValues: Array, - var value: String? - ) : AppRestriction(key, title, description) - data class MultiSelectItem( - override val key: String, - override val title: String?, - override val description: String?, - val entries: Array, - val entryValues: Array, - var value: Array? - ) : AppRestriction(key, title, description) -} - -data class MultiSelectEntry(val value: String, val title: String?, val selected: Boolean) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt deleted file mode 100644 index b0932b6..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ /dev/null @@ -1,591 +0,0 @@ -package com.bintianqi.owndroid.dpm - -import android.Manifest -import android.annotation.SuppressLint -import android.app.admin.ConnectEvent -import android.app.admin.DevicePolicyManager -import android.app.admin.DnsEvent -import android.app.admin.IDevicePolicyManager -import android.app.admin.SecurityLog -import android.content.Context -import android.content.Intent -import android.content.pm.IPackageInstaller -import android.content.pm.PackageInstaller -import android.os.Build.VERSION -import android.os.UserHandle -import android.os.UserManager -import android.util.Log -import androidx.annotation.RequiresApi -import com.bintianqi.owndroid.MyApplication -import com.bintianqi.owndroid.NotificationType -import com.bintianqi.owndroid.NotificationUtils -import com.bintianqi.owndroid.Privilege -import com.bintianqi.owndroid.Privilege.DAR -import com.bintianqi.owndroid.Privilege.DPM -import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.SP -import com.bintianqi.owndroid.ShortcutUtils -import com.rosan.dhizuku.api.Dhizuku -import com.rosan.dhizuku.api.DhizukuBinderWrapper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject - -@SuppressLint("PrivateApi") -fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager? { - try { - val context = appContext.createPackageContext(Dhizuku.getOwnerComponent().packageName, Context.CONTEXT_IGNORE_SECURITY) - val manager = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - val field = manager.javaClass.getDeclaredField("mService") - field.isAccessible = true - val oldInterface = field[manager] as IDevicePolicyManager - if (oldInterface is DhizukuBinderWrapper) return manager - val oldBinder = oldInterface.asBinder() - val newBinder = Dhizuku.binderWrapper(oldBinder) - val newInterface = IDevicePolicyManager.Stub.asInterface(newBinder) - field[manager] = newInterface - return manager - } catch (e: Exception) { - e.printStackTrace() - dhizukuErrorStatus.value = 1 - } - return null -} - -@SuppressLint("PrivateApi") -private fun binderWrapperPackageInstaller(appContext: Context): PackageInstaller? { - try { - val context = appContext.createPackageContext(Dhizuku.getOwnerComponent().packageName, Context.CONTEXT_IGNORE_SECURITY) - val installer = context.packageManager.packageInstaller - val field = installer.javaClass.getDeclaredField("mInstaller") - field.isAccessible = true - val oldInterface = field[installer] as IPackageInstaller - if (oldInterface is DhizukuBinderWrapper) return installer - val oldBinder = oldInterface.asBinder() - val newBinder = Dhizuku.binderWrapper(oldBinder) - val newInterface = IPackageInstaller.Stub.asInterface(newBinder) - field[installer] = newInterface - return installer - } catch (_: Exception) { - dhizukuErrorStatus.value = 1 - } - return null -} - -fun Context.getPackageInstaller(): PackageInstaller { - if(SP.dhizuku) { - if (!dhizukuPermissionGranted()) { - dhizukuErrorStatus.value = 2 - return this.packageManager.packageInstaller - } - return binderWrapperPackageInstaller(this) ?: this.packageManager.packageInstaller - } else { - return this.packageManager.packageInstaller - } -} - -val dhizukuErrorStatus = MutableStateFlow(0) - -data class PermissionItem( - val id: String, - val label: Int, - val icon: Int, - val profileOwnerRestricted: Boolean = false, - val requiresApi: Int = 23 -) - -@Suppress("InlinedApi") -val runtimePermissions = listOf( - PermissionItem(Manifest.permission.POST_NOTIFICATIONS, R.string.permission_POST_NOTIFICATIONS, R.drawable.notifications_fill0, requiresApi = 33), - PermissionItem(Manifest.permission.READ_EXTERNAL_STORAGE, R.string.permission_READ_EXTERNAL_STORAGE, R.drawable.folder_fill0), - PermissionItem(Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_WRITE_EXTERNAL_STORAGE, R.drawable.folder_fill0), - PermissionItem(Manifest.permission.READ_MEDIA_AUDIO, R.string.permission_READ_MEDIA_AUDIO, R.drawable.music_note_fill0, requiresApi = 33), - PermissionItem(Manifest.permission.READ_MEDIA_VIDEO, R.string.permission_READ_MEDIA_VIDEO, R.drawable.movie_fill0, requiresApi = 33), - PermissionItem(Manifest.permission.READ_MEDIA_IMAGES, R.string.permission_READ_MEDIA_IMAGES, R.drawable.image_fill0, requiresApi = 33), - PermissionItem(Manifest.permission.CAMERA, R.string.permission_CAMERA, R.drawable.photo_camera_fill0, true), - PermissionItem(Manifest.permission.RECORD_AUDIO, R.string.permission_RECORD_AUDIO, R.drawable.mic_fill0, true), - PermissionItem(Manifest.permission.ACCESS_COARSE_LOCATION, R.string.permission_ACCESS_COARSE_LOCATION, R.drawable.location_on_fill0, true), - PermissionItem(Manifest.permission.ACCESS_FINE_LOCATION, R.string.permission_ACCESS_FINE_LOCATION, R.drawable.location_on_fill0, true), - PermissionItem(Manifest.permission.ACCESS_BACKGROUND_LOCATION, R.string.permission_ACCESS_BACKGROUND_LOCATION, R.drawable.location_on_fill0, true, 29), - PermissionItem(Manifest.permission.READ_CONTACTS, R.string.permission_READ_CONTACTS, R.drawable.contacts_fill0), - PermissionItem(Manifest.permission.WRITE_CONTACTS, R.string.permission_WRITE_CONTACTS, R.drawable.contacts_fill0), - PermissionItem(Manifest.permission.READ_CALENDAR, R.string.permission_READ_CALENDAR, R.drawable.calendar_month_fill0), - PermissionItem(Manifest.permission.WRITE_CALENDAR, R.string.permission_WRITE_CALENDAR, R.drawable.calendar_month_fill0), - PermissionItem(Manifest.permission.BLUETOOTH_CONNECT, R.string.permission_BLUETOOTH_CONNECT, R.drawable.bluetooth_fill0, requiresApi = 31), - PermissionItem(Manifest.permission.BLUETOOTH_SCAN, R.string.permission_BLUETOOTH_SCAN, R.drawable.bluetooth_searching_fill0, requiresApi = 31), - PermissionItem(Manifest.permission.BLUETOOTH_ADVERTISE, R.string.permission_BLUETOOTH_ADVERTISE, R.drawable.bluetooth_fill0, requiresApi = 31), - PermissionItem(Manifest.permission.NEARBY_WIFI_DEVICES, R.string.permission_NEARBY_WIFI_DEVICES, R.drawable.wifi_fill0, requiresApi = 33), - PermissionItem(Manifest.permission.CALL_PHONE, R.string.permission_CALL_PHONE, R.drawable.call_fill0), - PermissionItem(Manifest.permission.ANSWER_PHONE_CALLS, R.string.permission_ANSWER_PHONE_CALLS, R.drawable.call_fill0, requiresApi = 26), - PermissionItem(Manifest.permission.READ_PHONE_NUMBERS, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0, requiresApi = 26), - PermissionItem(Manifest.permission.READ_PHONE_STATE, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0), - PermissionItem(Manifest.permission.USE_SIP, R.string.permission_USE_SIP, R.drawable.call_fill0), - PermissionItem(Manifest.permission.UWB_RANGING, R.string.permission_UWB_RANGING, R.drawable.cell_tower_fill0, requiresApi = 31), - PermissionItem(Manifest.permission.READ_SMS, R.string.permission_READ_SMS, R.drawable.sms_fill0), - PermissionItem(Manifest.permission.RECEIVE_SMS, R.string.permission_RECEIVE_SMS, R.drawable.sms_fill0), - PermissionItem(Manifest.permission.SEND_SMS, R.string.permission_SEND_SMS, R.drawable.sms_fill0), - PermissionItem(Manifest.permission.READ_CALL_LOG, R.string.permission_READ_CALL_LOG, R.drawable.call_log_fill0), - PermissionItem(Manifest.permission.WRITE_CALL_LOG, R.string.permission_WRITE_CALL_LOG, R.drawable.call_log_fill0), - PermissionItem(Manifest.permission.RECEIVE_WAP_PUSH, R.string.permission_RECEIVE_WAP_PUSH, R.drawable.wifi_fill0), - PermissionItem(Manifest.permission.BODY_SENSORS, R.string.permission_BODY_SENSORS, R.drawable.sensors_fill0, true), - PermissionItem(Manifest.permission.BODY_SENSORS_BACKGROUND, R.string.permission_BODY_SENSORS_BACKGROUND, R.drawable.sensors_fill0, requiresApi = 33), - PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true, 29) -).filter { VERSION.SDK_INT >= it.requiresApi } - -@Serializable -class NetworkLog( - val id: Long?, @SerialName("package") val packageName: String, val time: Long, val type: String, - val host: String?, val count: Int?, val addresses: List?, - val address: String?, val port: Int? -) - -@RequiresApi(26) -fun retrieveNetworkLogs(app: MyApplication, token: Long) { - CoroutineScope(Dispatchers.IO).launch { - val logs = DPM.retrieveNetworkLogs(DAR, token)?.mapNotNull { - when (it) { - is DnsEvent -> NetworkLog( - if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, "dns", - it.hostname, it.totalResolvedAddressCount, - it.inetAddresses.mapNotNull { address -> address.hostAddress }, null, null - ) - is ConnectEvent -> NetworkLog( - if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, - "connect", null, null, null, it.inetAddress.hostAddress, it.port - ) - else -> null - } - } - if (logs.isNullOrEmpty()) return@launch - app.myRepo.writeNetworkLogs(logs) - NotificationUtils.sendBasicNotification( - app, NotificationType.NetworkLogsCollected, - app.getString(R.string.n_logs_in_total, logs.size) - ) - } -} - -@Serializable -class SecurityEvent( - val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: JsonObject? -) - -@Serializable -class SecurityEventWithData( - val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: SecurityEventData? -) - -@Serializable -sealed class SecurityEventData { - @Serializable - class AdbShellCmd(val command: String): SecurityEventData() - @Serializable - class AppProcessStart( - val name: String, - val time: Long, - val uid: Int, - val pid: Int, - val seinfo: String, - val hash: String - ): SecurityEventData() - @Serializable - class BackupServiceToggled( - val admin: String, - val user: Int, - val state: Int - ): SecurityEventData() - @Serializable - class BluetoothConnection( - val mac: String, - val successful: Int, - @SerialName("failure_reason") val failureReason: String - ): SecurityEventData() - @Serializable - class BluetoothDisconnection( - val mac: String, - val reason: String - ): SecurityEventData() - @Serializable - class CameraPolicySet( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val disabled: Int - ): SecurityEventData() - @Serializable - class CaInstalledRemoved( - val result: Int, - val subject: String, - val user: Int - ): SecurityEventData() - @Serializable - class CertValidationFailure(val reason: String): SecurityEventData() - @Serializable - class CryptoSelfTestCompleted(val result: Int): SecurityEventData() - @Serializable - class KeyguardDisabledFeaturesSet( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val mask: Int - ): SecurityEventData() - @Serializable - class KeyguardDismissAuthAttempt( - val result: Int, - val strength: Int - ): SecurityEventData() - @Serializable - class KeyGeneratedImportDestruction( - val result: Int, - val alias: String, - val uid: Int - ): SecurityEventData() - @Serializable - class KeyIntegrityViolation( - val alias: String, - val uid: Int - ): SecurityEventData() - @Serializable - class MaxPasswordAttemptsSet( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val value: Int - ): SecurityEventData() - @Serializable - class MaxScreenLockTimeoutSet( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val timeout: Long - ): SecurityEventData() - @Serializable - class MediaMountUnmount( - @SerialName("mount_point") val mountPoint: String, - val label: String - ): SecurityEventData() - @Serializable - class OsStartup( - @SerialName("verified_boot_state") val verifiedBootState: String, - @SerialName("dm_verity_mode") val dmVerityMode: String - ): SecurityEventData() - @Serializable - class PackageInstalledUninstalledUpdated( - val name: String, - val version: Long, - val user: Int - ): SecurityEventData() - @Serializable - class PasswordChanged( - val complexity: Int, - val user: Int - ): SecurityEventData() - @Serializable - class PasswordComplexityRequired( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val complexity: Int - ): SecurityEventData() - @Serializable - class PasswordComplexitySet( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val length: Int, - val quality: Int, - val letters: Int, - @SerialName("non_letters") val nonLetters: Int, - val digits: Int, - val uppercase: Int, - val lowercase: Int, - val symbols: Int - ): SecurityEventData() - @Serializable - class PasswordExpirationSet( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val expiration: Long - ): SecurityEventData() - @Serializable - class PasswordHistoryLengthSet( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - val length: Int - ): SecurityEventData() - @Serializable - class RemoteLock( - val admin: String, - @SerialName("admin_user") val adminUser: Int, - @SerialName("target_user") val targetUser: Int, - ): SecurityEventData() - @Serializable - class SyncRecvSendFile(val path: String): SecurityEventData() - @Serializable - class UserRestrictionAddedRemoved( - val admin: String, - val user: Int, - val restriction: String - ): SecurityEventData() - @Serializable - class WifiConnection( - val bssid: String, - val type: String, - @SerialName("failure_reason") val failureReason: String - ): SecurityEventData() - @Serializable - class WifiDisconnection( - val bssid: String, - val reason: String - ): SecurityEventData() -} - -fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? { - return when(tag) { - SecurityLog.TAG_ADB_SHELL_CMD -> SecurityEventData.AdbShellCmd(payload as String) - SecurityLog.TAG_ADB_SHELL_INTERACTIVE -> null - SecurityLog.TAG_APP_PROCESS_START -> { - val data = payload as Array<*> - SecurityEventData.AppProcessStart( - data[0] as String, data[1] as Long, data[2] as Int, data[3] as Int, - data[4] as String, data[5] as String - ) - } - SecurityLog.TAG_BACKUP_SERVICE_TOGGLED -> { - val data = payload as Array<*> - SecurityEventData.BackupServiceToggled(data[0] as String, data[1] as Int, data[2] as Int) - } - SecurityLog.TAG_BLUETOOTH_CONNECTION -> { - val data = payload as Array<*> - SecurityEventData.BluetoothConnection(data[0] as String, data[1] as Int, data[2] as String) - } - SecurityLog.TAG_BLUETOOTH_DISCONNECTION -> { - val data = payload as Array<*> - SecurityEventData.BluetoothDisconnection(data[0] as String, data[1] as String) - } - SecurityLog.TAG_CAMERA_POLICY_SET -> { - val data = payload as Array<*> - SecurityEventData.CameraPolicySet( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int - ) - } - SecurityLog.TAG_CERT_AUTHORITY_INSTALLED, SecurityLog.TAG_CERT_AUTHORITY_REMOVED -> { - val data = payload as Array<*> - SecurityEventData.CaInstalledRemoved(data[0] as Int, data[1] as String, data[2] as Int) - } - SecurityLog.TAG_CERT_VALIDATION_FAILURE -> - SecurityEventData.CertValidationFailure(payload as String) - SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED -> - SecurityEventData.CryptoSelfTestCompleted(payload as Int) - SecurityLog.TAG_KEYGUARD_DISABLED_FEATURES_SET -> { - val data = payload as Array<*> - SecurityEventData.KeyguardDisabledFeaturesSet( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int - ) - } - SecurityLog.TAG_KEYGUARD_DISMISSED -> null - SecurityLog.TAG_KEYGUARD_DISMISS_AUTH_ATTEMPT -> { - val data = payload as Array<*> - SecurityEventData.KeyguardDismissAuthAttempt(data[0] as Int, data[1] as Int) - } - SecurityLog.TAG_KEYGUARD_SECURED -> null - SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT, SecurityLog.TAG_KEY_DESTRUCTION -> { - val data = payload as Array<*> - SecurityEventData.KeyGeneratedImportDestruction( - data[0] as Int, data[1] as String, data[2] as Int - ) - } - SecurityLog.TAG_LOGGING_STARTED, SecurityLog.TAG_LOGGING_STOPPED -> null - SecurityLog.TAG_LOG_BUFFER_SIZE_CRITICAL -> null - SecurityLog.TAG_MAX_PASSWORD_ATTEMPTS_SET -> { - val data = payload as Array<*> - SecurityEventData.MaxPasswordAttemptsSet( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int - ) - } - SecurityLog.TAG_MAX_SCREEN_LOCK_TIMEOUT_SET -> { - val data = payload as Array<*> - SecurityEventData.MaxScreenLockTimeoutSet( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long - ) - } - SecurityLog.TAG_MEDIA_MOUNT, SecurityLog.TAG_MEDIA_UNMOUNT -> { - val data = payload as Array<*> - SecurityEventData.MediaMountUnmount(data[0] as String, data[1] as String) - } - SecurityLog.TAG_NFC_ENABLED, SecurityLog.TAG_NFC_DISABLED -> null - SecurityLog.TAG_OS_SHUTDOWN -> null - SecurityLog.TAG_OS_STARTUP -> { - val data = payload as Array<*> - SecurityEventData.OsStartup(data[0] as String, data[1] as String) - } - SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UPDATED, - SecurityLog.TAG_PACKAGE_UNINSTALLED -> { - val data = payload as Array<*> - SecurityEventData.PackageInstalledUninstalledUpdated( - data[0] as String, data[1] as Long, data[2] as Int - ) - } - SecurityLog.TAG_PASSWORD_CHANGED -> { - val data = payload as Array<*> - SecurityEventData.PasswordChanged(data[0] as Int, data[1] as Int) - } - SecurityLog.TAG_PASSWORD_COMPLEXITY_REQUIRED -> { - val data = payload as Array<*> - SecurityEventData.PasswordComplexityRequired( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int - ) - } - SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> { - val data = payload as Array<*> - SecurityEventData.PasswordComplexitySet( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int, data[4] as Int, - data[5] as Int, data[6] as Int, data[7] as Int, data[8] as Int, data[9] as Int, - data[10] as Int - ) - } - SecurityLog.TAG_PASSWORD_EXPIRATION_SET -> { - val data = payload as Array<*> - SecurityEventData.PasswordExpirationSet( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long - ) - } - SecurityLog.TAG_PASSWORD_HISTORY_LENGTH_SET -> { - val data = payload as Array<*> - SecurityEventData.PasswordHistoryLengthSet( - data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int - ) - } - SecurityLog.TAG_REMOTE_LOCK -> { - val data = payload as Array<*> - SecurityEventData.RemoteLock(data[0] as String, data[1] as Int, data[2] as Int) - } - SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE -> - SecurityEventData.SyncRecvSendFile(payload as String) - SecurityLog.TAG_USER_RESTRICTION_ADDED, SecurityLog.TAG_USER_RESTRICTION_REMOVED -> { - val data = payload as Array<*> - SecurityEventData.UserRestrictionAddedRemoved( - data[0] as String, data[1] as Int, data[2] as String - ) - } - SecurityLog.TAG_WIFI_CONNECTION -> { - val data = payload as Array<*> - SecurityEventData.WifiConnection(data[0] as String, data[1] as String, data[2] as String) - } - SecurityLog.TAG_WIFI_DISCONNECTION -> { - val data = payload as Array<*> - SecurityEventData.WifiDisconnection(data[0] as String, data[1] as String) - } - SecurityLog.TAG_WIPE_FAILURE -> null - else -> null - } -} - -@RequiresApi(24) -fun retrieveSecurityLogs(app: MyApplication) { - CoroutineScope(Dispatchers.IO).launch { - val logs = DPM.retrieveSecurityLogs(DAR) - if (logs.isNullOrEmpty()) return@launch - app.myRepo.writeSecurityLogs(logs) - NotificationUtils.sendBasicNotification( - app, NotificationType.SecurityLogsCollected, - app.getString(R.string.n_logs_in_total, logs.size) - ) - } -} - -fun setDefaultAffiliationID() { - if (VERSION.SDK_INT < 26) return - if(!SP.isDefaultAffiliationIdSet) { - try { - DPM.setAffiliationIds(DAR, setOf("OwnDroid_default_affiliation_id")) - SP.isDefaultAffiliationIdSet = true - Log.d("DPM", "Default affiliation id set") - } catch (e: Exception) { - e.printStackTrace() - } - } -} - -fun dhizukuPermissionGranted() = - try { - Dhizuku.isPermissionGranted() - } catch(_: Exception) { - false - } - -fun parsePackageInstallerMessage(context: Context, result: Intent): String { - val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) - val statusMessage = result.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - val otherPackageName = result.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) - return when(status) { - PackageInstaller.STATUS_FAILURE_BLOCKED -> - context.getString( - R.string.status_failure_blocked, - otherPackageName ?: context.getString(R.string.unknown) - ) - PackageInstaller.STATUS_FAILURE_ABORTED -> - context.getString(R.string.status_failure_aborted) - PackageInstaller.STATUS_FAILURE_INVALID -> - context.getString(R.string.status_failure_invalid) - PackageInstaller.STATUS_FAILURE_CONFLICT -> - context.getString(R.string.status_failure_conflict, otherPackageName ?: "???") - PackageInstaller.STATUS_FAILURE_STORAGE -> - context.getString(R.string.status_failure_storage) + - result.getStringExtra(PackageInstaller.EXTRA_STORAGE_PATH).let { if(it == null) "" else "\n$it" } - PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> - context.getString(R.string.status_failure_incompatible) - PackageInstaller.STATUS_FAILURE_TIMEOUT -> - context.getString(R.string.timeout) - else -> "" - } + statusMessage.let { if(it == null) "" else "\n$it" } -} - - -fun handlePrivilegeChange(context: Context) { - val privilege = Privilege.status.value - SP.dhizukuServer = false - SP.shortcuts = privilege.activated - if (privilege.activated) { - ShortcutUtils.setAllShortcuts(context, true) - if (!privilege.dhizuku) { - setDefaultAffiliationID() - } - } else { - SP.isDefaultAffiliationIdSet = false - ShortcutUtils.setAllShortcuts(context, false) - SP.apiKeyHash = "" - } -} - -fun doUserOperationWithContext( - context: Context, type: UserOperationType, id: Int, isUserId: Boolean -): Boolean { - val um = context.getSystemService(Context.USER_SERVICE) as UserManager - val handle = if (isUserId && VERSION.SDK_INT >= 24) { - UserHandle.getUserHandleForUid(id * 100000) - } else { - um.getUserForSerialNumber(id.toLong()) - } - if (handle == null) return false - return when (type) { - UserOperationType.Start -> { - if (VERSION.SDK_INT >= 28) - DPM.startUserInBackground(DAR, handle) == UserManager.USER_OPERATION_SUCCESS - else false - } - UserOperationType.Switch -> DPM.switchUser(DAR, handle) - UserOperationType.Stop -> { - if (VERSION.SDK_INT >= 28) - DPM.stopUser(DAR, handle) == UserManager.USER_OPERATION_SUCCESS - else false - } - UserOperationType.Delete -> DPM.removeUser(DAR, handle) - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt deleted file mode 100644 index 8723f40..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ /dev/null @@ -1,2238 +0,0 @@ -package com.bintianqi.owndroid.dpm - -import android.Manifest -import android.annotation.SuppressLint -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.WifiSsidPolicy -import android.app.usage.NetworkStats -import android.net.ConnectivityManager -import android.net.Uri -import android.net.wifi.WifiConfiguration -import android.os.Build.VERSION -import android.provider.Telephony -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.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -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.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.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.MenuAnchorType -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -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.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 -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.pointer.pointerInput -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.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.BottomPadding -import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.MyViewModel -import com.bintianqi.owndroid.Privilege -import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.clickableTextField -import com.bintianqi.owndroid.formatDate -import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.adaptiveInsets -import com.bintianqi.owndroid.popToast -import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.CircularProgressDialog -import com.bintianqi.owndroid.ui.ErrorDialog -import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem -import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem -import com.bintianqi.owndroid.ui.FunctionItem -import com.bintianqi.owndroid.ui.ListItem -import com.bintianqi.owndroid.ui.MyScaffold -import com.bintianqi.owndroid.ui.MySmallTitleScaffold -import com.bintianqi.owndroid.ui.NavIcon -import com.bintianqi.owndroid.ui.Notes -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.channels.Channel -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Serializable object Network - -@Composable -fun NetworkScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - MyScaffold(R.string.network, onNavigateUp, 0.dp) { - if(!privilege.dhizuku) FunctionItem(R.string.wifi, icon = R.drawable.wifi_fill0) { onNavigate(WiFi) } - if(VERSION.SDK_INT >= 30) { - FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { onNavigate(NetworkOptions) } - } - if (!privilege.dhizuku) - FunctionItem(R.string.network_stats, icon = R.drawable.query_stats_fill0) { onNavigate(QueryNetworkStats) } - if(VERSION.SDK_INT >= 29 && privilege.device) { - FunctionItem(R.string.private_dns, icon = R.drawable.dns_fill0) { onNavigate(PrivateDns) } - } - if(VERSION.SDK_INT >= 24) { - FunctionItem(R.string.always_on_vpn, icon = R.drawable.vpn_key_fill0) { onNavigate(AlwaysOnVpnPackage) } - } - if(privilege.device) { - FunctionItem(R.string.recommended_global_proxy, icon = R.drawable.vpn_key_fill0) { onNavigate(RecommendedGlobalProxy) } - } - 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) { - 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) } - } - if(VERSION.SDK_INT >= 28 && privilege.device) { - FunctionItem(R.string.override_apn, icon = R.drawable.cell_tower_fill0) { onNavigate(OverrideApn) } - } - } -} - -@Serializable object NetworkOptions - -@Composable -fun NetworkOptionsScreen( - getLanEnabled: () -> Boolean, setLanEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit -) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by rememberSaveable { mutableIntStateOf(0) } - MyScaffold(R.string.options, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { - var lanEnabled by rememberSaveable { mutableStateOf(getLanEnabled()) } - SwitchItem(R.string.lockdown_admin_configured_network, icon = R.drawable.wifi_password_fill0, - state = lanEnabled, - onCheckedChange = { - setLanEnabled(it) - lanEnabled = it - }, - onClickBlank = { dialog = 1 } - ) - } - } - if(dialog != 0) AlertDialog( - text = { Text(stringResource(R.string.info_lockdown_admin_configured_network)) }, - confirmButton = { - TextButton(onClick = { dialog = 0 }) { Text(stringResource(R.string.confirm)) } - }, - onDismissRequest = { dialog = 0 } - ) -} - -@Serializable object WiFi - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -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) } - tabIndex = pagerState.currentPage - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.wifi)) }, - navigationIcon = { NavIcon(onNavigateUp) }, - colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) - ) - }, - contentWindowInsets = adaptiveInsets() - ) { paddingValues -> - Column( - modifier = Modifier.fillMaxSize().padding(paddingValues) - ) { - TabRow(tabIndex) { - Tab( - tabIndex == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(stringResource(R.string.overview)) } - ) - Tab( - tabIndex == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(stringResource(R.string.saved_networks)) } - ) - Tab( - tabIndex == 2, { coroutine.launch { pagerState.animateScrollToPage(2) } }, - text = { Text(stringResource(R.string.add_network)) } - ) - } - HorizontalPager(state = pagerState, verticalAlignment = Alignment.Top) { page -> - @Suppress("NewApi") - when (page) { - 0 -> WifiOverviewScreen(vm::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) } - } - } - } - } - } -} - -@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( - configuredNetworks: StateFlow>, getConfiguredNetworks: () -> Unit, - enableNetwork: (Int) -> Boolean, disableNetwork: (Int) -> Boolean, - removeNetwork: (Int) -> Boolean, editNetwork: (Int) -> Unit -) { - val context = LocalContext.current - var dialog by rememberSaveable { mutableIntStateOf(-1) } - val list by configuredNetworks.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - getConfiguredNetworks() - } - 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 { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }, - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Icon( - Icons.Outlined.LocationOn, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(start = 8.dp, end = 4.dp)) - Text( - text = stringResource(R.string.request_location_permission_description), - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(8.dp) - ) - } - } - itemsIndexed(list) { index, network -> - Row( - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(12.dp, 4.dp) - ) { - Text(network.ssid) - IconButton({ dialog = index }) { - Icon(painterResource(R.drawable.more_horiz_fill0), null) - } - } - } - } - if (dialog != -1) AlertDialog( - text = { - val network = list[dialog] - Column { - Text(stringResource(R.string.network_id) + ": " + network.id.toString()) - Spacer(Modifier.height(4.dp)) - Text("SSID", style = MaterialTheme.typography.titleMedium) - SelectionContainer { - Text(network.ssid) - } - Spacer(Modifier.height(4.dp)) - if (network.bssid.isNotEmpty()) { - Text("BSSID", style = MaterialTheme.typography.titleMedium) - SelectionContainer { - Text(network.bssid) - } - Spacer(Modifier.height(4.dp)) - } - Text(stringResource(R.string.status), style = MaterialTheme.typography.titleMedium) - SelectionContainer { - Text(stringResource(network.status.text)) - } - Row( - Modifier.fillMaxWidth().padding(top = 8.dp), Arrangement.SpaceBetween - ) { - FilledTonalButton({ - 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)) - } - } - 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)) - } - } - } - } - }, - confirmButton = { - TextButton({ dialog = -1 }) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { dialog = -1 } - ) -} - -@Serializable -data class UpdateNetwork(val index: Int) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -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) - } - } -} - -@Composable -fun UnchangedMenuItem(onClick: () -> Unit) { - DropdownMenuItem({ Text(stringResource(R.string.unchanged)) }, onClick) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AddNetworkScreen( - wifiInfo: WifiInfo?, setNetwork: (WifiInfo) -> Boolean, onNavigateUp: () -> Unit -) { - val updating = wifiInfo != null - val context = LocalContext.current - val fm = LocalFocusManager.current - /** 0: None, 1:Status, 2:Security, 3:MAC randomization, 4:Static IP, 5:Proxy, 6:Hidden SSID */ - var menu by rememberSaveable { mutableIntStateOf(0) } - var status by rememberSaveable { mutableStateOf(WifiStatus.Enabled) } - var ssid by rememberSaveable { mutableStateOf("") } - var hiddenSsid by rememberSaveable { mutableStateOf(false) } - var security by rememberSaveable { mutableStateOf(WifiSecurity.Open) } - var password by rememberSaveable { mutableStateOf("") } - var macRandomization by rememberSaveable { mutableStateOf(WifiMacRandomization.None) } - var ipMode by rememberSaveable { mutableStateOf(IpMode.Dhcp) } - var ipAddress by rememberSaveable { mutableStateOf("") } - var gatewayAddress by rememberSaveable { mutableStateOf("") } - var dnsServers by rememberSaveable { mutableStateOf("") } - var proxyMode by rememberSaveable { mutableStateOf(ProxyMode.None) } - var httpProxyHost by rememberSaveable { mutableStateOf("") } - var httpProxyPort by rememberSaveable { mutableStateOf("") } - var httpProxyExclList by rememberSaveable { mutableStateOf("") } - LaunchedEffect(Unit) { - if (updating) { - hiddenSsid = null - security = null - macRandomization = null - ipMode = null - proxyMode = null - status = wifiInfo.status - ssid = wifiInfo.ssid - } - } - Column( - Modifier.verticalScroll(rememberScrollState()).padding(horizontal = HorizontalPadding) - ) { - Spacer(Modifier.height(4.dp)) - ExposedDropdownMenuBox( - menu == 1, { menu = if(it) 1 else 0 }, Modifier.padding(bottom = 8.dp) - ) { - OutlinedTextField( - stringResource(status.text), {}, - Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - readOnly = true, - label = { Text(stringResource(R.string.status)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == 1) }, - ) - ExposedDropdownMenu(menu == 1, { menu = 0 }) { - WifiStatus.entries.forEach { - DropdownMenuItem( - { Text(stringResource(it.text)) }, - { - status = it - menu = 0 - } - ) - } - } - } - OutlinedTextField( - ssid, { ssid = it }, Modifier.fillMaxWidth().padding(bottom = 8.dp), - label = { Text("SSID") } - ) - 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) } - ) - 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 - } - ) - } - } - 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) } - ) - 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( - 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(menu == 3, { menu = 0 }) { - if (updating) UnchangedMenuItem { macRandomization = null } - WifiMacRandomization.entries.forEach { - DropdownMenuItem( - { Text(stringResource(it.text)) }, - { - macRandomization = it - menu = 0 - } - ) - } - } - } - } - if (VERSION.SDK_INT >= 33) { - ExposedDropdownMenuBox( - menu == 4, { menu = if(it) 4 else 0 }, Modifier.padding(bottom = 8.dp) - ) { - OutlinedTextField( - 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(menu == 4, { menu = 0 }) { - if (updating) UnchangedMenuItem { ipMode = null } - IpMode.entries.forEach { - DropdownMenuItem( - { Text(stringResource(it.text)) }, - { - ipMode = it - menu = 0 - } - ) - } - } - } - AnimatedVisibility(ipMode == IpMode.Static) { - val gatewayFr = FocusRequester() - val dnsFr = FocusRequester() - Column { - OutlinedTextField( - value = ipAddress, onValueChange = { ipAddress = it }, - label = { Text(stringResource(R.string.ip_address)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - //keyboardActions = KeyboardActions { gatewayFr.requestFocus() }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) - ) - OutlinedTextField( - value = gatewayAddress, onValueChange = { gatewayAddress = it }, - 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) - ) - OutlinedTextField( - value = dnsServers, onValueChange = { dnsServers = it }, - label = { Text(stringResource(R.string.dns_servers)) }, minLines = 2, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() }, - modifier = Modifier.focusRequester(dnsFr).fillMaxWidth().padding(bottom = 4.dp) - ) - } - } - } - if(VERSION.SDK_INT >= 26) { - ExposedDropdownMenuBox( - menu == 5, { menu = if(it) 5 else 0 }, Modifier.padding(bottom = 8.dp) - ) { - OutlinedTextField( - 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(menu == 5, { menu = 0 }) { - if (updating) UnchangedMenuItem { proxyMode = null } - ProxyMode.entries.forEach { - DropdownMenuItem( - { Text(stringResource(it.text)) }, - { - proxyMode = it - menu = 0 - } - ) - } - } - } - AnimatedVisibility(proxyMode == ProxyMode.Http) { - val portFr = FocusRequester() - val exclListFr = FocusRequester() - Column { - OutlinedTextField( - httpProxyHost, { httpProxyHost = it }, - Modifier.fillMaxWidth().padding(bottom = 4.dp), - label = { Text(stringResource(R.string.host)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { portFr.requestFocus() } - ) - OutlinedTextField( - 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() } - ) - OutlinedTextField( - 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() }, - minLines = 2 - ) - } - } - } - Button( - onClick = { - val proxyConf = if (proxyMode == ProxyMode.Http) { - ProxyConf( - httpProxyHost, httpProxyPort.toInt(), - httpProxyExclList.lines().filter { it.isNotBlank() } - ) - } else null - val ipConf = if (ipMode == IpMode.Static) { - IpConf(ipAddress, gatewayAddress, dnsServers.lines().filter { it.isNotBlank() }) - } else null - val result = 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), - enabled = (proxyMode != ProxyMode.Http || - (httpProxyPort.toIntOrNull() != null && httpProxyHost.isNotBlank())) - ) { - Text(stringResource(if (updating) R.string.update else R.string.add)) - } - Spacer(Modifier.height(BottomPadding)) - } -} - -@Serializable object WifiSecurityLevel - -@RequiresApi(33) -@Composable -fun WifiSecurityLevelScreen( - getLevel: () -> Int, setLevel: (Int) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var level by rememberSaveable { mutableIntStateOf(getLevel()) } - MyScaffold(R.string.min_wifi_security_level, onNavigateUp, 0.dp) { - FullWidthRadioButtonItem(R.string.wifi_security_open, level == WIFI_SECURITY_OPEN) { level = WIFI_SECURITY_OPEN } - FullWidthRadioButtonItem("WEP, WPA(2)-PSK", level == WIFI_SECURITY_PERSONAL) { level = WIFI_SECURITY_PERSONAL } - 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 = { - setLevel(level) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_minimum_wifi_security_level, HorizontalPadding) - } -} - -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( - 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 type by rememberSaveable { mutableStateOf(SsidPolicyType.None) } - val list = rememberSaveable { mutableStateListOf() } - LaunchedEffect(Unit) { - getPolicy().let { - type = it.type - list.addAll(it.list) - } - } - SsidPolicyType.entries.forEach { - FullWidthRadioButtonItem(it.text, type == it) { type = it } - } - AnimatedVisibility(type != SsidPolicyType.None) { - var inputSsid by remember { mutableStateOf("") } - Column(Modifier.padding(horizontal = HorizontalPadding)) { - Column(modifier = Modifier.animateContentSize()) { - for(i in list) { - ListItem(i) { list -= i } - } - } - Spacer(Modifier.padding(vertical = 5.dp)) - OutlinedTextField( - inputSsid, { inputSsid = it }, Modifier.fillMaxWidth(), - label = { Text("SSID") }, - trailingIcon = { - IconButton( - onClick = { - list += inputSsid - inputSsid = "" - }, - enabled = inputSsid.isNotEmpty() - ) { - Icon(Icons.Default.Add, stringResource(R.string.add)) - } - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) - ) - } - } - Button( - onClick = { - focusMgr.clearFocus() - setPolicy(SsidPolicy(type, list)) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), - enabled = type == SsidPolicyType.None || list.isNotEmpty() - ) { - Text(stringResource(R.string.apply)) - } - } -} - -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") -enum class NetworkType(val type: Int, val text: Int) { - Mobile(ConnectivityManager.TYPE_MOBILE, R.string.mobile), - Wifi(ConnectivityManager.TYPE_WIFI, R.string.wifi), - Bluetooth(ConnectivityManager.TYPE_BLUETOOTH, R.string.bluetooth), - Ethernet(ConnectivityManager.TYPE_ETHERNET, R.string.ethernet), - Vpn(ConnectivityManager.TYPE_VPN, R.string.vpn), -} -enum class NetworkStatsTarget(val text: Int, val type: NetworkStatsType, val minApi: Int = 23) { - Device(R.string.device, NetworkStatsType.Summary), - User(R.string.user, NetworkStatsType.Summary), - Uid(R.string.uid, NetworkStatsType.Details), - UidTag(R.string.uid_tag, NetworkStatsType.Details, 24), - UidTagState(R.string.uid_tag_state, NetworkStatsType.Details, 28) -} -@Suppress("InlinedApi") -enum class NetworkStatsState(val id: Int, val text: Int) { - All(NetworkStats.Bucket.STATE_ALL, R.string.all), - Default(NetworkStats.Bucket.STATE_DEFAULT, R.string.default_str), - Foreground(NetworkStats.Bucket.STATE_FOREGROUND, R.string.foreground) -} -enum class NetworkStatsUID(val uid: Int, val text: Int) { - All(NetworkStats.Bucket.UID_ALL, R.string.all), - Removed(NetworkStats.Bucket.UID_REMOVED, R.string.uninstalled), - Tethering(NetworkStats.Bucket.UID_TETHERING, R.string.tethering) -} - -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) -@Composable -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() - fun getDefaultSummaryTarget(): NetworkStatsTarget { - return if (privilege.device) NetworkStatsTarget.Device else NetworkStatsTarget.User - } - var menu by remember { mutableStateOf(NetworkStatsMenu.None) } - var type by rememberSaveable { mutableStateOf(NetworkStatsType.Summary) } - var target by rememberSaveable { mutableStateOf(getDefaultSummaryTarget()) } - var networkType by rememberSaveable { mutableStateOf(NetworkType.Mobile) } - var startTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis() - 7*24*60*60*1000) } - var endTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) } - var uid by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.UID_ALL) } - var tag by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.TAG_NONE) } - var state by rememberSaveable { mutableStateOf(NetworkStatsState.All) } - var errorMessage by rememberSaveable { mutableStateOf(null) } - MyScaffold(R.string.network_stats, onNavigateUp) { - ExposedDropdownMenuBox( - menu == NetworkStatsMenu.Type, - { menu = if (it) NetworkStatsMenu.Type else NetworkStatsMenu.None }, - Modifier.padding(top = 8.dp, bottom = 4.dp) - ) { - OutlinedTextField( - stringResource(type.text), {}, - Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - readOnly = true, label = { Text(stringResource(R.string.type)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Type) - } - ) - ExposedDropdownMenu( - menu == NetworkStatsMenu.Type, { menu = NetworkStatsMenu.None } - ) { - NetworkStatsType.entries.forEach { - DropdownMenuItem( - { Text(stringResource(it.text)) }, - { - type = it - target = if (it == NetworkStatsType.Summary) getDefaultSummaryTarget() - else NetworkStatsTarget.Uid - menu = NetworkStatsMenu.None - } - ) - } - } - } - ExposedDropdownMenuBox( - menu == NetworkStatsMenu.Target, - { menu = if(it) NetworkStatsMenu.Target else NetworkStatsMenu.None }, - Modifier.padding(bottom = 4.dp) - ) { - OutlinedTextField( - stringResource(target.text), {}, - Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - readOnly = true, label = { Text(stringResource(R.string.target)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Target) - } - ) - ExposedDropdownMenu( - menu == NetworkStatsMenu.Target, { menu = NetworkStatsMenu.None } - ) { - NetworkStatsTarget.entries.filter { - VERSION.SDK_INT >= it.minApi && type == it.type - }.forEach { - DropdownMenuItem( - text = { Text(stringResource(it.text)) }, - onClick = { - target = it - menu = NetworkStatsMenu.None - } - ) - } - } - } - ExposedDropdownMenuBox( - menu == NetworkStatsMenu.NetworkType, - { menu = if(it) NetworkStatsMenu.NetworkType else NetworkStatsMenu.None }, - Modifier.padding(bottom = 4.dp) - ) { - OutlinedTextField( - stringResource(networkType.text), {}, - Modifier.menuAnchor(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.text)) }, - onClick = { - networkType = it - menu = NetworkStatsMenu.None - } - ) - } - } - } - OutlinedTextField( - formatDate(startTime), {}, - Modifier - .fillMaxWidth() - .clickableTextField { menu = NetworkStatsMenu.StartTime } - .padding(bottom = 4.dp), - readOnly = true, label = { Text(stringResource(R.string.start_time)) }, - isError = startTime >= endTime - ) - OutlinedTextField( - formatDate(endTime), {}, - Modifier - .fillMaxWidth() - .clickableTextField { menu = NetworkStatsMenu.EndTime } - .padding(bottom = 4.dp), - readOnly = true, label = { Text(stringResource(R.string.end_time)) }, - isError = startTime >= endTime - ) - if(target == NetworkStatsTarget.Uid || target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState) - ExposedDropdownMenuBox( - menu == NetworkStatsMenu.Uid, - { menu = if(it) NetworkStatsMenu.Uid else NetworkStatsMenu.None } - ) { - var uidText by rememberSaveable { mutableStateOf(context.getString(NetworkStatsUID.All.text)) } - var readOnly by rememberSaveable { mutableStateOf(true) } - if (VERSION.SDK_INT >= 24) LaunchedEffect(Unit) { - val pkg = chosenPackage.receive() - uid = getUid(pkg) - uidText = "$uid ($pkg)" - } - OutlinedTextField( - uidText, - { - uidText = it - it.toIntOrNull()?.let { num -> uid = num } - }, - readOnly = readOnly, label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Uid) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - isError = !readOnly && uidText.toIntOrNull() == null, - modifier = Modifier - .menuAnchor(if(readOnly) MenuAnchorType.PrimaryNotEditable else MenuAnchorType.PrimaryEditable) - .fillMaxWidth().padding(bottom = 4.dp) - ) - ExposedDropdownMenu( - menu == NetworkStatsMenu.Uid, { menu = NetworkStatsMenu.None } - ) { - NetworkStatsUID.entries.forEach { - DropdownMenuItem( - text = { Text(stringResource(it.text)) }, - onClick = { - uid = it.uid - readOnly = true - uidText = context.getString(it.text) - menu = NetworkStatsMenu.None - } - ) - } - if(VERSION.SDK_INT >= 24) DropdownMenuItem( - text = { Text(stringResource(R.string.choose_an_app)) }, - onClick = { - readOnly = true - menu = NetworkStatsMenu.None - onChoosePackage() - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.input)) }, - onClick = { - readOnly = false - uidText = "" - menu = NetworkStatsMenu.None - } - ) - } - } - if (VERSION.SDK_INT >= 24 && (target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState)) - ExposedDropdownMenuBox( - menu == NetworkStatsMenu.Tag, - { menu = if(it) NetworkStatsMenu.Tag else NetworkStatsMenu.None }, - Modifier.padding(bottom = 4.dp) - ) { - var tagText by rememberSaveable { mutableStateOf(context.getString(R.string.all)) } - var readOnly by rememberSaveable { mutableStateOf(true) } - OutlinedTextField( - 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( - menu == NetworkStatsMenu.Tag, { menu = NetworkStatsMenu.None } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.all)) }, - onClick = { - tag = NetworkStats.Bucket.TAG_NONE - tagText = context.getString(R.string.all) - readOnly = true - menu = NetworkStatsMenu.None - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.input)) }, - onClick = { - tagText = "" - readOnly = false - menu = NetworkStatsMenu.None - } - ) - } - } - if (VERSION.SDK_INT >= 28 && target == NetworkStatsTarget.UidTagState) ExposedDropdownMenuBox( - menu == NetworkStatsMenu.State, - { menu = if(it) NetworkStatsMenu.State else NetworkStatsMenu.None }, - Modifier.padding(bottom = 4.dp) - ) { - OutlinedTextField( - stringResource(state.text), {}, - Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - readOnly = true, label = { Text(stringResource(R.string.uid)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.State) - } - ) - ExposedDropdownMenu( - menu == NetworkStatsMenu.State, { menu = NetworkStatsMenu.None } - ) { - NetworkStatsState.entries.forEach { - DropdownMenuItem( - { Text(stringResource(it.text)) }, - { - state = it - menu = NetworkStatsMenu.None - } - ) - } - } - } - var querying by rememberSaveable { mutableStateOf(false) } - Button( - onClick = { - querying = true - queryStats(QueryNetworkStatsParams( - type, target, networkType, startTime, endTime, uid, tag, state - )) { - querying = false - errorMessage = it - if (it == null) onNavigateToViewer() - } - }, - enabled = !querying, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp) - ) { - Text(stringResource(R.string.query)) - } - if (menu == NetworkStatsMenu.StartTime || menu == NetworkStatsMenu.EndTime) { - val datePickerState = rememberDatePickerState(if (menu == NetworkStatsMenu.StartTime) startTime else endTime) - DatePickerDialog( - onDismissRequest = { menu = NetworkStatsMenu.None }, - dismissButton = { - TextButton(onClick = { menu = NetworkStatsMenu.None }) { - Text(stringResource(R.string.cancel)) - } - }, - confirmButton = { - TextButton( - onClick = { - if (menu == NetworkStatsMenu.StartTime) startTime = datePickerState.selectedDateMillis!! - else endTime = datePickerState.selectedDateMillis!! - menu = NetworkStatsMenu.None - }, - enabled = datePickerState.selectedDateMillis != null - ) { - Text(stringResource(R.string.confirm)) - } - } - ) { - DatePicker(datePickerState) - } - } - } - ErrorDialog(errorMessage) { errorMessage = null } -} - -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 - -@Composable -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( - Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { - coroutine.launch { - ps.animateScrollToPage(index - 1) - } - }, - enabled = index > 0 - ) { - Icon(Icons.AutoMirrored.Default.KeyboardArrowLeft, null) - } - Text("${index + 1} / $size", modifier = Modifier.padding(horizontal = 8.dp)) - IconButton( - onClick = { - coroutine.launch { - ps.animateScrollToPage(index + 1) - } - }, - enabled = index < size - 1 - ) { - Icon(Icons.AutoMirrored.Default.KeyboardArrowRight, null) - } - } - HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page -> - val item = data[index] - Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { - Text(formatDate(item.startTime) + "\n~\n" + formatDate(item.endTime), - Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) - Spacer(Modifier.height(5.dp)) - val txBytes = item.txBytes - Text(stringResource(R.string.transmitted), style = MaterialTheme.typography.titleMedium) - Column(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) { - Text("$txBytes bytes (${formatFileSize(txBytes)})") - Text(item.txPackets.toString() + " packets") - } - val rxBytes = item.rxBytes - Text(stringResource(R.string.received), style = MaterialTheme.typography.titleMedium) - Column(modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) { - Text("$rxBytes bytes (${formatFileSize(rxBytes)})") - Text(item.rxPackets.toString() + " packets") - } - Row(verticalAlignment = Alignment.CenterVertically) { - val text = NetworkStatsState.entries.find { it.id == item.state }!!.text - Text(stringResource(R.string.state), Modifier.padding(end = 8.dp), style = MaterialTheme.typography.titleMedium) - Text(stringResource(text)) - } - if(VERSION.SDK_INT >= 24) { - Row(verticalAlignment = Alignment.CenterVertically) { - val tag = item.tag - Text(stringResource(R.string.tag), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) - Text(if(tag == NetworkStats.Bucket.TAG_NONE) stringResource(R.string.all) else tag.toString()) - } - Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(item.roaming) { - NetworkStats.Bucket.ROAMING_ALL -> R.string.all - NetworkStats.Bucket.ROAMING_YES -> R.string.yes - NetworkStats.Bucket.ROAMING_NO -> R.string.no - else -> R.string.unknown - } - Text(stringResource(R.string.roaming), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) - Text(stringResource(text)) - } - } - if(VERSION.SDK_INT >= 26) Row(verticalAlignment = Alignment.CenterVertically) { - val text = when(item.metered) { - NetworkStats.Bucket.METERED_ALL -> R.string.all - NetworkStats.Bucket.METERED_YES -> R.string.yes - NetworkStats.Bucket.METERED_NO -> R.string.no - else -> R.string.unknown - } - Text(stringResource(R.string.metered), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp)) - Text(stringResource(text)) - } - } - } - } -} - -@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( - getPrivateDns: () -> PrivateDnsConfiguration, - setPrivateDns: (PrivateDnsConfiguration) -> Boolean, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - 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 } - } - 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() }) - ) - Button( - onClick = { - focusMgr.clearFocus() - val result = setPrivateDns(PrivateDnsConfiguration(mode, inputHost)) - context.showOperationResultToast(result) - }, - modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - enabled = mode != null - ) { - Text(stringResource(R.string.apply)) - } - } -} - -@Serializable object AlwaysOnVpnPackage - -@RequiresApi(24) -@Composable -fun AlwaysOnVpnPackageScreen( - getPackage: () -> String, getLockdown: () -> Boolean, setConf: (String?, Boolean) -> Int, - chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var lockdown by rememberSaveable { mutableStateOf(getLockdown()) } - var pkgName by rememberSaveable { mutableStateOf(getPackage()) } - LaunchedEffect(Unit) { - pkgName = chosenPackage.receive() - } - MyScaffold(R.string.always_on_vpn, onNavigateUp) { - PackageNameTextField(pkgName, onChoosePackage, - Modifier.padding(vertical = 4.dp)) { pkgName = it } - SwitchItem(R.string.enable_lockdown, state = lockdown, onCheckedChange = { lockdown = it }, padding = false) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - context.popToast(setConf(pkgName, lockdown)) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.apply)) - } - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - context.popToast(setConf(null, false)) - pkgName = "" - lockdown = false - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.clear_current_config)) - } - Notes(R.string.info_always_on_vpn) - } -} - -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( - setProxy: (RecommendedProxyConf) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - var type by rememberSaveable { mutableStateOf(ProxyType.Off) } - var pacUrl by rememberSaveable { mutableStateOf("") } - var specifyPort by rememberSaveable { mutableStateOf(false) } - var host by rememberSaveable { mutableStateOf("") } - var port by rememberSaveable { mutableStateOf("") } - var exclList by rememberSaveable { mutableStateOf("") } - MyScaffold(R.string.recommended_global_proxy, onNavigateUp, 0.dp) { - ProxyType.entries.forEach { - FullWidthRadioButtonItem(it.text, type == it) { type = it } - } - AnimatedVisibility(type == ProxyType.Pac) { - OutlinedTextField( - pacUrl, { pacUrl = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), - label = { Text("URL") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) - ) - } - AnimatedVisibility(type == ProxyType.Direct) { - OutlinedTextField( - 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() }) - ) - } - AnimatedVisibility(type == ProxyType.Direct) { - OutlinedTextField( - exclList, { exclList = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp), - label = { Text(stringResource(R.string.excluded_hosts)) }, - maxLines = 5, - minLines = 2, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusMgr.clearFocus() } - ) - } - Button( - onClick = { - setProxy(RecommendedProxyConf( - type, pacUrl, host, specifyPort, port.toIntOrNull() ?: 0, - exclList.lines().filter { it.isNotBlank() } - )) - context.showOperationResultToast(true) - }, - 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, HorizontalPadding) - } -} - -@Serializable object NetworkLogging - -@RequiresApi(26) -@Composable -fun NetworkLoggingScreen( - getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, getCount: () -> Int, - exportLogs: (Uri, () -> Unit) -> Unit, deleteLogs: () -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var enabled by remember { mutableStateOf(false) } - var count by remember { mutableIntStateOf(0) } - var dialog by rememberSaveable { mutableStateOf(false) } - var exporting by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - 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, 0.dp) { - SwitchItem( - R.string.enable, enabled, { - setEnabled(it) - enabled = it - } - ) - Text( - stringResource(R.string.n_logs_in_total, count), - Modifier.padding(HorizontalPadding, 5.dp) - ) - Button( - { - val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) - exportLauncher.launch("network_logs_$date") - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - count > 0 - ) { - Text(stringResource(R.string.export_logs)) - } - if (count > 0) Button( - { - dialog = true - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - ) { - Text(stringResource(R.string.delete_logs)) - } - Spacer(Modifier.height(10.dp)) - Notes(R.string.info_network_log, HorizontalPadding) - } - if (exporting) CircularProgressDialog { 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( - 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() - } - MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp, 0.dp) { - SwitchItem(R.string.enabled, state = masterEnabled, onCheckedChange = { - setEnabled(it) - masterEnabled = it - }) - Spacer(Modifier.padding(vertical = 4.dp)) - configs.forEachIndexed { index, config -> - Row( - Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Text(config.id.toString()) - IconButton({ - onNavigate(AddPreferentialNetworkServiceConfig(index)) - }) { - Icon(Icons.Default.Edit, stringResource(R.string.edit)) - } - } - } - Row( - Modifier.fillMaxWidth() - .padding(top = 4.dp) - .clickable { onNavigate(AddPreferentialNetworkServiceConfig(-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)) - } - } -} - -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() -) - -@Serializable -data class AddPreferentialNetworkServiceConfig(val index: Int) - -@OptIn(ExperimentalMaterial3Api::class) -@RequiresApi(33) -@Composable -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) - 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 - } - ) - } - } - } - SwitchItem( - title = R.string.allow_fallback_to_default_connection, - state = allowFallback, onCheckedChange = { allowFallback = it }, padding = false - ) - if(VERSION.SDK_INT >= 34) SwitchItem( - title = R.string.block_non_matching_networks, - state = blockNonMatching, onCheckedChange = { blockNonMatching = it }, padding = false - ) - val includedUidsLegal = includedUids.lines().filter { it.isNotBlank() }.let { uid -> - uid.isEmpty() || (uid.all { it.toIntOrNull() != null } && excludedUids.isBlank()) - } - OutlinedTextField( - value = includedUids, onValueChange = { includedUids = it }, minLines = 2, - label = { Text(stringResource(R.string.included_uids)) }, - supportingText = { Text(stringResource(R.string.one_uid_per_line)) }, - isError = !includedUidsLegal, - modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp) - ) - val excludedUidsLegal = excludedUids.lines().filter { it.isNotBlank() }.let { uid -> - uid.isEmpty() || (uid.all { it.toIntOrNull() != null } && includedUids.isBlank()) - } - OutlinedTextField( - value = excludedUids, onValueChange = { excludedUids = it }, minLines = 2, - label = { Text(stringResource(R.string.excluded_uids)) }, - supportingText = { Text(stringResource(R.string.one_uid_per_line)) }, - isError = !excludedUidsLegal, - modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp) - ) - Button( - onClick = { - setConfig(PreferentialNetworkServiceInfo( - enabled, id, allowFallback, blockNonMatching, - excludedUids.lines().mapNotNull { it.toIntOrNull() }, - includedUids.lines().mapNotNull { it.toIntOrNull() } - ), true) - onNavigateUp() - }, - 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 = { - setConfig(origin, false) - onNavigateUp() - }, - colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError), - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.delete)) - } - } -} - -@Serializable object OverrideApn - -@RequiresApi(28) -@Composable -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, enabled, - { - setEnabled(it) - enabled = it - } - ) - configs.forEach { - Row( - Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Row { - Text(it.id.toString(), Modifier.padding(end = 8.dp)) - Column { - Text(it.name) - Text(it.apn, Modifier.alpha(0.7F), style = MaterialTheme.typography.bodyMedium) - } - } - IconButton({ - onNavigateToAddSetting(it.id) - }) { - Icon(Icons.Outlined.Edit, null) - } - } - } - Row( - Modifier.fillMaxWidth().clickable { - 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 = MaterialTheme.typography.labelLarge) - } - } -} - -enum class ApnMenu { - None, ApnType, AuthType, Protocol, RoamingProtocol, NetworkType, MvnoType, OperatorNumeric -} - -data class ApnType(val id: Int, val name: String, val requiresApi: Int = 0) -@SuppressLint("InlinedApi") -val apnTypes = listOf( - ApnType(ApnSetting.TYPE_DEFAULT, "Default"), - ApnType(ApnSetting.TYPE_MMS, "MMS"), - ApnType(ApnSetting.TYPE_SUPL, "SUPL"), - ApnType(ApnSetting.TYPE_DUN, "DUN"), - ApnType(ApnSetting.TYPE_HIPRI, "HiPri"), - ApnType(ApnSetting.TYPE_FOTA, "FOTA"), - ApnType(ApnSetting.TYPE_IMS, "IMS"), - ApnType(ApnSetting.TYPE_CBS, "CBS"), - ApnType(ApnSetting.TYPE_IA, "IA"), - ApnType(ApnSetting.TYPE_EMERGENCY, "Emergency"), - ApnType(ApnSetting.TYPE_MCX, "MCX", 29), - ApnType(ApnSetting.TYPE_XCAP, "XCAP", 30), - ApnType(ApnSetting.TYPE_VSIM, "VSIM", 31), - ApnType(ApnSetting.TYPE_BIP, "BIP", 31), - ApnType(ApnSetting.TYPE_ENTERPRISE, "Enterprise", 33), - ApnType(ApnSetting.TYPE_RCS, "RCS", 35), - ApnType(ApnSetting.TYPE_OEM_PAID, "OEM paid"), - ApnType(ApnSetting.TYPE_OEM_PRIVATE, "OEM private") -).filter { VERSION.SDK_INT >= it.requiresApi } - -@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 data class AddApnSetting(val index: Int) - -@OptIn(ExperimentalMaterial3Api::class) -@RequiresApi(28) -@Composable -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) { - SwitchItem(R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false) - OutlinedTextField( - entryName, { entryName = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), - label = { Text("Name") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - OutlinedTextField( - apnName, { apnName = it }, Modifier.fillMaxWidth(), - label = { Text("APN") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - OutlinedTextField( - proxy, { proxy = it }, Modifier.fillMaxWidth(), - label = { Text("Proxy") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - OutlinedTextField( - port, { port = it }, Modifier.fillMaxWidth(), - label = { Text("Port") }, - isError = port.isNotEmpty() && port.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) - ) - OutlinedTextField( - user, { user = it }, Modifier.fillMaxWidth(), - label = { Text("Username") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - OutlinedTextField( - password, { password = it }, Modifier.fillMaxWidth(), - label = { Text("Password") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - 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 }) - } - ) - } - OutlinedTextField( - mmsc, { mmsc = it }, Modifier.fillMaxWidth(), - label = { Text("MMSC") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - OutlinedTextField( - mmsProxy, { mmsProxy = it }, Modifier.fillMaxWidth(), - label = { Text("MMS proxy") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - OutlinedTextField( - mmsPort, { mmsPort = it }, Modifier.fillMaxWidth(), - label = { Text("MMS port") }, - isError = mmsPort.isNotEmpty() && mmsPort.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) - ) - ExposedDropdownMenuBox( - menu == ApnMenu.AuthType, { menu = if (it) ApnMenu.AuthType else ApnMenu.None } - ) { - OutlinedTextField( - 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.Done) - ) - OutlinedTextField( - mtuV6, { mtuV6 = it }, Modifier.fillMaxWidth(0.96F), - label = { Text("MTU (IPv6)") }, - isError = mtuV6.isNotEmpty() && mtuV6.toIntOrNull() == null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) - ) - } - ExposedDropdownMenuBox( - menu == ApnMenu.MvnoType, { menu = if (it) ApnMenu.MvnoType else ApnMenu.None } - ) { - OutlinedTextField( - mvnoType.text, {}, - Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), - readOnly = true, label = { Text("MVNO type") }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol) - } - ) - ExposedDropdownMenu(menu == ApnMenu.MvnoType, { menu = ApnMenu.None }) { - ApnMvnoType.entries.forEach { - DropdownMenuItem( - { Text(it.text) }, - { - mvnoType = it - menu = ApnMenu.None - } - ) - } - } - } - ExposedDropdownMenuBox( - menu == ApnMenu.OperatorNumeric, - { menu = if (it) ApnMenu.OperatorNumeric else ApnMenu.None } - ) { - OutlinedTextField( - operatorNumeric, {}, - Modifier.fillMaxWidth().menuAnchor(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 - }) - } - } - } - if (VERSION.SDK_INT >= 33) Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text("Persistent") - Switch(persistent, { persistent = it }) - } - Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text("Always on") - Switch(alwaysOn, { alwaysOn = it }) - } - Button( - { - 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( - { - 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(errorMessage != null) AlertDialog( - title = { Text(stringResource(R.string.error)) }, - text = { Text(errorMessage ?: "") }, - confirmButton = { - TextButton({ errorMessage = null }) { Text(stringResource(R.string.confirm)) } - }, - onDismissRequest = { errorMessage = null } - ) - } - if (menu == ApnMenu.ApnType) AlertDialog( - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - apnTypes.forEach { type -> - val checked = apnType and type.id == type.id - Row( - Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { - apnType = if (checked) apnType and type.id.inv() - else apnType or type.id - }.padding(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox(checked, null) - Text(type.name, Modifier.padding(start = 8.dp), style = MaterialTheme.typography.bodyLarge) - } - } - } - }, - confirmButton = { - TextButton({ menu = ApnMenu.None }) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { menu = ApnMenu.None } - ) - if (menu == ApnMenu.NetworkType) AlertDialog( - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - apnNetworkTypes.forEach { type -> - val checked = type.id and networkType == type.id - Row( - Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { - networkType = if (checked) networkType and type.id.inv() - else networkType or type.id - }.padding(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox(checked, null) - Text(type.text, Modifier.padding(start = 6.dp), style = MaterialTheme.typography.bodyLarge) - } - } - } - }, - confirmButton = { - TextButton({ menu = ApnMenu.None }) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { - menu = ApnMenu.None - } - ) -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt deleted file mode 100644 index db843e3..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ /dev/null @@ -1,483 +0,0 @@ -package com.bintianqi.owndroid.dpm - -import android.annotation.SuppressLint -import android.app.Activity -import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC -import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC -import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK -import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC -import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX -import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_SOMETHING -import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED -import android.app.admin.DevicePolicyManager.RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT -import android.app.admin.DevicePolicyManager.RESET_PASSWORD_REQUIRE_ENTRY -import android.content.Context -import android.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.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.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 -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.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.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 -import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem -import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem -import com.bintianqi.owndroid.ui.FunctionItem -import com.bintianqi.owndroid.ui.InfoItem -import com.bintianqi.owndroid.ui.MyScaffold -import com.bintianqi.owndroid.ui.Notes -import com.bintianqi.owndroid.ui.RadioButtonItem -import com.bintianqi.owndroid.yesOrNo -import kotlinx.serialization.Serializable - -@Serializable object Password - -@SuppressLint("NewApi") -@Composable -fun PasswordScreen(vm: MyViewModel,onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { - val context = LocalContext.current - val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by rememberSaveable { mutableIntStateOf(0) } - MyScaffold(R.string.password_and_keyguard, onNavigateUp, 0.dp) { - FunctionItem(R.string.password_info, icon = R.drawable.info_fill0) { onNavigate(PasswordInfo) } - if (SP.displayDangerousFeatures) { - if(VERSION.SDK_INT >= 26) { - FunctionItem(R.string.reset_password_token, icon = R.drawable.key_vertical_fill0) { onNavigate(ResetPasswordToken) } - } - FunctionItem(R.string.reset_password, icon = R.drawable.lock_reset_fill0) { onNavigate(ResetPassword) } - } - if(VERSION.SDK_INT >= 31) { - FunctionItem(R.string.required_password_complexity, icon = R.drawable.password_fill0) { onNavigate(RequiredPasswordComplexity) } - } - FunctionItem(R.string.disable_keyguard_features, icon = R.drawable.screen_lock_portrait_fill0) { onNavigate(KeyguardDisabledFeatures) } - if(privilege.device) { - FunctionItem(R.string.max_time_to_lock, icon = R.drawable.schedule_fill0) { dialog = 1 } - FunctionItem(R.string.pwd_expiration_timeout, icon = R.drawable.lock_clock_fill0) { dialog = 3 } - if (SP.displayDangerousFeatures) { - FunctionItem(R.string.max_pwd_fail, icon = R.drawable.no_encryption_fill0) { dialog = 4 } - } - } - if(VERSION.SDK_INT >= 26) { - FunctionItem(R.string.required_strong_auth_timeout, icon = R.drawable.fingerprint_off_fill0) { dialog = 2 } - } - FunctionItem(R.string.pwd_history, icon = R.drawable.history_fill0) { dialog = 5 } - if(VERSION.SDK_INT < 31) { - FunctionItem(R.string.required_password_quality, icon = R.drawable.password_fill0) { onNavigate(RequiredPasswordQuality) } - } - } - if(dialog != 0) { - var input by remember { mutableStateOf("") } - LaunchedEffect(Unit) { - input = when (dialog) { - 1 -> vm.getMaxTimeToLock() - 2 -> vm.getRequiredStrongAuthTimeout() - 3 -> vm.getPasswordExpirationTimeout() - 4 -> vm.getMaxFailedPasswordsForWipe() - 5 -> vm.getPasswordHistoryLength() - else -> 0 - }.toString() - } - AlertDialog( - title = { - Text(stringResource( - when(dialog) { - 1 -> R.string.max_time_to_lock - 2 -> R.string.required_strong_auth_timeout - 3 -> R.string.pwd_expiration_timeout - 4 -> R.string.max_pwd_fail - 5 -> R.string.pwd_history - else -> R.string.password - } - )) - }, - text = { - val focusMgr = LocalFocusManager.current - val um = context.getSystemService(Context.USER_SERVICE) as UserManager - Column { - OutlinedTextField( - value = input, - label = { - Text(stringResource( - when(dialog) { - 1,2,3 -> R.string.time_unit_ms - 4 -> R.string.max_pwd_fail_textfield - 5 -> R.string.length - else -> R.string.password - } - )) - }, - onValueChange = { input = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - textStyle = typography.bodyLarge, - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) - ) - Text(stringResource( - when(dialog) { - 1 -> R.string.info_screen_timeout - 2 -> R.string.info_required_strong_auth_timeout - 3 -> R.string.info_password_expiration_timeout - 4 -> if(um.isSystemUser) R.string.info_max_failed_password_system_user else R.string.info_max_failed_password_other_user - 5 -> R.string.info_password_history_length - else -> R.string.password - } - )) - } - }, - confirmButton = { - TextButton( - onClick = { - when(dialog) { - 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)) - } - }, - dismissButton = { - TextButton(onClick = { dialog = 0 }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { - dialog = 0 - } - ) - } -} - -@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( - getComplexity: () -> PasswordComplexity, isSufficient: () -> Boolean, isUnified: () -> Boolean, - onNavigateUp: () -> Unit -) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by rememberSaveable { mutableIntStateOf(0) } // 0:none, 1:password complexity - MyScaffold(R.string.password_info, onNavigateUp, 0.dp) { - if (VERSION.SDK_INT >= 31) { - InfoItem(R.string.current_password_complexity, getComplexity().text, true) { dialog = 1 } - } - InfoItem(R.string.password_sufficient, isSufficient().yesOrNo) - if(VERSION.SDK_INT >= 28 && privilege.work) { - InfoItem(R.string.unified_password, isUnified().yesOrNo) - } - } - if(dialog != 0) AlertDialog( - text = { Text(stringResource(R.string.info_password_complexity)) }, - confirmButton = { - TextButton({ dialog = 0 }) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { dialog = 0 } - ) -} - -data class RpTokenState(val set: Boolean, val active: Boolean) - -@Serializable object ResetPasswordToken - -@RequiresApi(26) -@Composable -fun ResetPasswordTokenScreen( - getState: () -> RpTokenState, setToken: (String) -> Boolean, getIntent: () -> Intent?, - clearToken: () -> Boolean, onNavigateUp: () -> Unit -) { - val context = LocalContext.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( - token, { token = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.token)) }, - supportingText = { Text("${token.length}/32") }, - trailingIcon = { - IconButton({ token = generateBase64Key(24) }) { - Icon(painterResource(R.drawable.casino_fill0), null) - } - } - ) - Button( - onClick = { - val result = setToken(token) - context.showOperationResultToast(result) - if (result) state = getState() - }, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - enabled = token.length >= 32 - ) { - Text(stringResource(R.string.set)) - } - if (state.set && !state.active) Button( - onClick = { - val intent = getIntent() - if (intent == null) { - context.showOperationResultToast(false) - } else { - launcher.launch(intent) - } - }, - modifier = Modifier.fillMaxWidth() - ) { - 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) - } -} - -@Serializable object ResetPassword - -@Composable -fun ResetPasswordScreen(resetPassword: (String, String, Int) -> Boolean, onNavigateUp: () -> Unit) { - val context = LocalContext.current - var password by rememberSaveable { mutableStateOf("") } - var token by rememberSaveable { mutableStateOf("") } - var flags by rememberSaveable { mutableIntStateOf(0) } - var confirmPassword by rememberSaveable { mutableStateOf("") } - MyScaffold(R.string.reset_password, onNavigateUp) { - if (VERSION.SDK_INT >= 26) { - OutlinedTextField( - token, { token = it }, Modifier.fillMaxWidth().padding(bottom = 5.dp), - label = { Text(stringResource(R.string.token)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) - ) - } - OutlinedTextField( - password, { password = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.password)) }, - isError = password.length in 1..3, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - visualTransformation = PasswordVisualTransformation() - ) - OutlinedTextField( - confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.confirm_password)) }, - isError = confirmPassword != password, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - visualTransformation = PasswordVisualTransformation() - ) - Spacer(Modifier.padding(vertical = 5.dp)) - CheckBoxItem( - R.string.do_not_ask_credentials_on_boot, - flags and RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT != 0 - ) { flags = flags xor RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT } - CheckBoxItem( - R.string.reset_password_require_entry, - flags and RESET_PASSWORD_REQUIRE_ENTRY != 0 - ) { flags = flags xor RESET_PASSWORD_REQUIRE_ENTRY } - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - 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) - } -} - -@Serializable object RequiredPasswordComplexity - -@RequiresApi(31) -@Composable -fun RequiredPasswordComplexityScreen( - getComplexity: () -> PasswordComplexity, setComplexity: (PasswordComplexity) -> Unit, - onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var complexity by rememberSaveable { mutableStateOf(PasswordComplexity.None) } - LaunchedEffect(Unit) { complexity = getComplexity() } - MyScaffold(R.string.required_password_complexity, onNavigateUp, 0.dp) { - PasswordComplexity.entries.forEach { - FullWidthRadioButtonItem(it.text, complexity == it) { complexity = it } - } - Button( - onClick = { - setComplexity(complexity) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) - ) { - Text(text = stringResource(R.string.apply)) - } - Notes(R.string.info_password_complexity, HorizontalPadding) - } -} - -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( - getConfig: () -> KeyguardDisableConfig, setConfig: (KeyguardDisableConfig) -> Unit, - onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var mode by rememberSaveable { mutableStateOf(KeyguardDisableMode.None) } - var flags by rememberSaveable { mutableIntStateOf(0) } - LaunchedEffect(Unit) { - val config = getConfig() - mode = config.mode - flags = config.flags - } - MyScaffold(R.string.disable_keyguard_features, onNavigateUp) { - KeyguardDisableMode.entries.forEach { - FullWidthRadioButtonItem(it.text, mode == it) { mode = it } - } - Spacer(Modifier.height(8.dp)) - AnimatedVisibility(mode == KeyguardDisableMode.Custom) { - Column { - keyguardDisabledFeatures.forEach { - FullWidthCheckBoxItem(it.text, flags and it.id == it.id) { checked -> - flags = flags xor it.id - } - } - } - } - Button( - onClick = { - setConfig(KeyguardDisableConfig(mode, flags)) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp) - ) { - Text(text = stringResource(R.string.apply)) - } - } -} - -@Serializable object RequiredPasswordQuality - -@Composable -fun RequiredPasswordQualityScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current - val passwordQuality = mapOf( - PASSWORD_QUALITY_UNSPECIFIED to R.string.password_quality_unspecified, - PASSWORD_QUALITY_SOMETHING to R.string.password_quality_something, - PASSWORD_QUALITY_ALPHABETIC to R.string.password_quality_alphabetic, - PASSWORD_QUALITY_NUMERIC to R.string.password_quality_numeric, - PASSWORD_QUALITY_ALPHANUMERIC to R.string.password_quality_alphanumeric, - PASSWORD_QUALITY_BIOMETRIC_WEAK to R.string.password_quality_biometrics_weak, - PASSWORD_QUALITY_NUMERIC_COMPLEX to R.string.password_quality_numeric_complex - ) - 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 { - RadioButtonItem(it.value, selectedItem == it.key) { selectedItem = it.key } - } - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - Privilege.DPM.setPasswordQuality(Privilege.DAR, selectedItem) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.apply)) - } - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt deleted file mode 100644 index 059071d..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ /dev/null @@ -1,809 +0,0 @@ -package com.bintianqi.owndroid.dpm - -import android.app.admin.DevicePolicyManager -import android.content.ComponentName -import android.os.Build.VERSION -import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -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.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Warning -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.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 -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TriStateCheckbox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color -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.state.ToggleableState -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.AppInfo -import com.bintianqi.owndroid.BottomPadding -import com.bintianqi.owndroid.DhizukuClientInfo -import com.bintianqi.owndroid.DhizukuPermissions -import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.MyViewModel -import com.bintianqi.owndroid.Privilege -import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.Settings -import com.bintianqi.owndroid.adaptiveInsets -import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.CircularProgressDialog -import com.bintianqi.owndroid.ui.InfoItem -import com.bintianqi.owndroid.ui.MyLazyScaffold -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.SwitchItem -import com.bintianqi.owndroid.yesOrNo -import com.google.accompanist.drawablepainter.rememberDrawablePainter -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.Serializable - -@Serializable data class WorkModes(val canNavigateUp: Boolean) - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun WorkModesScreen( - vm: MyViewModel, params: WorkModes, onNavigateUp: () -> Unit, onActivate: () -> Unit, - onDeactivate: () -> Unit, onNavigate: (Any) -> Unit -) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - /** 0: none, 1: device owner, 2: circular progress indicator, 3: result, 4: deactivate, 5: command */ - var dialog by rememberSaveable { mutableIntStateOf(0) } - var operationSucceed by rememberSaveable { mutableStateOf(false) } - var resultText by rememberSaveable { mutableStateOf("") } - LaunchedEffect(privilege) { - if (!params.canNavigateUp && privilege.device) { - delay(1000) - if (dialog != 3) { // Activated by ADB command - operationSucceed = true - resultText = "" - dialog = 3 - } - } - } - Scaffold( - topBar = { - TopAppBar( - { - if(!params.canNavigateUp) { - Column { - Text(stringResource(R.string.app_name)) - Text(stringResource(R.string.choose_work_mode), Modifier.alpha(0.8F), style = typography.bodyLarge) - } - } - }, - navigationIcon = { - if(params.canNavigateUp) NavIcon(onNavigateUp) - }, - actions = { - var expanded by remember { mutableStateOf(false) } - if(privilege.device || privilege.profile) Box { - IconButton({ expanded = true }) { - Icon(Icons.Default.MoreVert, null) - } - DropdownMenu(expanded, { expanded = false }) { - DropdownMenuItem( - { Text(stringResource(R.string.deactivate)) }, - { - expanded = false - dialog = 4 - }, - leadingIcon = { Icon(Icons.Default.Close, null) } - ) - if (VERSION.SDK_INT >= 26) DropdownMenuItem( - { Text(stringResource(R.string.delegated_admins)) }, - { - expanded = false - onNavigate(DelegatedAdmins) - }, - leadingIcon = { Icon(painterResource(R.drawable.admin_panel_settings_fill0), null) } - ) - if (!privilege.dhizuku && VERSION.SDK_INT >= 28) DropdownMenuItem( - { Text(stringResource(R.string.transfer_ownership)) }, - { - expanded = false - onNavigate(TransferOwnership) - }, - leadingIcon = { Icon(painterResource(R.drawable.swap_horiz_fill0), null) } - ) - } - } - if(!params.canNavigateUp) IconButton({ onNavigate(Settings) }) { - Icon(Icons.Default.Settings, null) - } - } - ) - }, - contentWindowInsets = adaptiveInsets() - ) { paddingValues -> - fun handleResult(succeeded: Boolean, output: String?) { - operationSucceed = succeeded - resultText = output ?: "" - dialog = 3 - } - Column(Modifier.fillMaxSize().padding(paddingValues)) { - if (!privilege.profile) { - WorkingModeItem(R.string.device_owner, privilege.device) { - if (!privilege.device || (VERSION.SDK_INT >= 28 && privilege.dhizuku)) { - dialog = 1 - } - } - } - if (privilege.profile) WorkingModeItem(R.string.profile_owner, true) { } - if (privilege.dhizuku || !privilege.activated) { - WorkingModeItem(R.string.dhizuku, privilege.dhizuku) { - if (!privilege.dhizuku) { - dialog = 2 - vm.activateDhizukuMode(::handleResult) - } - } - } - if( - privilege.work || (VERSION.SDK_INT < 24 || vm.isCreatingWorkProfileAllowed()) - ) { - WorkingModeItem(R.string.work_profile, privilege.work) { - if (!privilege.work) onNavigate(CreateWorkProfile) - } - } - if (privilege.activated && !privilege.dhizuku) Row( - Modifier - .padding(top = 20.dp) - .fillMaxWidth() - .clickable { onNavigate(DhizukuServerSettings) } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(painterResource(R.drawable.dhizuku_icon), null, Modifier - .padding(8.dp) - .size(28.dp)) - Text(stringResource(R.string.dhizuku_server), style = typography.titleLarge) - } - - Column(Modifier.padding(HorizontalPadding, 20.dp)) { - Row(Modifier.padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Outlined.Warning, null, Modifier.padding(end = 4.dp), colorScheme.error) - Text(stringResource(R.string.warning), color = colorScheme.error, style = typography.labelLarge) - } - Text(stringResource(R.string.owndroid_warning)) - } - } - if(dialog == 1) AlertDialog( - title = { Text(stringResource(R.string.activate_method)) }, - text = { - FlowRow(Modifier.fillMaxWidth()) { - if (!privilege.dhizuku) { - Button({ dialog = 5 }, Modifier.padding(end = 8.dp)) { - Text(stringResource(R.string.adb_command)) - } - Button({ - dialog = 2 - vm.activateDoByShizuku(::handleResult) - }, Modifier.padding(end = 8.dp)) { - Text(stringResource(R.string.shizuku)) - } - Button({ - dialog = 2 - vm.activateDoByRoot(::handleResult) - }, Modifier.padding(end = 8.dp)) { - Text("Root") - } - } - if (VERSION.SDK_INT >= 28 && privilege.dhizuku) Button({ - dialog = 2 - vm.activateDoByDhizuku(::handleResult) - }, Modifier.padding(end = 8.dp)) { - Text(stringResource(R.string.dhizuku)) - } - } - }, - confirmButton = { - TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } - }, - onDismissRequest = { dialog = 0 } - ) - if(dialog == 2) CircularProgressDialog { } - if(dialog == 3) AlertDialog( - title = { Text(stringResource(if(operationSucceed) R.string.succeeded else R.string.failed)) }, - text = { - Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { - Text(resultText) - } - }, - confirmButton = { - TextButton({ - dialog = 0 - if (operationSucceed && !params.canNavigateUp) onActivate() - }) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = {} - ) - if(dialog == 4) AlertDialog( - title = { Text(stringResource(R.string.deactivate)) }, - text = { Text(stringResource(R.string.info_deactivate)) }, - confirmButton = { - var time by remember { mutableIntStateOf(3) } - LaunchedEffect(Unit) { - for (i in (0..2).reversed()) { - delay(1000) - time = i - } - } - val timeText = if (time != 0) " (${time}s)" else "" - TextButton( - { - if(privilege.dhizuku) { - vm.deactivateDhizukuMode() - } else { - if(privilege.device) { - vm.clearDeviceOwner() - } else if(VERSION.SDK_INT >= 24) { - vm.clearProfileOwner() - } - // Status updated in Receiver.onDisabled() - } - dialog = 0 - onDeactivate() - }, - enabled = time == 0, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) - ) { - Text(stringResource(R.string.confirm) + timeText) - } - }, - dismissButton = { - TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } - }, - onDismissRequest = { dialog = 0 } - ) - if(dialog == 5) AlertDialog( - text = { - SelectionContainer { - Text(ACTIVATE_DEVICE_OWNER_COMMAND) - } - }, - confirmButton = { - TextButton({ dialog = 0 }) { Text(stringResource(R.string.confirm)) } - }, - onDismissRequest = { dialog = 0 } - ) - } -} - -@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 - ) - } -} - -const val ACTIVATE_DEVICE_OWNER_COMMAND = "dpm set-device-owner com.bintianqi.owndroid/.Receiver" - -@Serializable object DhizukuServerSettings - -@Composable -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, enabled, { - setServerEnabled(it) - enabled = it - }) - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - } - if (enabled) items(clients) { (client, app) -> - var expand by remember { mutableStateOf(false) } - Card( - Modifier - .fillMaxWidth() - .padding(HorizontalPadding, 8.dp) - ) { - Row( - Modifier - .fillMaxWidth() - .padding(8.dp, 8.dp, 0.dp, 8.dp), - Arrangement.SpaceBetween, Alignment.CenterVertically - ) { - Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { - Image( - rememberDrawablePainter(app.icon), null, - Modifier.padding(end = 16.dp).size(45.dp) - ) - Column { - Text(app.label, style = typography.titleMedium) - Text(app.name, Modifier.alpha(0.7F), style = typography.bodyMedium) - } - } - val ts = when (DhizukuPermissions.filter { it !in client.permissions }.size) { - 0 -> ToggleableState.On - DhizukuPermissions.size -> ToggleableState.Off - else -> ToggleableState.Indeterminate - } - Row(verticalAlignment = Alignment.CenterVertically) { - TriStateCheckbox(ts, { - if (ts == ToggleableState.Off) { - 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) } - )) - }) - } - } - } - } - } - } - } -} - -@Serializable object LockScreenInfo - -@RequiresApi(24) -@Composable -fun LockScreenInfoScreen( - getText: () -> String, setText: (String) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - var infoText by rememberSaveable { mutableStateOf(getText()) } - MyScaffold(R.string.lock_screen_info, onNavigateUp) { - OutlinedTextField( - value = infoText, - label = { Text(stringResource(R.string.lock_screen_info)) }, - onValueChange = { infoText = it }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusMgr.clearFocus() }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) - Button( - onClick = { - focusMgr.clearFocus() - setText(infoText) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(R.string.apply)) - } - Button( - onClick = { - focusMgr.clearFocus() - setText("") - infoText = "" - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(R.string.reset)) - } - Spacer(Modifier.padding(vertical = 10.dp)) - Notes(R.string.info_lock_screen_info) - } -} - -data class DelegatedScope(val id: String, val string: Int, val requiresApi: Int = 26) -@Suppress("InlinedApi") -val delegatedScopesList = listOf( - DelegatedScope(DevicePolicyManager.DELEGATION_APP_RESTRICTIONS, R.string.manage_application_restrictions), - DelegatedScope(DevicePolicyManager.DELEGATION_BLOCK_UNINSTALL, R.string.block_uninstall), - DelegatedScope(DevicePolicyManager.DELEGATION_CERT_INSTALL, R.string.manage_certificates), - DelegatedScope(DevicePolicyManager.DELEGATION_CERT_SELECTION, R.string.select_keychain_certificates, 29), - DelegatedScope(DevicePolicyManager.DELEGATION_ENABLE_SYSTEM_APP, R.string.enable_system_app), - DelegatedScope(DevicePolicyManager.DELEGATION_INSTALL_EXISTING_PACKAGE, R.string.install_existing_packages, 28), - DelegatedScope(DevicePolicyManager.DELEGATION_KEEP_UNINSTALLED_PACKAGES, R.string.manage_uninstalled_packages, 28), - DelegatedScope(DevicePolicyManager.DELEGATION_NETWORK_LOGGING, R.string.network_logging, 29), - DelegatedScope(DevicePolicyManager.DELEGATION_PACKAGE_ACCESS, R.string.change_package_state), - DelegatedScope(DevicePolicyManager.DELEGATION_PERMISSION_GRANT, R.string.grant_permissions), - DelegatedScope(DevicePolicyManager.DELEGATION_SECURITY_LOGGING, R.string.security_logging, 31) -).filter { VERSION.SDK_INT >= it.requiresApi } - -data class DelegatedAdmin(val app: AppInfo, val scopes: List) - -@Serializable object DelegatedAdmins - -@RequiresApi(26) -@Composable -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) - } - } - } - item { - 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()) - -@RequiresApi(26) -@Composable -fun AddDelegatedAdminScreen( - chosenPackage: Channel, onChoosePackage: () -> Unit, data: AddDelegatedAdmin, - setDelegatedAdmin: (String, List) -> Unit, onNavigateUp: () -> Unit -) { - val updateMode = data.pkg.isNotEmpty() - 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) { - 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.id else scopes -= scope.id } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - 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) - } - } - } - Button( - onClick = { - setDelegatedAdmin(input, scopes) - onNavigateUp() - }, - 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 = { - setDelegatedAdmin(input, emptyList()) - onNavigateUp() - }, - 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(vm: MyViewModel, onNavigateUp: () -> Unit) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - var dialog by rememberSaveable { mutableIntStateOf(0) } - MyScaffold(R.string.device_info, onNavigateUp, 0.dp) { - if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.org)) { - InfoItem(R.string.financed_device, vm.getDeviceFinanced().yesOrNo) - } - if (VERSION.SDK_INT >= 33) { - InfoItem(R.string.dpmrh, vm.getDpmRh() ?: stringResource(R.string.none)) - } - 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, 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)) }, - confirmButton = { TextButton(onClick = { dialog = 0 }) { Text(stringResource(R.string.confirm)) } }, - onDismissRequest = { dialog = 0 } - ) -} - -@Serializable object SupportMessage - -@RequiresApi(24) -@Composable -fun SupportMessageScreen( - getShortMessage: () -> String, getLongMessage: () -> String, setShortMessage: (String?) -> Unit, - setLongMessage: (String?) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var shortMsg by rememberSaveable { mutableStateOf("") } - var longMsg by rememberSaveable { mutableStateOf("") } - LaunchedEffect(Unit) { - shortMsg = getShortMessage() - longMsg = getLongMessage() - } - MyScaffold(R.string.support_messages, onNavigateUp) { - OutlinedTextField( - value = shortMsg, - label = { Text(stringResource(R.string.short_support_msg)) }, - onValueChange = { shortMsg = it }, - minLines = 2, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 2.dp) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { - setShortMessage(shortMsg) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(text = stringResource(R.string.apply)) - } - Button( - onClick = { - setShortMessage(null) - shortMsg = "" - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(text = stringResource(R.string.reset)) - } - } - Notes(R.string.info_short_support_message) - Spacer(Modifier.padding(vertical = 8.dp)) - OutlinedTextField( - value = longMsg, - label = { Text(stringResource(R.string.long_support_msg)) }, - onValueChange = { longMsg = it }, - minLines = 3, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 2.dp) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { - setLongMessage(longMsg) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(text = stringResource(R.string.apply)) - } - Button( - onClick = { - setLongMessage(null) - longMsg = "" - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(text = stringResource(R.string.reset)) - } - } - Notes(R.string.info_long_support_message) - } -} - -data class DeviceAdmin(val app: AppInfo, val admin: ComponentName) - -@Serializable object TransferOwnership - -@RequiresApi(28) -@Composable -fun TransferOwnershipScreen( - deviceAdmins: StateFlow>, getDeviceAdmins: () -> Unit, - transferOwnership: (ComponentName) -> Unit, onNavigateUp: () -> Unit, onTransferred: () -> Unit -) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - 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) - } - } - if (dialog) AlertDialog( - text = { - Text(stringResource( - R.string.transfer_ownership_warning, - stringResource(if(privilege.device) R.string.device_owner else R.string.profile_owner), - receivers[selectedIndex].app.name - )) - }, - confirmButton = { - TextButton( - onClick = { - transferOwnership(receivers[selectedIndex].admin) - dialog = false - onTransferred() - }, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton(onClick = { dialog = false }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { dialog = false } - ) -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt deleted file mode 100644 index 8f0fd09..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ /dev/null @@ -1,2011 +0,0 @@ -package com.bintianqi.owndroid.dpm - -import android.annotation.SuppressLint -import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyManager.MTE_DISABLED -import android.app.admin.DevicePolicyManager.MTE_ENABLED -import android.app.admin.DevicePolicyManager.MTE_NOT_CONTROLLED_BY_POLICY -import android.app.admin.DevicePolicyManager.NEARBY_STREAMING_DISABLED -import android.app.admin.DevicePolicyManager.NEARBY_STREAMING_ENABLED -import android.app.admin.DevicePolicyManager.NEARBY_STREAMING_NOT_CONTROLLED_BY_POLICY -import android.app.admin.DevicePolicyManager.NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY -import android.app.admin.DevicePolicyManager.PERMISSION_POLICY_AUTO_DENY -import android.app.admin.DevicePolicyManager.PERMISSION_POLICY_AUTO_GRANT -import android.app.admin.DevicePolicyManager.PERMISSION_POLICY_PROMPT -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.SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC -import android.app.admin.SystemUpdatePolicy.TYPE_INSTALL_WINDOWED -import android.app.admin.SystemUpdatePolicy.TYPE_POSTPONE -import android.content.Context -import android.net.Uri -import android.os.Build.VERSION -import android.os.HardwarePropertiesManager -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.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.Add -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 -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Tab -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 -import androidx.compose.material3.rememberTimePickerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.AppInfo -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.SP -import com.bintianqi.owndroid.adaptiveInsets -import com.bintianqi.owndroid.clickableTextField -import com.bintianqi.owndroid.formatDate -import com.bintianqi.owndroid.popToast -import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.CheckBoxItem -import com.bintianqi.owndroid.ui.CircularProgressDialog -import com.bintianqi.owndroid.ui.ErrorDialog -import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem -import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem -import com.bintianqi.owndroid.ui.FunctionItem -import com.bintianqi.owndroid.ui.ListItem -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.SwitchItem -import com.bintianqi.owndroid.yesOrNo -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import kotlin.math.roundToLong - -@Serializable object SystemManager - -@Composable -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 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) } - if(VERSION.SDK_INT >= 24 && privilege.device && !privilege.dhizuku) - FunctionItem(R.string.hardware_monitor, icon = R.drawable.memory_fill0) { onNavigate(HardwareMonitor) } - if(VERSION.SDK_INT >= 24 && privilege.device) { - FunctionItem(R.string.reboot, icon = R.drawable.restart_alt_fill0) { dialog = 1 } - } - if(VERSION.SDK_INT >= 24 && privilege.device && (VERSION.SDK_INT < 28 || privilege.affiliated)) { - FunctionItem(R.string.bug_report, icon = R.drawable.bug_report_fill0) { dialog = 2 } - } - if(VERSION.SDK_INT >= 28 && (privilege.device || privilege.org)) { - FunctionItem(R.string.change_time, icon = R.drawable.schedule_fill0) { onNavigate(ChangeTime) } - FunctionItem(R.string.change_timezone, icon = R.drawable.globe_fill0) { onNavigate(ChangeTimeZone) } - } - if (VERSION.SDK_INT >= 36 && (privilege.device || privilege.org)) { - FunctionItem(R.string.auto_time_policy, icon = R.drawable.schedule_fill0) { onNavigate(AutoTimePolicy) } - FunctionItem(R.string.auto_timezone_policy, icon = R.drawable.globe_fill0) { onNavigate(AutoTimeZonePolicy) } - } - /*if(VERSION.SDK_INT >= 28 && (deviceOwner || profileOwner)) - FunctionItem(R.string.key_pairs, icon = R.drawable.key_vertical_fill0) { navCtrl.navigate("KeyPairs") }*/ - if(VERSION.SDK_INT >= 35 && (privilege.device || (privilege.profile && privilege.affiliated))) - FunctionItem(R.string.content_protection_policy, icon = R.drawable.search_fill0) { onNavigate(ContentProtectionPolicy) } - FunctionItem(R.string.permission_policy, icon = R.drawable.key_fill0) { onNavigate(PermissionPolicy) } - if(VERSION.SDK_INT >= 34 && privilege.device) { - FunctionItem(R.string.mte_policy, icon = R.drawable.memory_fill0) { onNavigate(MtePolicy) } - } - if(VERSION.SDK_INT >= 31) { - FunctionItem(R.string.nearby_streaming_policy, icon = R.drawable.share_fill0) { onNavigate(NearbyStreamingPolicy) } - } - if (VERSION.SDK_INT >= 28 && privilege.device) { - 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) } - if(VERSION.SDK_INT >= 26 && !privilege.dhizuku && (privilege.device || privilege.org)) { - FunctionItem(R.string.security_logging, icon = R.drawable.description_fill0) { onNavigate(SecurityLogging) } - } - FunctionItem(R.string.device_info, icon = R.drawable.perm_device_information_fill0) { onNavigate(DeviceInfo) } - if(VERSION.SDK_INT >= 24 && (privilege.profile || (VERSION.SDK_INT >= 26 && privilege.device))) { - FunctionItem(R.string.org_name, icon = R.drawable.corporate_fare_fill0) { dialog = 3 } - } - if(VERSION.SDK_INT >= 31) { - FunctionItem(R.string.org_id, icon = R.drawable.corporate_fare_fill0) { dialog = 4 } - } - if (VERSION.SDK_INT >= 31) { - FunctionItem(R.string.enrollment_specific_id, icon = R.drawable.id_card_fill0) { dialog = 5 } - } - if(VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { - FunctionItem(R.string.lock_screen_info, icon = R.drawable.screen_lock_portrait_fill0) { onNavigate(LockScreenInfo) } - } - if(VERSION.SDK_INT >= 24) { - FunctionItem(R.string.support_messages, icon = R.drawable.chat_fill0) { onNavigate(SupportMessage) } - } - FunctionItem(R.string.disable_account_management, icon = R.drawable.account_circle_fill0) { onNavigate(DisableAccountManagement) } - if (privilege.device || privilege.org) { - FunctionItem(R.string.system_update_policy, icon = R.drawable.system_update_fill0) { onNavigate(SetSystemUpdatePolicy) } - } - if(VERSION.SDK_INT >= 29 && (privilege.device || privilege.org)) { - FunctionItem(R.string.install_system_update, icon = R.drawable.system_update_fill0) { onNavigate(InstallSystemUpdate) } - } - if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { - FunctionItem(R.string.frp_policy, icon = R.drawable.device_reset_fill0) { onNavigate(FrpPolicy) } - } - if(SP.displayDangerousFeatures && !privilege.work) { - FunctionItem(R.string.wipe_data, icon = R.drawable.device_reset_fill0) { onNavigate(WipeData) } - } - } - if((dialog == 1 || dialog == 2) && VERSION.SDK_INT >= 24) AlertDialog( - onDismissRequest = { dialog = 0 }, - title = { Text(stringResource(if(dialog == 1) R.string.reboot else R.string.bug_report)) }, - text = { Text(stringResource(if(dialog == 1) R.string.info_reboot else R.string.confirm_bug_report)) }, - dismissButton = { - TextButton(onClick = { dialog = 0 }) { - Text(stringResource(R.string.cancel)) - } - }, - confirmButton = { - TextButton( - onClick = { - if(dialog == 1) { - vm.reboot() - } else { - context.showOperationResultToast(vm.requestBugReport()) - } - dialog = 0 - } - ) { - Text(stringResource(R.string.confirm)) - } - }, - modifier = Modifier.fillMaxWidth() - ) - if(dialog in 3..5) { - var input by rememberSaveable { mutableStateOf("") } - AlertDialog( - text = { - val focusMgr = LocalFocusManager.current - LaunchedEffect(Unit) { - 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), - readOnly = dialog == 5, - label = { - Text(stringResource( - when(dialog){ - 3 -> R.string.org_name - 4 -> R.string.org_id - 5 -> R.string.enrollment_specific_id - else -> R.string.place_holder - } - )) - }, - supportingText = { - if(dialog == 4) Text(stringResource(R.string.length_6_to_64)) - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusMgr.clearFocus() }, - textStyle = typography.bodyLarge - ) - if(dialog == 5) Text(stringResource(R.string.info_enrollment_specific_id)) - if(dialog == 4) Text(stringResource(R.string.info_org_id)) - } - }, - onDismissRequest = { dialog = 0 }, - dismissButton = { - if (dialog != 5) TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } - }, - confirmButton = { - TextButton( - onClick = { - 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 - ) { - Text(stringResource(R.string.confirm)) - } - } - ) - } -} - -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(vm: MyViewModel, onNavigateUp: () -> Unit) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - 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, status.cameraDisabled, vm::setCameraDisabled, - R.drawable.no_photography_fill0) - SwitchItem(R.string.disable_screen_capture, status.screenCaptureDisabled, - vm::setScreenCaptureDisabled, R.drawable.screenshot_fill0) - if (VERSION.SDK_INT >= 34 && privilege.run { device || (profile && affiliated) }) { - SwitchItem(R.string.disable_status_bar, status.statusBarDisabled, - vm::setStatusBarDisabled, R.drawable.notifications_fill0) - } - if (privilege.device || privilege.org) { - if(VERSION.SDK_INT >= 30) { - SwitchItem(R.string.auto_time, status.autoTimeEnabled, vm::setAutoTimeEnabled, - R.drawable.schedule_fill0) - SwitchItem(R.string.auto_timezone, status.autoTimeZoneEnabled, - vm::setAutoTimeZoneEnabled, R.drawable.globe_fill0) - } else { - SwitchItem(R.string.require_auto_time, status.autoTimeRequired, - vm::setAutoTimeRequired, R.drawable.schedule_fill0) - } - } - if (!privilege.work) SwitchItem(R.string.master_mute, - status.masterVolumeMuted, vm::setMasterVolumeMuted, R.drawable.volume_off_fill0) - if (VERSION.SDK_INT >= 26) { - SwitchItem(R.string.backup_service, icon = R.drawable.backup_fill0, - state = status.backupServiceEnabled, onCheckedChange = vm::setBackupServiceEnabled, - onClickBlank = { dialog = 1 }) - } - if (VERSION.SDK_INT >= 24 && privilege.work) { - SwitchItem(R.string.disable_bt_contact_share, status.btContactSharingDisabled, - vm::setBtContactSharingDisabled, R.drawable.account_circle_fill0) - } - if(VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { - SwitchItem(R.string.common_criteria_mode, icon = R.drawable.security_fill0, - state = status.commonCriteriaMode, - onCheckedChange = vm::setCommonCriteriaModeEnabled, - onClickBlank = { dialog = 2 }) - } - if (VERSION.SDK_INT >= 31 && (privilege.device || privilege.org) && status.canDisableUsbSignal) { - SwitchItem(R.string.enable_usb_signal, status.usbSignalEnabled, - vm::setUsbSignalEnabled, R.drawable.usb_fill0) - } - if (VERSION.SDK_INT < 34) { - Row( - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.status_bar), style = typography.titleMedium) - Button({ - vm.setStatusBarDisabled(true) - }, Modifier.padding(horizontal = 4.dp)) { - Text(stringResource(R.string.disable)) - } - Button({ - vm.setStatusBarDisabled(false) - }) { - Text(stringResource(R.string.enable)) - } - } - } - } - if(dialog != 0) AlertDialog( - text = { - Text(stringResource( - when(dialog) { - 1 -> R.string.info_backup_service - 2 -> R.string.info_common_criteria_mode - else -> R.string.options - } - )) - }, - confirmButton = { - TextButton(onClick = { dialog = 0 }) { Text(stringResource(R.string.confirm)) } - }, - onDismissRequest = { dialog = 0 } - ) -} - -@Serializable object Keyguard - -@Composable -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 (privilege.device || - (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Button( - onClick = { context.showOperationResultToast(setKeyguardDisabled(true)) }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.disable)) - } - Button( - onClick = { context.showOperationResultToast(setKeyguardDisabled(false)) }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.enable)) - } - } - Notes(R.string.info_disable_keyguard) - Spacer(Modifier.padding(vertical = 12.dp)) - } - Text(text = stringResource(R.string.lock_now), style = typography.headlineLarge) - Spacer(Modifier.padding(vertical = 2.dp)) - var evictKey by rememberSaveable { mutableStateOf(false) } - Button( - onClick = { lock(evictKey) }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.lock_now)) - } - 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( - 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() - LaunchedEffect(Unit) { - getHardwareProperties() - } - MyScaffold(R.string.hardware_monitor, onNavigateUp) { - Text(stringResource(R.string.refresh_interval), Modifier.padding(top = 8.dp, bottom = 4.dp), - style = typography.titleLarge) - Slider(refreshInterval, { - refreshInterval = it - setRefreshInterval(it) - }, valueRange = 0.5F..2F, steps = 14) - Text("${refreshIntervalMs}ms") - Spacer(Modifier.padding(vertical = 10.dp)) - properties.temperatures.forEach { tempMapItem -> - Text(stringResource(temperatureTypes[tempMapItem.key]!!), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) - if(tempMapItem.value.isEmpty()) { - Text(stringResource(R.string.unsupported)) - } else { - tempMapItem.value.forEachIndexed { index, temp -> - Row(modifier = Modifier.padding(vertical = 4.dp)) { - Text(index.toString(), style = typography.titleMedium, modifier = Modifier.padding(start = 8.dp, end = 12.dp)) - Text(if(temp == HardwarePropertiesManager.UNDEFINED_TEMPERATURE) stringResource(R.string.undefined) else temp.toString()) - } - } - } - Spacer(Modifier.padding(vertical = 10.dp)) - } - Text(stringResource(R.string.cpu_usages), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) - if (properties.cpuUsages.isEmpty()) { - Text(stringResource(R.string.unsupported)) - } else { - properties.cpuUsages.forEachIndexed { index, usage -> - Row(modifier = Modifier.padding(vertical = 4.dp)) { - Text(index.toString(), style = typography.titleMedium, modifier = Modifier.padding(start = 8.dp, end = 12.dp)) - Column { - Text(stringResource(R.string.active) + ": " + usage.first + "ms") - Text(stringResource(R.string.total) + ": " + usage.second + "ms") - } - } - } - } - Spacer(Modifier.padding(vertical = 10.dp)) - Text(stringResource(R.string.fan_speeds), style = typography.titleLarge, modifier = Modifier.padding(vertical = 4.dp)) - if (properties.fanSpeeds.isEmpty()) { - Text(stringResource(R.string.unsupported)) - } else { - properties.fanSpeeds.forEachIndexed { index, speed -> - Row(modifier = Modifier.padding(vertical = 4.dp)) { - Text(index.toString(), style = typography.titleMedium, modifier = Modifier.padding(start = 8.dp, end = 12.dp)) - Text("$speed RPM") - } - } - } - } -} - -@Serializable object ChangeTime - -@OptIn(ExperimentalMaterial3Api::class) -@RequiresApi(28) -@Composable -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 } - 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(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) - ) { - 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)) } - ) - } - 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)) - } - } - } - } - if(picker == 1) DatePickerDialog( - confirmButton = { - TextButton(onClick = { picker = 0; focusMgr.clearFocus() } ) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { picker = 0; focusMgr.clearFocus() } - ) { - Column(Modifier.verticalScroll(rememberScrollState())) { - DatePicker(datePickerState) - } - } - if (picker == 2) TimePickerDialog( - title = {}, - confirmButton = { - TextButton({ picker = 0 }) { - Text(stringResource(R.string.confirm)) - } - }, - onDismissRequest = { picker = 0 } - ) { - TimePicker(timePickerState) - } -} - -@Serializable object ChangeTimeZone - -@RequiresApi(28) -@Composable -fun ChangeTimeZoneScreen(setTimeZone: (String) -> Boolean, onNavigateUp: () -> Unit) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - 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) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - context.showOperationResultToast(setTimeZone(inputTimezone)) - }, - modifier = Modifier.fillMaxWidth(), - enabled = inputTimezone.isNotEmpty() && validInput - ) { - Text(stringResource(R.string.apply)) - } - Spacer(Modifier.padding(vertical = 10.dp)) - Notes(R.string.disable_auto_time_zone_before_set) - } - if(dialog) AlertDialog( - text = { - LazyColumn { - items(availableIds) { - Text( - text = it, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 1.dp) - .clip(RoundedCornerShape(15)) - .clickable { - inputTimezone = it - dialog = false - } - .padding(start = 6.dp, top = 10.dp, bottom = 10.dp) - ) - } - } - }, - confirmButton = { - TextButton(onClick = { dialog = false }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { dialog = false } - ) -} - -@Serializable object AutoTimePolicy - -@RequiresApi(36) -@Composable -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, - DevicePolicyManager.AUTO_TIME_NOT_CONTROLLED_BY_POLICY to R.string.not_controlled_by_policy - ).forEach { - FullWidthRadioButtonItem(it.second, it.first == policy) { - policy = it.first - } - } - Button( - { - setPolicy(policy) - context.showOperationResultToast(true) - }, - Modifier - .fillMaxWidth() - .padding(horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } -} - -@Serializable object AutoTimeZonePolicy - -@RequiresApi(36) -@Composable -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, - DevicePolicyManager.AUTO_TIME_ZONE_NOT_CONTROLLED_BY_POLICY to R.string.not_controlled_by_policy - ).forEach { - FullWidthRadioButtonItem(it.second, it.first == policy) { - policy = it.first - } - } - Button({ - setPolicy(policy) - context.showOperationResultToast(true) - }, Modifier - .fillMaxWidth() - .padding(horizontal = HorizontalPadding)) { - Text(stringResource(R.string.apply)) - } -} - -/*@RequiresApi(28) -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun KeyPairs(navCtrl: NavHostController) { - val context = LocalContext.current - val dpm = context.getDPM() - val receiver = context.getReceiver() - var alias by remember { mutableStateOf("") } - var purpose by remember { mutableIntStateOf(0) } - //var keySpecType by remember { mutableIntStateOf() } - var ecStdName by remember { mutableStateOf("") } - var rsaKeySize by remember { mutableStateOf("") } - var rsaExponent by remember { mutableStateOf("") } - var algorithm by remember { mutableStateOf("") } - var idAttestationFlags by remember { mutableIntStateOf(0) } - MyScaffold(R.string.key_pairs, 8.dp, navCtrl) { - OutlinedTextField( - value = alias, onValueChange = { alias = it }, label = { Text(stringResource(R.string.alias)) }, - modifier = Modifier.fillMaxWidth() - ) - Text(stringResource(R.string.algorithm), style = typography.titleLarge) - SingleChoiceSegmentedButtonRow { - *//*SegmentedButton( - algorithm == "DH", { algorithm = "DH" }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 4) - ) { - Text("DH") - } - SegmentedButton( - algorithm == "DSA", { algorithm = "DSA" }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 4) - ) { - Text("DSA") - }*//* - SegmentedButton( - algorithm == "EC", { algorithm = "EC" }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) - ) { - Text("EC") - } - SegmentedButton( - algorithm == "RSA", { algorithm = "RSA" }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) - ) { - Text("RSA") - } - } - AnimatedVisibility(algorithm != "") { - Text(stringResource(R.string.key_specification), style = typography.titleLarge) - } - AnimatedVisibility(algorithm == "EC") { - OutlinedTextField( - value = ecStdName, onValueChange = { ecStdName = it }, label = { Text(stringResource(R.string.standard_name)) }, - modifier = Modifier.fillMaxWidth() - ) - } - AnimatedVisibility(algorithm == "RSA") { - Column { - OutlinedTextField( - value = rsaKeySize, onValueChange = { rsaKeySize = it }, label = { Text(stringResource(R.string.key_size)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = rsaExponent, onValueChange = { rsaExponent = it }, label = { Text(stringResource(R.string.exponent)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - } - } - Text(stringResource(R.string.key_purpose), style = typography.titleLarge) - FlowRow { - if(VERSION.SDK_INT >= 23) { - InputChip( - purpose and KeyProperties.PURPOSE_ENCRYPT != 0, - { purpose = purpose xor KeyProperties.PURPOSE_ENCRYPT }, - { Text(stringResource(R.string.kp_encrypt)) }, - Modifier.padding(horizontal = 4.dp) - ) - InputChip( - purpose and KeyProperties.PURPOSE_DECRYPT != 0, - { purpose = purpose xor KeyProperties.PURPOSE_DECRYPT }, - { Text(stringResource(R.string.kp_decrypt)) }, - Modifier.padding(horizontal = 4.dp) - ) - InputChip( - purpose and KeyProperties.PURPOSE_SIGN != 0, - { purpose = purpose xor KeyProperties.PURPOSE_SIGN }, - { Text(stringResource(R.string.kp_sign)) }, - Modifier.padding(horizontal = 4.dp) - ) - InputChip( - purpose and KeyProperties.PURPOSE_VERIFY != 0, - { purpose = purpose xor KeyProperties.PURPOSE_VERIFY }, - { Text(stringResource(R.string.kp_verify)) }, - Modifier.padding(horizontal = 4.dp) - ) - } - if(VERSION.SDK_INT >= 28) InputChip( - purpose and KeyProperties.PURPOSE_WRAP_KEY != 0, - { purpose = purpose xor KeyProperties.PURPOSE_WRAP_KEY }, - { Text(stringResource(R.string.kp_wrap)) }, - Modifier.padding(horizontal = 4.dp) - ) - if(VERSION.SDK_INT >= 31) { - InputChip( - purpose and KeyProperties.PURPOSE_AGREE_KEY != 0, - { purpose = purpose xor KeyProperties.PURPOSE_AGREE_KEY }, - { Text(stringResource(R.string.kp_agree)) }, - Modifier.padding(horizontal = 4.dp) - ) - InputChip( - purpose and KeyProperties.PURPOSE_ATTEST_KEY != 0, - { purpose = purpose xor KeyProperties.PURPOSE_ATTEST_KEY }, - { Text(stringResource(R.string.kp_attest)) }, - Modifier.padding(horizontal = 4.dp) - ) - } - } - Text(stringResource(R.string.attestation_record_identifiers), style = typography.titleLarge) - FlowRow { - InputChip( - idAttestationFlags and DevicePolicyManager.ID_TYPE_BASE_INFO != 0, - { idAttestationFlags = idAttestationFlags xor DevicePolicyManager.ID_TYPE_BASE_INFO }, - { Text(stringResource(R.string.base_info)) }, - Modifier.padding(horizontal = 4.dp) - ) - InputChip( - idAttestationFlags and DevicePolicyManager.ID_TYPE_SERIAL != 0, - { idAttestationFlags = idAttestationFlags xor DevicePolicyManager.ID_TYPE_SERIAL }, - { Text(stringResource(R.string.serial_number)) }, - Modifier.padding(horizontal = 4.dp) - ) - InputChip( - idAttestationFlags and DevicePolicyManager.ID_TYPE_IMEI != 0, - { idAttestationFlags = idAttestationFlags xor DevicePolicyManager.ID_TYPE_IMEI }, - { Text("IMEI") }, - Modifier.padding(horizontal = 4.dp) - ) - InputChip( - idAttestationFlags and DevicePolicyManager.ID_TYPE_MEID != 0, - { idAttestationFlags = idAttestationFlags xor DevicePolicyManager.ID_TYPE_MEID }, - { Text("MEID") }, - Modifier.padding(horizontal = 4.dp) - ) - if(VERSION.SDK_INT >= 30) InputChip( - idAttestationFlags and DevicePolicyManager.ID_TYPE_INDIVIDUAL_ATTESTATION != 0, - { idAttestationFlags = idAttestationFlags xor DevicePolicyManager.ID_TYPE_INDIVIDUAL_ATTESTATION }, - { Text(stringResource(R.string.individual_certificate)) }, - Modifier.padding(horizontal = 4.dp) - ) - } - Button( - onClick = { - try { - val aps = if(algorithm == "EC") ECGenParameterSpec(ecStdName) - else RSAKeyGenParameterSpec(rsaKeySize.toInt(), rsaExponent.toBigInteger()) - val keySpec = KeyGenParameterSpec.Builder(alias, purpose).run { - setAlgorithmParameterSpec(aps) - this.setAttestationChallenge() - build() - } - dpm.generateKeyPair(receiver, algorithm, keySpec, idAttestationFlags) - } catch(e: Exception) { - AlertDialog.Builder(context) - .setTitle(R.string.error) - .setMessage(e.message ?: "") - .setPositiveButton(R.string.confirm) { dialog, _ -> dialog.dismiss() } - .show() - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = alias != "" && purpose != 0 && - ((algorithm == "EC") || (algorithm == "RSA" && rsaKeySize.all { it.isDigit() } && rsaExponent.all { it.isDigit() })) - ) { - Text(stringResource(R.string.generate)) - } - } -}*/ - -@Serializable object ContentProtectionPolicy - -@RequiresApi(35) -@Composable -fun ContentProtectionPolicyScreen( - getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - 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, - DevicePolicyManager.CONTENT_PROTECTION_ENABLED to R.string.enabled, - DevicePolicyManager.CONTENT_PROTECTION_DISABLED to R.string.disabled - ).forEach { (policyId, string) -> - FullWidthRadioButtonItem(string, policy == policyId) { policy = policyId } - } - Button( - onClick = { - setPolicy(policy) - context.showOperationResultToast(true) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_content_protection_policy, HorizontalPadding) - } -} - -@Serializable object PermissionPolicy - -@Composable -fun PermissionPolicyScreen( - getPolicy: () -> Int, setPolicy: (Int) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - 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 - } - FullWidthRadioButtonItem(R.string.auto_grant, selectedPolicy == PERMISSION_POLICY_AUTO_GRANT) { - selectedPolicy = PERMISSION_POLICY_AUTO_GRANT - } - FullWidthRadioButtonItem(R.string.auto_deny, selectedPolicy == PERMISSION_POLICY_AUTO_DENY) { - selectedPolicy = PERMISSION_POLICY_AUTO_DENY - } - Button( - onClick = { - setPolicy(selectedPolicy) - context.showOperationResultToast(true) - }, - modifier = Modifier - .fillMaxWidth() - .padding(HorizontalPadding, 5.dp) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_permission_policy, HorizontalPadding) - } -} - -@Serializable object MtePolicy - -@RequiresApi(34) -@Composable -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, policy == MTE_NOT_CONTROLLED_BY_POLICY) { - policy = MTE_NOT_CONTROLLED_BY_POLICY - } - FullWidthRadioButtonItem(R.string.enabled, policy == MTE_ENABLED) { policy = MTE_ENABLED } - FullWidthRadioButtonItem(R.string.disabled, policy == MTE_DISABLED) { policy = MTE_DISABLED } - Button( - onClick = { - if (!setPolicy(policy)) policy = getPolicy() - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_mte_policy, HorizontalPadding) - } -} - -@Serializable object NearbyStreamingPolicy - -@RequiresApi(31) -@Composable -fun NearbyStreamingPolicyScreen( - getAppPolicy: () -> Int, setAppPolicy: (Int) -> Unit, getNotificationPolicy: () -> Int, - setNotificationPolicy: (Int) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - var appPolicy by rememberSaveable { mutableIntStateOf(getAppPolicy()) } - MySmallTitleScaffold(R.string.nearby_streaming_policy, onNavigateUp, 0.dp) { - Text( - stringResource(R.string.nearby_app_streaming), - Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), style = typography.titleLarge - ) - FullWidthRadioButtonItem( - R.string.decide_by_user, - appPolicy == NEARBY_STREAMING_NOT_CONTROLLED_BY_POLICY - ) { appPolicy = NEARBY_STREAMING_NOT_CONTROLLED_BY_POLICY } - FullWidthRadioButtonItem(R.string.enabled, appPolicy == NEARBY_STREAMING_ENABLED) { appPolicy = NEARBY_STREAMING_ENABLED } - FullWidthRadioButtonItem(R.string.disabled, appPolicy == NEARBY_STREAMING_DISABLED) { appPolicy = NEARBY_STREAMING_DISABLED } - FullWidthRadioButtonItem( - R.string.enable_if_secure_enough, - appPolicy == NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY - ) { appPolicy = NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY } - Button( - onClick = { - setAppPolicy(appPolicy) - context.showOperationResultToast(true) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_nearby_app_streaming_policy, HorizontalPadding) - 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 - ) - FullWidthRadioButtonItem( - R.string.decide_by_user, - notificationPolicy == NEARBY_STREAMING_NOT_CONTROLLED_BY_POLICY - ) { notificationPolicy = NEARBY_STREAMING_NOT_CONTROLLED_BY_POLICY } - FullWidthRadioButtonItem( - R.string.enabled, - notificationPolicy == NEARBY_STREAMING_ENABLED - ) { notificationPolicy = NEARBY_STREAMING_ENABLED } - FullWidthRadioButtonItem( - R.string.disabled, - notificationPolicy == NEARBY_STREAMING_DISABLED - ) { notificationPolicy = NEARBY_STREAMING_DISABLED } - FullWidthRadioButtonItem( - R.string.enable_if_secure_enough, - notificationPolicy == NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY - ) { notificationPolicy = NEARBY_STREAMING_SAME_MANAGED_ACCOUNT_ONLY } - Button( - onClick = { - setNotificationPolicy(notificationPolicy) - context.showOperationResultToast(true) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_nearby_notification_streaming_policy, HorizontalPadding) - } -} - -@Serializable object LockTaskMode - -@OptIn(ExperimentalMaterial3Api::class) -@RequiresApi(28) -@Composable -fun LockTaskModeScreen( - chosenPackage: Channel, chooseSinglePackage: () -> Unit, choosePackage: () -> Unit, - lockTaskPackages: StateFlow>, getLockTaskPackages: () -> Unit, - setLockTaskPackage: (String, Boolean) -> Unit, - startLockTaskMode: (String, String, Boolean, Boolean) -> Boolean, - getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String?, onNavigateUp: () -> Unit -) { - val coroutine = rememberCoroutineScope() - val pagerState = rememberPagerState { 3 } - var tabIndex by rememberSaveable { mutableIntStateOf(0) } - tabIndex = pagerState.targetPage - LaunchedEffect(Unit) { - getLockTaskPackages() - } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.lock_task_mode)) }, - navigationIcon = { NavIcon(onNavigateUp) }, - colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) - ) - }, - contentWindowInsets = adaptiveInsets() - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - PrimaryTabRow(tabIndex) { - Tab( - tabIndex == 0, onClick = { coroutine.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(stringResource(R.string.start)) } - ) - Tab( - tabIndex == 1, onClick = { coroutine.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(stringResource(R.string.applications)) } - ) - Tab( - tabIndex == 2, onClick = { coroutine.launch { pagerState.animateScrollToPage(2) } }, - text = { Text(stringResource(R.string.features)) } - ) - } - HorizontalPager(pagerState, verticalAlignment = Alignment.Top) { page -> - if(page == 0) { - StartLockTaskMode(startLockTaskMode, chosenPackage, chooseSinglePackage) - } else if (page == 1) { - LockTaskPackages(chosenPackage, choosePackage, lockTaskPackages, setLockTaskPackage) - } else { - LockTaskFeatures(getLockTaskFeatures, setLockTaskFeature) - } - } - } - } -} - -@RequiresApi(28) -@Composable -private fun StartLockTaskMode( - startLockTaskMode: (String, String, Boolean, Boolean) -> Boolean, - chosenPackage: Channel, onChoosePackage: () -> Unit -) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - val privilege by Privilege.status.collectAsStateWithLifecycle() - var packageName by rememberSaveable { mutableStateOf("") } - var activity by rememberSaveable { mutableStateOf("") } - var specifyActivity by rememberSaveable { mutableStateOf(false) } - var clearTask by rememberSaveable { mutableStateOf(true) } - var showNotification by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { - packageName = chosenPackage.receive() - } - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - ) { - PackageNameTextField( - packageName, onChoosePackage, Modifier.padding(HorizontalPadding, 8.dp) - ) { packageName = it } - FullWidthCheckBoxItem( - R.string.lock_task_mode_start_clear_task, clearTask - ) { clearTask = it } - FullWidthCheckBoxItem( - R.string.lock_task_mode_show_notification, showNotification - ) { showNotification = it } - Row( - Modifier - .fillMaxWidth() - .padding(start = 4.dp, top = 4.dp, end = HorizontalPadding, bottom = 8.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(horizontal = HorizontalPadding), - onClick = { - val result = startLockTaskMode(packageName, activity, clearTask, showNotification) - if (!result) context.showOperationResultToast(false) - }, - enabled = packageName.isNotBlank() && (!specifyActivity || activity.isNotBlank()) - ) { - Text(stringResource(R.string.start)) - } - Spacer(Modifier.height(5.dp)) - if (!privilege.dhizuku) Notes(R.string.info_start_lock_task_mode) - } -} - -@RequiresApi(26) -@Composable -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() - } - LazyColumn { - items(packages, { it.name }) { - ApplicationItem(it) { setLockTaskPackage(it.name, false) } - } - 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)) - } - } - } -} - -@RequiresApi(28) -@Composable -private fun LockTaskFeatures( - getLockTaskFeatures: () -> Int, setLockTaskFeature: (Int) -> String? -) { - val context = LocalContext.current - var flags by rememberSaveable { mutableIntStateOf(getLockTaskFeatures()) } - var errorMessage by rememberSaveable { mutableStateOf(null) } - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - ) { - Spacer(Modifier.padding(vertical = 5.dp)) - listOf( - DevicePolicyManager.LOCK_TASK_FEATURE_SYSTEM_INFO to R.string.ltf_sys_info, - DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS to R.string.ltf_notifications, - DevicePolicyManager.LOCK_TASK_FEATURE_HOME to R.string.ltf_home, - DevicePolicyManager.LOCK_TASK_FEATURE_OVERVIEW to R.string.ltf_overview, - DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS to R.string.ltf_global_actions, - DevicePolicyManager.LOCK_TASK_FEATURE_KEYGUARD to R.string.ltf_keyguard - ).let { - if(VERSION.SDK_INT >= 30) it.plus( - DevicePolicyManager.LOCK_TASK_FEATURE_BLOCK_ACTIVITY_START_IN_TASK to - R.string.ltf_block_activity_start_in_task) - else it - }.forEach { (id, title) -> - FullWidthCheckBoxItem(title, flags and id != 0) { 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 } - } -} - -data class CaCertInfo( - val hash: String, - 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( - 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 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) exportCert(uri) - } - LaunchedEffect(Unit) { getCerts() } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.ca_cert)) }, - navigationIcon = { NavIcon(onNavigateUp) }, - actions = { - IconButton({ dialog = 3 }) { - Icon(Icons.Outlined.Delete, stringResource(R.string.delete)) - } - } - ) - }, - floatingActionButton = { - FloatingActionButton({ - context.popToast(R.string.select_ca_cert) - getCertLauncher.launch(arrayOf("*/*")) - }) { - Icon(Icons.Default.Add, stringResource(R.string.install)) - } - }, - contentWindowInsets = adaptiveInsets() - ) { paddingValues -> - LazyColumn( - Modifier - .fillMaxSize() - .padding(paddingValues), - horizontalAlignment = Alignment.CenterHorizontally - ) { - items(caCerts, { it.hash }) { cert -> - Column( - Modifier - .fillMaxWidth() - .clickable { - selectCaCert(cert) - dialog = 2 - } - .animateItem() - .padding(vertical = 10.dp, horizontal = 8.dp) - ) { - Text(cert.hash.substring(0..7)) - } - HorizontalDivider() - } - item { - Spacer(Modifier.height(BottomPadding)) - } - } - if (selectedCert != null && (dialog == 1 || dialog == 2)) { - val cert = selectedCert!! - AlertDialog( - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - Text("Serial number", style = typography.labelLarge) - SelectionContainer { Text(cert.serialNumber) } - Text("Subject", style = typography.labelLarge) - SelectionContainer { Text(cert.subject) } - Text("Issuer", style = typography.labelLarge) - SelectionContainer { Text(cert.issuer) } - Text("Issued on", style = typography.labelLarge) - SelectionContainer { Text(formatDate(cert.issuedTime)) } - Text("Expires on", style = typography.labelLarge) - SelectionContainer { Text(formatDate(cert.expiresTime)) } - Text("SHA-256 fingerprint", style = typography.labelLarge) - SelectionContainer { Text(cert.hash) } - if (dialog == 2) Row( - Modifier - .fillMaxWidth() - .padding(top = 4.dp), Arrangement.SpaceBetween - ) { - TextButton( - onClick = { - uninstallCert() - dialog = 0 - }, - modifier = Modifier.fillMaxWidth(0.49F), - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) - ) { - Text(stringResource(R.string.uninstall)) - } - FilledTonalButton( - onClick = { - exportCertLauncher.launch(cert.hash.substring(0..7) + ".0") - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.export)) - } - } - } - }, - confirmButton = { - if (dialog == 1) { - TextButton({ - context.showOperationResultToast(installCert()) - dialog = 0 - }) { - Text(stringResource(R.string.install)) - } - } else { - TextButton({ - dialog = 0 - }) { - Text(stringResource(R.string.confirm)) - } - } - }, - dismissButton = { - if (dialog == 1) { - TextButton({ - dialog = 0 - }) { - Text(stringResource(R.string.cancel)) - } - } - }, - onDismissRequest = { dialog = 0 } - ) - } - if (dialog == 3) { - AlertDialog( - text = { - Text(stringResource(R.string.uninstall_all_user_ca_cert)) - }, - confirmButton = { - TextButton({ - uninstallAllCerts() - dialog = 0 - }) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton({ - dialog = 0 - }) { - Text(stringResource(R.string.cancel)) - } - }, - onDismissRequest = { dialog = 0 } - ) - } - } -} - -@Serializable object SecurityLogging - -@RequiresApi(24) -@Composable -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 - 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 exportPRLogsLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/json") - ) { - if (it != null) { - exporting = true - exportPRLogs(it) { - exporting = false - context.showOperationResultToast(true) - } - } - } - MyScaffold(R.string.security_logging, onNavigateUp, 0.dp) { - SwitchItem( - R.string.enable, enabled, { - setEnabled(it) - enabled = it - } - ) - Text( - stringResource(R.string.n_logs_in_total, logsCount), - Modifier.padding(HorizontalPadding) - ) - Button( - { - val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) - exportLauncher.launch("security_logs_$date") - }, - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), - logsCount > 0 - ) { - Text(stringResource(R.string.export_logs)) - } - if (logsCount > 0) FilledTonalButton( - { dialog = true }, - Modifier.fillMaxWidth().padding(HorizontalPadding, 4.dp) - ) { - Text(stringResource(R.string.delete_logs)) - } - Notes(R.string.info_security_log, HorizontalPadding) - Button( - onClick = { - if (getPRLogs()) { - val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) - exportPRLogsLauncher.launch("pre_reboot_security_logs_$date") - } else { - context.showOperationResultToast(false) - } - }, - modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 15.dp) - ) { - Text(stringResource(R.string.pre_reboot_security_logs)) - } - Notes(R.string.info_pre_reboot_security_log, HorizontalPadding) - } - if (exporting) CircularProgressDialog { 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( - 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) { - - Column(modifier = Modifier.animateContentSize()) { - for(i in list) { - ListItem(i) { - setMdAccount(i, false) - } - } - } - var inputText by remember{ mutableStateOf("") } - OutlinedTextField( - value = inputText, - onValueChange = { inputText = it }, - label = { Text(stringResource(R.string.account_type)) }, - trailingIcon = { - IconButton( - onClick = { - setMdAccount(inputText, true) - inputText = "" - }, - enabled = inputText != "" - ) { - Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add)) - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) - ) - Spacer(Modifier.padding(vertical = 10.dp)) - Notes(R.string.info_disable_account_management) - } -} - -data class FrpPolicyInfo( - val supported: Boolean, - val usePolicy: Boolean, - val enabled: Boolean, - val accounts: List -) - -@Serializable object FrpPolicy - -@RequiresApi(30) -@Composable -fun FrpPolicyScreen( - frpPolicy: FrpPolicyInfo, setFrpPolicy: (FrpPolicyInfo) -> Unit, - onNavigateUp: () -> Unit -) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - 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(HorizontalPadding, 8.dp) - .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.primaryContainer) - ) { - Text(stringResource(R.string.frp_not_supported), Modifier.padding(8.dp), color = colorScheme.onPrimaryContainer) - } - } else { - SwitchItem(R.string.use_policy, usePolicy, { usePolicy = 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)) - for(i in accountList) { - ListItem(i) { accountList -= i } - } - } - OutlinedTextField( - value = inputAccount, - onValueChange = { inputAccount = it }, - label = { Text(stringResource(R.string.account)) }, - trailingIcon = { - IconButton( - onClick = { - accountList += inputAccount - inputAccount = "" - }, - enabled = inputAccount.isNotBlank() - ) { - Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add)) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), - 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)) - } - } - } - Notes(R.string.info_frp_policy, HorizontalPadding) - } -} - -@Serializable object WipeData - -@Composable -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 rememberSaveable { mutableIntStateOf(0) } - var dialog by rememberSaveable { mutableIntStateOf(0) } // 0: none, 1: wipe data, 2: wipe device - var reason by rememberSaveable { mutableStateOf("") } - MyScaffold(R.string.wipe_data, onNavigateUp, 0.dp) { - FullWidthCheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { - flag = flag xor WIPE_EXTERNAL_STORAGE - } - if (privilege.device) FullWidthCheckBoxItem( - R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0) { - flag = flag xor WIPE_RESET_PROTECTION_DATA - } - if(VERSION.SDK_INT >= 28) FullWidthCheckBoxItem(R.string.wipe_euicc, - flag and WIPE_EUICC != 0) { - flag = flag xor WIPE_EUICC - } - if (VERSION.SDK_INT < 34 || !userManager.isSystemUser) { - if(VERSION.SDK_INT >= 29) CheckBoxItem(R.string.wipe_silently, flag and WIPE_SILENTLY != 0) { - flag = flag xor WIPE_SILENTLY - reason = "" - } - AnimatedVisibility(flag and WIPE_SILENTLY != 0 && VERSION.SDK_INT >= 28) { - OutlinedTextField( - value = reason, onValueChange = { reason = it }, - label = { Text(stringResource(R.string.reason)) }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) - } - Button( - onClick = { - focusMgr.clearFocus() - dialog = 1 - }, - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 5.dp) - ) { - Text("WipeData") - } - } - if (VERSION.SDK_INT >= 34 && privilege.device) { - Button( - onClick = { - focusMgr.clearFocus() - dialog = 2 - }, - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - modifier = Modifier.fillMaxWidth().padding(HorizontalPadding, 5.dp) - ) { - Text("WipeDevice") - } - } - } - if (dialog != 0) { - AlertDialog( - title = { - Text(text = stringResource(R.string.warning), color = colorScheme.error) - }, - text = { - Text( - text = stringResource( - if (userManager.isSystemUser) R.string.wipe_data_warning - else R.string.info_wipe_data_in_managed_user - ), - color = colorScheme.error - ) - }, - onDismissRequest = { dialog = 0 }, - confirmButton = { - var timer by remember { mutableIntStateOf(6) } - LaunchedEffect(Unit) { - while(timer > 0) { - timer -= 1 - delay(1000) - } - } - val timerText = if(timer > 0) "(${timer}s)" else "" - TextButton( - onClick = { - wipeData(dialog == 2, flag, reason) - }, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error), - modifier = Modifier.animateContentSize(), - enabled = timer == 0 - ) { - Text(stringResource(R.string.confirm) + timerText) - } - }, - dismissButton = { - TextButton(onClick = { dialog = 0 }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } -} - -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 - -@Composable -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) { - FullWidthRadioButtonItem(R.string.none, policyType == -1) { policyType = -1 } - FullWidthRadioButtonItem( - R.string.system_update_policy_automatic, - policyType == TYPE_INSTALL_AUTOMATIC - ) { policyType = TYPE_INSTALL_AUTOMATIC } - FullWidthRadioButtonItem( - R.string.system_update_policy_install_windowed, - policyType == TYPE_INSTALL_WINDOWED - ) { policyType = TYPE_INSTALL_WINDOWED } - FullWidthRadioButtonItem( - R.string.system_update_policy_postpone, - 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 - ) { - OutlinedTextField( - value = windowedPolicyStart, - label = { Text(stringResource(R.string.start_time)) }, - onValueChange = { windowedPolicyStart = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth(0.49F) - ) - OutlinedTextField( - value = windowedPolicyEnd, - 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) - ) - } - Text(stringResource(R.string.minutes_in_one_day), - color = colorScheme.onSurfaceVariant, style = typography.bodyMedium) - } - } - Button( - onClick = { - setPolicy(SystemUpdatePolicyInfo( - policyType, windowedPolicyStart.toIntOrNull() ?: 0, - windowedPolicyEnd.toIntOrNull() ?: 0 - )) - context.showOperationResultToast(true) - }, - modifier = Modifier - .fillMaxWidth() - .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) { - Column(Modifier.padding(HorizontalPadding)) { - if (pendingUpdate.exists) { - Text(stringResource(R.string.update_received_time, formatDate(pendingUpdate.time))) - Text(stringResource(R.string.is_security_patch, - stringResource(pendingUpdate.securityPatch.yesOrNo))) - } else { - Text(text = stringResource(R.string.no_system_update)) - } - } - } - } -} - -@Serializable object InstallSystemUpdate - -@SuppressLint("NewApi") -@Composable -fun InstallSystemUpdateScreen( - installSystemUpdate: (Uri, (String) -> Unit) -> Unit, onNavigateUp: () -> Unit -) { - var uri by remember { mutableStateOf(null) } - var installing by rememberSaveable { mutableStateOf(false) } - var errorMessage by rememberSaveable { mutableStateOf(null) } - val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri = it } - MyScaffold(R.string.install_system_update, onNavigateUp) { - Button( - onClick = { - getFileLauncher.launch("application/zip") - }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) { - Text(stringResource(R.string.select_ota_package)) - } - 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) - } - ErrorDialog(errorMessage) { errorMessage = null } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt deleted file mode 100644 index beb3cb3..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt +++ /dev/null @@ -1,404 +0,0 @@ -package com.bintianqi.owndroid.dpm - -import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyManager.WIPE_EUICC -import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE -import android.content.Intent -import android.os.Binder -import android.os.Build.VERSION -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -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 -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.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.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.Privilege -import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.showOperationResultToast -import com.bintianqi.owndroid.ui.CheckBoxItem -import com.bintianqi.owndroid.ui.CircularProgressDialog -import com.bintianqi.owndroid.ui.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.yesOrNo -import kotlinx.serialization.Serializable - -@Serializable object WorkProfile - -@Composable -fun WorkProfileScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { - val privilege by Privilege.status.collectAsStateWithLifecycle() - MyScaffold(R.string.work_profile, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 30 && !privilege.org) { - FunctionItem(R.string.org_owned_work_profile, icon = R.drawable.corporate_fare_fill0) { onNavigate(OrganizationOwnedProfile) } - } - if(privilege.org) { - FunctionItem(R.string.suspend_personal_app, icon = R.drawable.block_fill0) { onNavigate(SuspendPersonalApp) } - } - FunctionItem(R.string.intent_filter, icon = R.drawable.filter_alt_fill0) { onNavigate(CrossProfileIntentFilter) } - FunctionItem(R.string.delete_work_profile, icon = R.drawable.delete_forever_fill0) { onNavigate(DeleteWorkProfile) } - } -} - -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( - createIntent: (CreateWorkProfileOptions) -> Intent, onNavigateUp: () -> Unit -) { - val focusMgr = LocalFocusManager.current - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - 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) } - FullWidthCheckBoxItem(R.string.migrate_account, migrateAccount) { migrateAccount = it } - AnimatedVisibility(migrateAccount) { - val fr = FocusRequester() - Column(modifier = Modifier.padding(start = 10.dp)) { - OutlinedTextField( - value = migrateAccountName, onValueChange = { migrateAccountName = it }, - label = { Text(stringResource(R.string.account_name)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() }, - modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) - ) - OutlinedTextField( - value = migrateAccountType, onValueChange = { migrateAccountType = it }, - label = { Text(stringResource(R.string.account_type)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { focusMgr.clearFocus() }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = HorizontalPadding) - .focusRequester(fr) - ) - if(VERSION.SDK_INT >= 26) { - FullWidthCheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = it } - } - } - } - 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 = { - val intent = createIntent(CreateWorkProfileOptions( - skipEncrypt, offlineProvisioning, migrateAccount, migrateAccountName, - migrateAccountType, keepAccount - )) - launcher.launch(intent) - }, - modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) - ) { - Text(stringResource(R.string.create)) - } - } -} - -@Serializable object OrganizationOwnedProfile - -@RequiresApi(30) -@Composable -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({ - activating = true - onActivate { - activating = false - context.showOperationResultToast(it) - if (it) onNavigateUp() - } - }) { - Text(stringResource(R.string.shizuku)) - } - Button({ dialog = true }) { Text(stringResource(R.string.adb_command)) } - if(dialog) AlertDialog( - text = { - SelectionContainer { - Text(activateOrgProfileCommand) - } - }, - confirmButton = { - TextButton({ dialog = false }) { Text(stringResource(R.string.confirm)) } - }, - onDismissRequest = { dialog = false } - ) - if (activating) CircularProgressDialog { } - } -} - -val activateOrgProfileCommand = "dpm mark-profile-owner-on-organization-owned-device --user " + - "${Binder.getCallingUid()/100000} com.bintianqi.owndroid/com.bintianqi.owndroid.Receiver" - -@Serializable object SuspendPersonalApp - -@RequiresApi(30) -@Composable -fun SuspendPersonalAppScreen( - getSuspendedReasons: () -> Int, setSuspended: (Boolean) -> Unit, getMaxTime: () -> Long, - setMaxTime: (Long) -> Unit, onNavigateUp: () -> Unit -) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - 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 = reason != 0, - onCheckedChange = { - setSuspended(it) - reason = if (it) DevicePolicyManager.PERSONAL_APPS_SUSPENDED_EXPLICITLY - else DevicePolicyManager.PERSONAL_APPS_NOT_SUSPENDED - }, padding = false - ) - 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(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() }) - ) - Button( - onClick = { - setMaxTime(time.toLong()) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(), - enabled = time.toLongOrNull() != null - ) { - Text(stringResource(R.string.apply)) - } - Notes(R.string.info_profile_maximum_time_off) - } -} - -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( - 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) { - OutlinedTextField( - value = action, onValueChange = { action = it }, - label = { Text("Action") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() - ) - 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( - { - addFilter(IntentFilterOptions( - action, category, mimeType, direction - )) - context.showOperationResultToast(true) - }, - Modifier.fillMaxWidth(), - enabled = action.isNotBlank() && (!customCategory || category.isNotBlank()) && - (!customMimeType || mimeType.isNotBlank()) - ) { - Text(stringResource(R.string.add)) - } - Button( - onClick = { - Privilege.DPM.clearCrossProfileIntentFilters(Privilege.DAR) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) - ) { - Text(stringResource(R.string.clear_cross_profile_filters)) - } - Notes(R.string.info_cross_profile_intent_filter) - } -} - -@Serializable object DeleteWorkProfile - -@Composable -fun DeleteWorkProfileScreen( - deleteProfile: (Boolean, Int, String) -> Unit, onNavigateUp: () -> Unit -) { - val focusMgr = LocalFocusManager.current - var flags by remember { mutableIntStateOf(0) } - var warning by remember { mutableStateOf(false) } - var reason by remember { mutableStateOf("") } - MyScaffold(R.string.delete_work_profile, onNavigateUp) { - 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() - warning = true - }, - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error, contentColor = colorScheme.onError), - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.delete)) - } - } - if (warning) { - AlertDialog( - title = { - Text(text = stringResource(R.string.warning), color = colorScheme.error) - }, - text = { - Text(text = stringResource(R.string.wipe_work_profile_warning), color = colorScheme.error) - }, - onDismissRequest = { warning = false }, - confirmButton = { - TextButton( - onClick = { - deleteProfile(false, flags, reason) - }, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) - ) { - Text(stringResource(R.string.confirm)) - } - }, - dismissButton = { - TextButton(onClick = { warning = false }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserScreen.kt similarity index 53% rename from app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt rename to app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserScreen.kt index 4f34429..181e41a 100644 --- a/app/src/main/java/com/bintianqi/owndroid/PackageChooser.kt +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserScreen.kt @@ -1,9 +1,6 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.feature.applications import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable -import android.os.Build import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -23,25 +20,26 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,7 +54,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource @@ -64,129 +61,92 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.searchInString import com.google.accompanist.drawablepainter.rememberDrawablePainter -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.serialization.Serializable - -data class AppInfo( - val name: String, - val label: String, - val icon: Drawable, - val flags: Int -) - -private fun searchInString(query: String, content: String) - = query.split(' ').all { content.contains(it, true) } - -@Serializable data class ApplicationsList(val canSwitchView: Boolean, val multiSelect: Boolean) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun AppChooserScreen( - params: ApplicationsList, packageList: MutableStateFlow>, - refreshProgress: MutableStateFlow, onChoosePackage: (String?) -> Unit, - onSwitchView: () -> Unit, onRefresh: () -> Unit, - setPackagesSuspend: (List, Boolean) -> Unit, - setPackagesHidden: (List, Boolean) -> Unit, + params: Destination.ApplicationsList, vm: AppChooserViewModel, + onChoosePackage: (String?) -> Unit, onSwitchView: () -> Unit, ) { - val packages by packageList.collectAsStateWithLifecycle() - val context = LocalContext.current + val packages by vm.packagesState.collectAsStateWithLifecycle() val hf = LocalHapticFeedback.current - val progress by refreshProgress.collectAsStateWithLifecycle() - var system by rememberSaveable { mutableStateOf(false) } + val progress by vm.progressState.collectAsStateWithLifecycle() + var showUserApps by rememberSaveable { mutableStateOf(true) } + var showSystemApps by rememberSaveable { mutableStateOf(false) } var query by rememberSaveable { mutableStateOf("") } var searchMode by rememberSaveable { mutableStateOf(false) } val filteredPackages = packages.filter { - system == (it.flags and ApplicationInfo.FLAG_SYSTEM != 0) && - (query.isEmpty() || (searchInString(query, it.label) || searchInString(query, it.name))) + ((showUserApps && it.flags and ApplicationInfo.FLAG_SYSTEM == 0) || + (showSystemApps && it.flags and ApplicationInfo.FLAG_SYSTEM != 0)) && + (!searchMode || query.isBlank() || searchInString(query, it.name) || + searchInString(query, it.label)) } val selectedPackages = remember { mutableStateListOf() } val focusMgr = LocalFocusManager.current LaunchedEffect(Unit) { - if(packages.size <= 1) onRefresh() + if (packages.size <= 1) vm.refreshPackageList() } Scaffold( topBar = { TopAppBar( actions = { - if(!searchMode) { - IconButton({ searchMode = true }) { - Icon(painter = painterResource(R.drawable.search_fill0), contentDescription = stringResource(R.string.search)) - } + if (!searchMode) IconButton({ searchMode = true }) { + Icon(painterResource(R.drawable.search_fill0), stringResource(R.string.search)) + } + var dropdown by remember { mutableStateOf(false) } + Box { IconButton({ - system = !system - context.popToast(if(system) R.string.show_system_app else R.string.show_user_app) + dropdown = !dropdown }) { - Icon(painterResource(R.drawable.filter_alt_fill0), null) + Icon(Icons.Default.MoreVert, null) } - if (selectedPackages.isEmpty()) { - IconButton(onRefresh, enabled = progress == 1F) { - Icon(Icons.Default.Refresh, null) - } - if (params.canSwitchView) IconButton(onSwitchView) { - Icon(Icons.AutoMirrored.Default.List, null) + DropdownMenu(dropdown, { dropdown = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.refresh)) }, + { + vm.refreshPackageList() + dropdown = false + }, + leadingIcon = { Icon(Icons.Default.Refresh, null) } + ) + HorizontalDivider() + DropdownMenuItem( + { Text(stringResource(R.string.user_apps)) }, + { showUserApps = !showUserApps }, + leadingIcon = { Checkbox(showUserApps, null) } + ) + DropdownMenuItem( + { Text(stringResource(R.string.system_apps)) }, + { showSystemApps = !showSystemApps }, + leadingIcon = { Checkbox(showSystemApps, null) } + ) + if (params.canSwitchView) { + HorizontalDivider() + DropdownMenuItem( + { Text(stringResource(R.string.apps_view)) }, + {}, + leadingIcon = { RadioButton(true, null) } + ) + DropdownMenuItem( + { Text(stringResource(R.string.features_view)) }, + { + dropdown = false + onSwitchView() + }, + leadingIcon = { RadioButton(false, null) } + ) } } } if (selectedPackages.isNotEmpty()) { - if (params.canSwitchView) { - var dropdown by remember { mutableStateOf(false) } - Box { - IconButton({ - dropdown = !dropdown - }) { - Icon(Icons.Default.MoreVert, null) - } - DropdownMenu(dropdown, { dropdown = false }) { - if (Build.VERSION.SDK_INT >= 24) { - DropdownMenuItem( - { Text(stringResource(R.string.suspend)) }, - { - setPackagesSuspend(selectedPackages.map { it.name }, true) - dropdown = false - selectedPackages.clear() - }, - leadingIcon = { - Icon(painterResource(R.drawable.block_fill0), null) - } - ) - DropdownMenuItem( - { Text(stringResource(R.string.unsuspend)) }, - { - setPackagesSuspend(selectedPackages.map { it.name }, false) - dropdown = false - selectedPackages.clear() - }, - leadingIcon = { - Icon(painterResource(R.drawable.enable_fill0), null) - } - ) - } - DropdownMenuItem( - { Text(stringResource(R.string.hide)) }, - { - setPackagesHidden(selectedPackages.map { it.name }, true) - dropdown = false - selectedPackages.clear() - }, - leadingIcon = { - Icon(painterResource(R.drawable.visibility_off_fill0), null) - } - ) - DropdownMenuItem( - { Text(stringResource(R.string.unhide)) }, - { - setPackagesHidden(selectedPackages.map { it.name }, false) - dropdown = false - selectedPackages.clear() - }, - leadingIcon = { - Icon(painterResource(R.drawable.visibility_fill0), null) - } - ) - } - } - } else { + if (!params.canSwitchView) { FilledIconButton({ onChoosePackage(selectedPackages.joinToString("\n") { it.name }) }) { @@ -200,8 +160,7 @@ fun AppChooserScreen( val fr = remember { FocusRequester() } LaunchedEffect(Unit) { fr.requestFocus() } OutlinedTextField( - value = query, - onValueChange = { query = it }, + query, { query = it }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions { focusMgr.clearFocus() }, placeholder = { Text(stringResource(R.string.search)) }, @@ -214,7 +173,9 @@ fun AppChooserScreen( } }, textStyle = typography.bodyLarge, - modifier = Modifier.fillMaxWidth().focusRequester(fr) + modifier = Modifier + .fillMaxWidth() + .focusRequester(fr) ) } else { if (selectedPackages.isNotEmpty()) { @@ -226,13 +187,16 @@ fun AppChooserScreen( IconButton({ onChoosePackage(null) }) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } - }, - colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) + } ) }, contentWindowInsets = adaptiveInsets() ) { paddingValues -> - LazyColumn(Modifier.fillMaxSize().padding(paddingValues)) { + LazyColumn( + Modifier + .fillMaxSize() + .padding(paddingValues) + ) { if (progress < 1F) stickyHeader { LinearProgressIndicator({ progress }, Modifier.fillMaxWidth()) } @@ -264,7 +228,9 @@ fun AppChooserScreen( ) { Image( painter = rememberDrawablePainter(it.icon), contentDescription = null, - modifier = Modifier.padding(start = 12.dp, end = 18.dp).size(40.dp) + modifier = Modifier + .padding(start = 12.dp, end = 18.dp) + .size(40.dp) ) Column { Text(text = it.label, style = typography.titleLarge) @@ -276,6 +242,3 @@ fun AppChooserScreen( } } } - -val getInstalledAppsFlags = - if(Build.VERSION.SDK_INT >= 24) PackageManager.MATCH_DISABLED_COMPONENTS or PackageManager.MATCH_UNINSTALLED_PACKAGES else 0 diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserViewModel.kt new file mode 100644 index 0000000..e4cc45a --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppChooserViewModel.kt @@ -0,0 +1,36 @@ +package com.bintianqi.owndroid.feature.applications + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.getAppInfo +import com.bintianqi.owndroid.utils.getInstalledAppsFlags +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AppChooserViewModel(val application: MyApplication) : ViewModel() { + val packagesState = MutableStateFlow(emptyList()) + val progressState = MutableStateFlow(0F) + + fun refreshPackageList() { + viewModelScope.launch(Dispatchers.IO) { + packagesState.value = emptyList() + val apps = application.packageManager.getInstalledApplications(getInstalledAppsFlags) + apps.forEachIndexed { index, info -> + packagesState.update { + it + getAppInfo(application.packageManager, info) + } + progressState.value = (index + 1).toFloat() / apps.size + } + } + } + + fun onPackageRemoved(name: String) { + packagesState.update { list -> + list.filter { it.name != name } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsModel.kt new file mode 100644 index 0000000..31982a8 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsModel.kt @@ -0,0 +1,10 @@ +package com.bintianqi.owndroid.feature.applications + +data class AppDetailsUiState( + val suspend: Boolean = false, + val hide: Boolean = false, + val uninstallBlocked: Boolean = false, + val userControlDisabled: Boolean = false, + val meteredDataDisabled: Boolean = false, + val keepUninstalled: Boolean = false +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsScreen.kt new file mode 100644 index 0000000..a19dc0f --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsScreen.kt @@ -0,0 +1,158 @@ +package com.bintianqi.owndroid.feature.applications + +import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT +import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED +import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED +import android.os.Build.VERSION +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.FunctionItem +import com.bintianqi.owndroid.ui.MyLazyScaffold +import com.bintianqi.owndroid.ui.MySmallTitleScaffold +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.PermissionItem +import com.bintianqi.owndroid.utils.runtimePermissions +import com.google.accompanist.drawablepainter.rememberDrawablePainter + +@Composable +fun ApplicationDetailsScreen( + vm: AppDetailsViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + var dialog by rememberSaveable { mutableIntStateOf(0) } // 1: clear storage, 2: uninstall + val uiState by vm.uiState.collectAsStateWithLifecycle() + MySmallTitleScaffold(R.string.place_holder, onNavigateUp, 0.dp) { + Column( + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally + ) { + Image(rememberDrawablePainter(vm.appInfo.icon), null, Modifier.size(50.dp)) + Text(vm.appInfo.label, Modifier.padding(top = 4.dp)) + Text( + vm.appInfo.name, + Modifier + .alpha(0.7F) + .padding(bottom = 8.dp), + style = typography.bodyMedium + ) + } + FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) { + onNavigate(Destination.AppPermissionsManager(vm.packageName)) + } + if (VERSION.SDK_INT >= 24) SwitchItem( + R.string.suspend, uiState.suspend, vm::setSuspended, R.drawable.block_fill0 + ) + SwitchItem( + R.string.hide, uiState.hide, vm::setHidden, R.drawable.visibility_off_fill0 + ) + SwitchItem( + R.string.block_uninstall, uiState.uninstallBlocked, + vm::setUninstallBlocked, R.drawable.delete_forever_fill0 + ) + if (VERSION.SDK_INT >= 30) SwitchItem( + R.string.disable_user_control, uiState.userControlDisabled, + vm::setUserControlDisabled, R.drawable.do_not_touch_fill0 + ) + if (VERSION.SDK_INT >= 28) SwitchItem( + R.string.disable_metered_data, uiState.meteredDataDisabled, + vm::setMeteredDataDisabled, R.drawable.money_off_fill0 + ) + if (privilege.device && VERSION.SDK_INT >= 28) SwitchItem( + R.string.keep_after_uninstall, uiState.keepUninstalled, + vm::setKeepUninstalled, R.drawable.delete_fill0 + ) + FunctionItem(R.string.managed_configuration, icon = R.drawable.description_fill0) { + onNavigate(Destination.ManagedConfiguration(vm.packageName)) + } + if (VERSION.SDK_INT >= 28) FunctionItem( + R.string.clear_app_storage, icon = R.drawable.mop_fill0 + ) { dialog = 1 } + FunctionItem(R.string.uninstall, icon = R.drawable.delete_fill0) { dialog = 2 } + Spacer(Modifier.height(BottomPadding)) + } + if (dialog == 1 && VERSION.SDK_INT >= 28) + ClearAppStorageDialog({ + vm.clearData { dialog = 0 } + }) { dialog = 0 } + if (dialog == 2) UninstallAppDialog(vm::uninstall) { + dialog = 0 + if (it) onNavigateUp() + } +} + +@Composable +fun AppPermissionsManagerScreen( + vm: AppDetailsViewModel, onNavigateUp: () -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + var selectedPermission by remember { mutableStateOf(null) } + val permissions by vm.permissionsState.collectAsState() + MyLazyScaffold(R.string.permissions, onNavigateUp) { + items(runtimePermissions) { + Row( + Modifier + .fillMaxWidth() + .clickable { + selectedPermission = it + } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp)) + Column { + val stateStr = when (permissions[it.id]) { + PERMISSION_GRANT_STATE_DEFAULT -> R.string.default_stringres + PERMISSION_GRANT_STATE_GRANTED -> R.string.granted + PERMISSION_GRANT_STATE_DENIED -> R.string.denied + else -> R.string.unknown + } + Text(stringResource(it.label)) + Text( + stringResource(stateStr), Modifier.alpha(0.7F), + style = typography.bodyMedium + ) + } + } + } + item { + Spacer(Modifier.height(BottomPadding)) + } + } + if (selectedPermission != null) PackagePermissionDialog( + selectedPermission!!, permissions[selectedPermission!!.id]!!, privilege.profile, + { + vm.setPermission(selectedPermission!!.id, it) + selectedPermission = null + } + ) { selectedPermission = null } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsViewModel.kt new file mode 100644 index 0000000..940f245 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppDetailsViewModel.kt @@ -0,0 +1,126 @@ +package com.bintianqi.owndroid.feature.applications + +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import com.bintianqi.owndroid.utils.getAppInfo +import com.bintianqi.owndroid.utils.plusOrMinus +import com.bintianqi.owndroid.utils.runtimePermissions +import com.bintianqi.owndroid.utils.uninstallPackage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class AppDetailsViewModel( + val packageName: String, val application: MyApplication, val ph: PrivilegeHelper, + val privilegeState: StateFlow, val toastChannel: ToastChannel +) : ViewModel() { + val appInfo = getAppInfo(application.packageManager, packageName) + val uiState = MutableStateFlow(AppDetailsUiState()) + + init { + getStatus() + } + + fun getStatus() = ph.safeDpmCall { + uiState.value = AppDetailsUiState( + if (VERSION.SDK_INT >= 24) dpm.isPackageSuspended(dar, packageName) else false, + dpm.isApplicationHidden(dar, packageName), + dpm.isUninstallBlocked(dar, packageName), + if (VERSION.SDK_INT >= 30) packageName in dpm.getUserControlDisabledPackages(dar) + else false, + if (VERSION.SDK_INT >= 28) packageName in dpm.getMeteredDataDisabledPackages(dar) + else false, + if (VERSION.SDK_INT >= 28 && privilegeState.value.device) + dpm.getKeepUninstalledPackages(dar)?.contains(packageName) == true + else false + ) + } + + @RequiresApi(24) + fun setSuspended(status: Boolean) = ph.safeDpmCall { + try { + dpm.setPackagesSuspended(dar, arrayOf(packageName), status) + uiState.update { it.copy(suspend = dpm.isPackageSuspended(dar, packageName)) } + } catch (_: Exception) { + } + } + + fun setHidden(status: Boolean) = ph.safeDpmCall { + dpm.setApplicationHidden(dar, packageName, status) + uiState.update { it.copy(hide = dpm.isApplicationHidden(dar, packageName)) } + } + + fun setUninstallBlocked(status: Boolean) = ph.safeDpmCall { + dpm.setUninstallBlocked(dar, packageName, status) + uiState.update { it.copy(uninstallBlocked = dpm.isUninstallBlocked(dar, packageName)) } + } + + @RequiresApi(30) + fun setUserControlDisabled(state: Boolean) = ph.safeDpmCall { + dpm.setUserControlDisabledPackages( + dar, + dpm.getUserControlDisabledPackages(dar).plusOrMinus(state, packageName) + ) + uiState.update { + it.copy(userControlDisabled = packageName in dpm.getUserControlDisabledPackages(dar)) + } + } + + @RequiresApi(28) + fun setMeteredDataDisabled(state: Boolean) = ph.safeDpmCall { + dpm.setMeteredDataDisabledPackages( + dar, + dpm.getMeteredDataDisabledPackages(dar).plusOrMinus(state, packageName) + ) + uiState.update { + it.copy(meteredDataDisabled = packageName in dpm.getMeteredDataDisabledPackages(dar)) + } + } + + @RequiresApi(28) + fun setKeepUninstalled(state: Boolean) = ph.safeDpmCall { + dpm.setKeepUninstalledPackages( + dar, + (dpm.getKeepUninstalledPackages(dar) ?: emptyList()).plusOrMinus(state, packageName) + ) + uiState.update { + it.copy( + keepUninstalled = dpm.getKeepUninstalledPackages(dar)?.contains(packageName) == true + ) + } + } + + val permissionsState = MutableStateFlow(emptyMap()) + + fun getPermissions() = ph.safeDpmCall { + permissionsState.value = runtimePermissions.associate { + it.id to dpm.getPermissionGrantState(dar, packageName, it.id) + } + } + + fun setPermission(permission: String, status: Int) = ph.safeDpmCall { + val result = dpm.setPermissionGrantState(dar, packageName, permission, status) + if (result) { + getPermissions() + } else { + toastChannel.sendStatus(false) + } + } + + @RequiresApi(28) + fun clearData(callback: () -> Unit) = ph.safeDpmCall { + dpm.clearApplicationUserData(dar, packageName, application.mainExecutor) { _, result -> + callback() + toastChannel.sendStatus(result) + } + } + + fun uninstall(callback: (String?) -> Unit) { + uninstallPackage(application, ph, packageName, callback) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesScreen.kt new file mode 100644 index 0000000..f81ecba --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesScreen.kt @@ -0,0 +1,781 @@ +package com.bintianqi.owndroid.feature.applications + +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.AppInstallerActivity +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.FunctionItem +import com.bintianqi.owndroid.ui.MyLazyScaffold +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.PackageNameTextField +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.isValidPackageName +import com.bintianqi.owndroid.utils.parsePackageNames +import com.bintianqi.owndroid.utils.runtimePermissions +import com.bintianqi.owndroid.utils.searchInString +import com.bintianqi.owndroid.utils.showOperationResultToast +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ApplicationsFeaturesScreen( + vm: AppFeaturesViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit, + onSwitchView: () -> Unit +) { + val context = LocalContext.current + val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( + Modifier.nestedScroll(sb.nestedScrollConnection), + topBar = { + LargeTopAppBar( + { Text(stringResource(R.string.applications)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + actions = { + Box { + var dropdown by remember { mutableStateOf(false) } + IconButton({ dropdown = true }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(dropdown, { dropdown = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.apps_view)) }, + { + dropdown = false + onSwitchView() + }, + leadingIcon = { RadioButton(false, null) } + ) + DropdownMenuItem( + { Text(stringResource(R.string.features_view)) }, + {}, + leadingIcon = { RadioButton(true, null) } + ) + } + } + }, + scrollBehavior = sb + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(bottom = 80.dp) + ) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + if (VERSION.SDK_INT >= 24) FunctionItem( + R.string.suspend, icon = R.drawable.block_fill0 + ) { + onNavigate(Destination.Suspend) + } + FunctionItem(R.string.hide, icon = R.drawable.visibility_off_fill0) { + onNavigate(Destination.Hide) + } + FunctionItem(R.string.block_uninstall, icon = R.drawable.delete_forever_fill0) { + onNavigate(Destination.BlockUninstall) + } + if (VERSION.SDK_INT >= 30 && (privilege.device || (VERSION.SDK_INT >= 33 && privilege.profile))) { + FunctionItem(R.string.disable_user_control, icon = R.drawable.do_not_touch_fill0) { + onNavigate(Destination.DisableUserControl) + } + } + FunctionItem(R.string.permissions, icon = R.drawable.shield_fill0) { + onNavigate(Destination.PermissionManager) + } + if (VERSION.SDK_INT >= 28) { + FunctionItem(R.string.disable_metered_data, icon = R.drawable.money_off_fill0) { + onNavigate(Destination.DisableMeteredData) + } + } + if (VERSION.SDK_INT >= 28) { + FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { + onNavigate(Destination.ClearAppStorage) + } + } + FunctionItem(R.string.install_app, icon = R.drawable.install_mobile_fill0) { + context.startActivity(Intent(context, AppInstallerActivity::class.java)) + } + FunctionItem(R.string.uninstall_app, icon = R.drawable.delete_fill0) { + onNavigate(Destination.UninstallApp) + } + if (VERSION.SDK_INT >= 28 && privilege.device) { + FunctionItem(R.string.keep_uninstalled_packages, icon = R.drawable.delete_fill0) { + onNavigate(Destination.KeepUninstalledPackages) + } + } + if (VERSION.SDK_INT >= 28 && (privilege.device || (privilege.profile && privilege.affiliated))) { + FunctionItem( + R.string.install_existing_app, icon = R.drawable.install_mobile_fill0 + ) { + onNavigate(Destination.InstallExistingApp) + } + } + if (VERSION.SDK_INT >= 30 && privilege.work) { + FunctionItem(R.string.cross_profile_apps, icon = R.drawable.work_fill0) { + onNavigate(Destination.CrossProfilePackages) + } + } + if (privilege.work) { + FunctionItem(R.string.cross_profile_widget, icon = R.drawable.widgets_fill0) { + onNavigate(Destination.CrossProfileWidgetProviders) + } + } + if (VERSION.SDK_INT >= 34 && privilege.device) { + FunctionItem(R.string.credential_manager_policy, icon = R.drawable.license_fill0) { + onNavigate(Destination.CredentialManagerPolicy) + } + } + FunctionItem( + R.string.permitted_accessibility_services, + icon = R.drawable.settings_accessibility_fill0 + ) { + onNavigate(Destination.PermittedAccessibilityServices) + } + FunctionItem(R.string.permitted_ime, icon = R.drawable.keyboard_fill0) { + onNavigate(Destination.PermittedInputMethods) + } + FunctionItem(R.string.enable_system_app, icon = R.drawable.enable_fill0) { + onNavigate(Destination.EnableSystemApp) + } + if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.work)) { + FunctionItem(R.string.set_default_dialer, icon = R.drawable.call_fill0) { + onNavigate(Destination.SetDefaultDialer) + } + } + } + } +} + + +@Composable +fun PermissionManagerScreen( + onNavigate: (Destination.PermissionDetail) -> Unit, onNavigateUp: () -> Unit +) { + MyLazyScaffold(R.string.permissions, onNavigateUp) { + items(runtimePermissions) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onNavigate(Destination.PermissionDetail(it.id)) + } + .padding(8.dp, 12.dp) + ) { + Icon(painterResource(it.icon), null, Modifier.padding(horizontal = 12.dp)) + Text(stringResource(it.label)) + } + } + item { + Spacer(Modifier.height(BottomPadding)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionDetailScreen( + param: Destination.PermissionDetail, vm: AppFeaturesViewModel, onNavigateUp: () -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + val permissionItem = runtimePermissions.find { it.id == param.permission }!! + val packagesList by vm.permissionPackagesState.collectAsState() + var selectedPackage by remember { mutableStateOf?>(null) } + var showUserApps by rememberSaveable { mutableStateOf(true) } + var showSystemApps by rememberSaveable { mutableStateOf(false) } + var searchMode by rememberSaveable { mutableStateOf(false) } + var query by rememberSaveable { mutableStateOf("") } + val displayedPackagesList = packagesList.filter { + ((showUserApps && it.first.flags and ApplicationInfo.FLAG_SYSTEM == 0) || + (showSystemApps && it.first.flags and ApplicationInfo.FLAG_SYSTEM != 0)) && + (!searchMode || query.isBlank() || searchInString(query, it.first.name) || + searchInString(query, it.first.label)) + } + val fm = LocalFocusManager.current + LaunchedEffect(Unit) { + vm.getPermissionPackages(param.permission) + } + Scaffold( + topBar = { + TopAppBar( + { + if (searchMode) { + val fr = remember { FocusRequester() } + LaunchedEffect(Unit) { fr.requestFocus() } + OutlinedTextField( + query, { query = it }, + Modifier + .fillMaxWidth() + .focusRequester(fr), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions { fm.clearFocus() }, + placeholder = { Text(stringResource(R.string.search)) }, + trailingIcon = { + IconButton({ + query = "" + searchMode = false + }) { + Icon(Icons.Outlined.Clear, null) + } + }, + textStyle = typography.bodyLarge + ) + } else { + Text(stringResource(permissionItem.label)) + } + }, + navigationIcon = { NavIcon(onNavigateUp) }, + actions = { + if (!searchMode) { + IconButton({ searchMode = true }) { + Icon(Icons.Default.Search, null) + } + } + var menu by remember { mutableStateOf(false) } + Box { + IconButton({ menu = true }) { + Icon(painterResource(R.drawable.filter_alt_fill0), null) + } + DropdownMenu(menu, { menu = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.user_apps)) }, + { showUserApps = !showUserApps }, + leadingIcon = { Checkbox(showUserApps, null) } + ) + DropdownMenuItem( + { Text(stringResource(R.string.system_apps)) }, + { showSystemApps = !showSystemApps }, + leadingIcon = { Checkbox(showSystemApps, null) } + ) + } + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(displayedPackagesList, { it.first.name }) { (info, grantState) -> + Row( + Modifier + .fillMaxWidth() + .clickable { selectedPackage = info.name to grantState } + .padding(horizontal = 8.dp, vertical = 6.dp) + .animateItem(), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + rememberDrawablePainter(info.icon), null, + Modifier + .padding(start = 12.dp, end = 18.dp) + .size(30.dp) + ) + Column { + Text(info.label) + Text(info.name, Modifier.alpha(0.8F), style = typography.bodyMedium) + } + } + if (grantState != 0) { + Icon( + painterResource( + if (grantState == 1) R.drawable.check_circle_fill0 + else R.drawable.cancel_fill0 + ), + null + ) + } + } + } + item { + Spacer(Modifier.height(BottomPadding)) + } + } + } + if (selectedPackage != null) PackagePermissionDialog( + permissionItem, selectedPackage!!.second, privilege.profile, + { + vm.setPackagePermission(selectedPackage!!.first, param.permission, it) + selectedPackage = null + } + ) { selectedPackage = null } +} + +@RequiresApi(28) +@Composable +fun ClearAppStorageScreen( + vm: AppFeaturesViewModel, + chosenPackage: Channel, onChoosePackage: () -> Unit, onNavigateUp: () -> Unit +) { + var dialog by rememberSaveable { mutableStateOf(false) } + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + MyScaffold(R.string.clear_app_storage, onNavigateUp) { + PackageNameTextField( + packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp) + ) { packageName = it } + Button( + { dialog = true }, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.clear)) + } + } + if (dialog) ClearAppStorageDialog({ + vm.clearStorage(packageName) { dialog = false } + }) { dialog = false } +} + + +@Composable +fun UninstallAppScreen( + vm: AppFeaturesViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit, + onNavigateUp: () -> Unit +) { + var dialog by rememberSaveable { mutableStateOf(false) } + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + MyScaffold(R.string.uninstall_app, onNavigateUp) { + PackageNameTextField( + packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp) + ) { packageName = it } + Button( + { dialog = true }, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.uninstall)) + } + } + if (dialog) UninstallAppDialog({ + vm.uninstallApp(packageName, it) + }) { + packageName = "" + dialog = false + } +} + +@RequiresApi(28) +@Composable +fun InstallExistingAppScreen( + vm: AppFeaturesViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit, + onNavigateUp: () -> Unit +) { + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + MyScaffold(R.string.install_existing_app, onNavigateUp) { + PackageNameTextField( + packageName, onChoosePackage, + Modifier.padding(vertical = 8.dp) + ) { packageName = it } + Button( + { + vm.installExistingApp(packageName) + }, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.install)) + } + Notes(R.string.info_install_existing_app) + } +} + +@RequiresApi(34) +@Composable +fun CredentialManagerPolicyScreen( + vm: AppFeaturesViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit, + onNavigateUp: () -> Unit +) { + val policy by vm.cmPolicyState.collectAsState() + val packages by vm.cmPackagesState.collectAsState() + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) + LaunchedEffect(Unit) { + input = chosenPackage.receive() + } + MyLazyScaffold(R.string.credential_manager_policy, onNavigateUp) { + item { + mapOf( + -1 to R.string.none, + 1 to R.string.blacklist, + 2 to R.string.whitelist_and_system_app, + 3 to R.string.whitelist + ).forEach { (key, value) -> + FullWidthRadioButtonItem(value, policy == key) { vm.setCmPolicy(key) } + } + Spacer(Modifier.padding(vertical = 4.dp)) + } + if (policy != -1) items(packages, { it.name }) { + ApplicationItem(it) { vm.setCmPackage(listOf(it.name), false) } + } + item { + Column(Modifier.padding(horizontal = HorizontalPadding)) { + if (policy != -1) { + PackageNameTextField( + input, onChoosePackage, + Modifier.padding(vertical = 8.dp) + ) { input = it } + Button( + { + vm.setCmPackage(inputPackages, true) + input = "" + }, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.add)) + } + } + Button( + vm::applyCmPolicy, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.apply)) + } + Spacer(Modifier.height(BottomPadding)) + } + } + } +} + +@Composable +fun PermittedAsAndImPackagesScreen( + title: Int, note: Int, chosenPackage: Channel, onChoosePackage: () -> Unit, + policyState: StateFlow, packagesState: MutableStateFlow>, + getPolicy: () -> Unit, setPolicy: (Boolean) -> Unit, + setPackage: (List, Boolean) -> Unit, applyPolicy: () -> Unit, + onNavigateUp: () -> Unit +) { + val allowAll by policyState.collectAsState() + val packages by packagesState.collectAsStateWithLifecycle() + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) + var initialized by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (!initialized) { + getPolicy() + initialized = true + } + } + LaunchedEffect(Unit) { + input = chosenPackage.receive() + } + MyLazyScaffold(title, onNavigateUp) { + item { + SwitchItem(R.string.allow_all, allowAll, setPolicy) + } + if (!allowAll) items(packages, { it.name }) { + ApplicationItem(it) { setPackage(listOf(it.name), false) } + } + item { + if (!allowAll) { + PackageNameTextField( + input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp) + ) { input = it } + Button( + { + setPackage(inputPackages, true) + input = "" + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + ) { + Text(stringResource(R.string.add)) + } + } + Button( + applyPolicy, + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .padding(horizontal = HorizontalPadding) + ) { + Text(stringResource(R.string.apply)) + } + Spacer(Modifier.height(10.dp)) + Notes(note, HorizontalPadding) + Spacer(Modifier.height(BottomPadding)) + } + } +} + +@Composable +fun EnableSystemAppScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onEnable: (String) -> Unit, onNavigateUp: () -> Unit +) { + val context = LocalContext.current + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + MyScaffold(R.string.enable_system_app, onNavigateUp) { + Spacer(Modifier.padding(vertical = 4.dp)) + PackageNameTextField( + packageName, onChoosePackage, + Modifier.padding(bottom = 8.dp) + ) { packageName = it } + Button( + { + onEnable(packageName) + packageName = "" + context.showOperationResultToast(true) + }, + Modifier.fillMaxWidth(), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.enable)) + } + Notes(R.string.info_enable_system_app) + } +} + +@RequiresApi(34) +@Composable +fun SetDefaultDialerScreen( + chosenPackage: Channel, onChoosePackage: () -> Unit, + onSet: (String) -> Unit, onNavigateUp: () -> Unit +) { + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + MyScaffold(R.string.set_default_dialer, onNavigateUp) { + Spacer(Modifier.padding(vertical = 4.dp)) + PackageNameTextField( + packageName, onChoosePackage, + Modifier.padding(bottom = 8.dp) + ) { packageName = it } + Button( + { + onSet(packageName) + }, + Modifier.fillMaxWidth(), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.set)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PackageFunctionScreen( + title: Int, packagesState: MutableStateFlow>, onGet: () -> Unit, + onSet: (List, Boolean) -> Unit, onNavigateUp: () -> Unit, + chosenPackage: Channel, onChoosePackage: () -> Unit, + navigateToGroups: () -> Unit, appGroups: StateFlow>, notes: Int? = null +) { + val groups by appGroups.collectAsStateWithLifecycle() + val packages by packagesState.collectAsStateWithLifecycle() + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) + var dialog by remember { mutableStateOf(false) } + var selectedGroup by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + val res = LocalResources.current + val coroutine = rememberCoroutineScope() + LaunchedEffect(Unit) { + onGet() + input = chosenPackage.receive() + } + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(title)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + actions = { + var expand by remember { mutableStateOf(false) } + Box { + IconButton({ + expand = true + }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(expand, { expand = false }) { + groups.forEach { + DropdownMenuItem( + { Text("(${it.apps.size}) ${it.name}") }, + { + selectedGroup = it + dialog = true + expand = false + } + ) + } + if (groups.isNotEmpty()) HorizontalDivider() + DropdownMenuItem( + { Text(stringResource(R.string.manage_app_groups)) }, + { + navigateToGroups() + expand = false + } + ) + } + } + } + ) + }, + snackbarHost = { + SnackbarHost(snackbar) + } + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(packages, { it.name }) { + ApplicationItem(it) { + onSet(listOf(it.name), false) + coroutine.launch { + val result = snackbar.showSnackbar( + res.getString(R.string.package_removed, it.name), + res.getString(R.string.undo), + true, SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + onSet(listOf(it.name), true) + } + } + } + } + item { + PackageNameTextField( + input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp) + ) { input = it } + Button( + { + onSet(inputPackages, true) + input = "" + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .padding(bottom = 10.dp), + packages.none { it.name in inputPackages } + ) { + Text(stringResource(R.string.add)) + } + if (notes != null) Notes(notes, HorizontalPadding) + Spacer(Modifier.height(BottomPadding)) + } + } + } + if (dialog) AlertDialog( + text = { + Column { + Text(selectedGroup!!.name, style = typography.titleLarge) + Spacer(Modifier.height(6.dp)) + Button({ + onSet(selectedGroup!!.apps, true) + dialog = false + }) { + Text(stringResource(R.string.add_to_list)) + } + Button({ + onSet(selectedGroup!!.apps, false) + dialog = false + }) { + Text(stringResource(R.string.remove_from_list)) + } + } + }, + confirmButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesViewModel.kt new file mode 100644 index 0000000..190b7a5 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppFeaturesViewModel.kt @@ -0,0 +1,324 @@ +package com.bintianqi.owndroid.feature.applications + +import android.app.admin.PackagePolicy +import android.content.pm.PackageManager +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import com.bintianqi.owndroid.utils.getAppInfo +import com.bintianqi.owndroid.utils.getInstalledAppsFlags +import com.bintianqi.owndroid.utils.plusOrMinus +import com.bintianqi.owndroid.utils.uninstallPackage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AppFeaturesViewModel( + val application: MyApplication, val ph: PrivilegeHelper, + val privilegeState: StateFlow, + val toastChannel: ToastChannel +) : ViewModel() { + val pm = application.packageManager!! + + val suspendedPackages = MutableStateFlow(emptyList()) + + @RequiresApi(24) + fun getSuspendedPackaged() = ph.safeDpmCall { + val packages = pm.getInstalledApplications(getInstalledAppsFlags).filter { + dpm.isPackageSuspended(dar, it.packageName) + } + suspendedPackages.value = packages.map { getAppInfo(pm, it) } + } + + @RequiresApi(24) + fun setPackageSuspended(packages: List, status: Boolean) = ph.safeDpmCall { + dpm.setPackagesSuspended(dar, packages.toTypedArray(), status) + getSuspendedPackaged() + } + + val hiddenPackages = MutableStateFlow(emptyList()) + fun getHiddenPackages() = ph.safeDpmCall { + hiddenPackages.value = pm.getInstalledApplications(getInstalledAppsFlags).filter { + dpm.isApplicationHidden(dar, it.packageName) + }.map { getAppInfo(pm, it) } + } + + fun setPackageHidden(packages: List, status: Boolean) = ph.safeDpmCall { + for (name in packages) { + dpm.setApplicationHidden(dar, name, status) + } + getHiddenPackages() + } + + // Uninstall blocked packages + val ubPackages = MutableStateFlow(emptyList()) + fun getUbPackages() = ph.safeDpmCall { + ubPackages.value = pm.getInstalledApplications(getInstalledAppsFlags).filter { + dpm.isUninstallBlocked(dar, it.packageName) + }.map { getAppInfo(pm, it) } + } + + fun setPackageUb(packages: List, status: Boolean) = ph.safeDpmCall { + for (name in packages) { + dpm.setUninstallBlocked(dar, name, status) + } + getUbPackages() + } + + // User control disabled packages + val ucdPackages = MutableStateFlow(emptyList()) + + @RequiresApi(30) + fun getUcdPackages() = ph.safeDpmCall { + ucdPackages.value = dpm.getUserControlDisabledPackages(dar).distinct().map { + getAppInfo(pm, it) + } + } + + @RequiresApi(30) + fun setPackageUcd(packages: List, status: Boolean) = ph.safeDpmCall { + dpm.setUserControlDisabledPackages( + dar, + ucdPackages.value.map { it.name }.plusOrMinus(status, packages) + ) + getUcdPackages() + } + + val permissionPackagesState = MutableStateFlow(emptyList>()) + + fun getPermissionPackages(permission: String) { + viewModelScope.launch(Dispatchers.IO) { + permissionPackagesState.value = emptyList() + ph.safeDpmCall { + permissionPackagesState.value = pm.getInstalledPackages( + getInstalledAppsFlags or PackageManager.GET_PERMISSIONS + ).filter { + it.requestedPermissions?.contains(permission) ?: false + }.map { + getAppInfo(pm, it.packageName) to + dpm.getPermissionGrantState(dar, it.packageName, permission) + } + } + } + } + + fun setPackagePermission( + packageName: String, permission: String, state: Int + ) = ph.safeDpmCall { + val result = dpm.setPermissionGrantState(dar, packageName, permission, state) + if (result) { + getPermissionPackages(permission) + } else { + toastChannel.sendStatus(false) + } + } + + @RequiresApi(28) + fun clearStorage(packageName: String, callback: () -> Unit) = ph.safeDpmCall { + dpm.clearApplicationUserData(dar, packageName, application.mainExecutor) { _, result -> + callback() + toastChannel.sendStatus(result) + } + } + + fun uninstallApp(packageName: String, callback: (String?) -> Unit) { + uninstallPackage(application, ph, packageName, callback) + } + + // Metered data disabled packages + val mddPackages = MutableStateFlow(emptyList()) + + @RequiresApi(28) + fun getMddPackages() = ph.safeDpmCall { + mddPackages.value = + dpm.getMeteredDataDisabledPackages(dar).distinct().map { getAppInfo(pm, it) } + } + + @RequiresApi(28) + fun setPackageMdd(packages: List, status: Boolean) = ph.safeDpmCall { + dpm.setMeteredDataDisabledPackages( + dar, mddPackages.value.map { it.name }.plusOrMinus(status, packages) + ) + getMddPackages() + } + + // Keep uninstalled packages + val kuPackages = MutableStateFlow(emptyList()) + + @RequiresApi(28) + fun getKuPackages() = ph.safeDpmCall { + kuPackages.value = + dpm.getKeepUninstalledPackages(dar)?.distinct()?.map { getAppInfo(pm, it) } ?: emptyList() + } + + @RequiresApi(28) + fun setPackageKu(packages: List, status: Boolean) = ph.safeDpmCall { + dpm.setKeepUninstalledPackages( + dar, kuPackages.value.map { it.name }.plusOrMinus(status, packages) + ) + getKuPackages() + } + + // Cross profile packages + val cpPackages = MutableStateFlow(emptyList()) + + @RequiresApi(30) + fun getCpPackages() = ph.safeDpmCall { + cpPackages.value = dpm.getCrossProfilePackages(dar).map { getAppInfo(pm, it) } + } + + @RequiresApi(30) + fun setPackageCp(packages: List, status: Boolean) = ph.safeDpmCall { + dpm.setCrossProfilePackages( + dar, + cpPackages.value.map { it.name }.toSet().run { + if (status) plus(packages) else minus(packages) + } + ) + getCpPackages() + } + + // Cross-profile widget providers + val cpwProviders = MutableStateFlow(emptyList()) + fun getCpwProviders() = ph.safeDpmCall { + cpwProviders.value = + dpm.getCrossProfileWidgetProviders(dar).distinct().map { getAppInfo(pm, it) } + } + + fun setCpwProvider(packages: List, status: Boolean) = ph.safeDpmCall { + for (name in packages) { + if (status) { + dpm.addCrossProfileWidgetProvider(dar, name) + } else { + dpm.removeCrossProfileWidgetProvider(dar, name) + } + } + getCpwProviders() + } + + @RequiresApi(28) + fun installExistingApp(name: String) = ph.safeDpmCall { + val result = dpm.installExistingPackage(dar, name) + toastChannel.sendStatus(result) + } + + // Credential manager policy + val cmPolicyState = MutableStateFlow(-1) + val cmPackagesState = MutableStateFlow(emptyList()) + + @RequiresApi(34) + fun getCmPolicy() = ph.safeDpmCall { + val policy = dpm.credentialManagerPolicy + if (policy != null) { + cmPackagesState.value = policy.packageNames.distinct().map { getAppInfo(pm, it) } + } + cmPolicyState.value = policy?.policyType ?: -1 + } + + fun setCmPolicy(type: Int) { + cmPolicyState.value = type + } + + fun setCmPackage(packages: List, status: Boolean) { + cmPackagesState.update { + updateAppInfoList(it, packages, status) + } + } + + @RequiresApi(34) + fun applyCmPolicy() = ph.safeDpmCall { + val type = cmPolicyState.value + dpm.credentialManagerPolicy = if (type != -1 && cmPackagesState.value.isNotEmpty()) { + PackagePolicy(type, cmPackagesState.value.map { it.name }.toSet()) + } else null + getCmPolicy() + } + + fun updateAppInfoList( + origin: List, input: List, status: Boolean + ): List { + return if (status) { + origin + input.map { getAppInfo(pm, it) } + } else { + origin.filter { it.name !in input } + } + } + + // Permitted input method + val pimAllowAll = MutableStateFlow(true) + val pimPackages = MutableStateFlow(emptyList()) + + fun getPimPolicy() = ph.safeDpmCall { + val packages = dpm.getPermittedInputMethods(dar) + pimAllowAll.value = packages == null + if (packages != null) pimPackages.value = packages.distinct().map { getAppInfo(pm, it) } + } + + fun setPimAllowAll(state: Boolean) { + pimAllowAll.value = state + } + + fun setPimPackage(packages: List, status: Boolean) { + pimPackages.update { + updateAppInfoList(it, packages, status) + } + } + + fun applyPimPolicy() = ph.safeDpmCall { + val result = dpm.setPermittedInputMethods( + dar, if (pimAllowAll.value) null else pimPackages.value.map { it.name } + ) + toastChannel.sendStatus(result) + getPimPolicy() + } + + // Permitted accessibility services + val pasAllowAll = MutableStateFlow(true) + val pasPackages = MutableStateFlow(emptyList()) + fun getPasPolicy() = ph.safeDpmCall { + val packages = dpm.getPermittedAccessibilityServices(dar) + pasAllowAll.value = packages == null + if (packages != null) pasPackages.value = packages.distinct().map { getAppInfo(pm, it) } + } + + fun setPasAllowAll(state: Boolean) { + pasAllowAll.value = state + } + + fun setPasPackage(packages: List, status: Boolean) { + pasPackages.update { + updateAppInfoList(it, packages, status) + } + } + + fun applyPasPolicy() = ph.safeDpmCall { + val result = dpm.setPermittedAccessibilityServices( + dar, if (pasAllowAll.value) null else pasPackages.value.map { it.name } + ) + toastChannel.sendStatus(result) + getPasPolicy() + } + + fun enableSystemApp(name: String) = ph.safeDpmCall { + dpm.enableSystemApp(dar, name) + } + + @RequiresApi(34) + fun setDefaultDialer(name: String) = ph.safeDpmCall { + val result = try { + dpm.setDefaultDialerApplication(name) + true + } catch (e: IllegalArgumentException) { + e.printStackTrace() + false + } + toastChannel.sendStatus(result) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupModel.kt new file mode 100644 index 0000000..a24e367 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupModel.kt @@ -0,0 +1,15 @@ +package com.bintianqi.owndroid.feature.applications + +import com.bintianqi.owndroid.utils.AppInfo +import kotlinx.serialization.Serializable + +@Serializable +open class BasicAppGroup(open val name: String, open val apps: List) + +class AppGroup( + val id: Int, override val name: String, override val apps: List +) : BasicAppGroup(name, apps) + +data class AppGroupEditorUiState( + val id: Int? = null, val name: String = "", val apps: List = emptyList() +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupRepository.kt new file mode 100644 index 0000000..f9b74c6 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupRepository.kt @@ -0,0 +1,31 @@ +package com.bintianqi.owndroid.feature.applications + +import android.content.ContentValues +import com.bintianqi.owndroid.MyDbHelper + +class AppGroupRepository(val dbHelper: MyDbHelper) { + fun getAppGroups(): List { + val list = mutableListOf() + dbHelper.readableDatabase.rawQuery("SELECT * FROM app_groups", null).use { + while (it.moveToNext()) { + list += AppGroup(it.getInt(0), it.getString(1), it.getString(2).split(',')) + } + } + return list + } + + fun setAppGroup(id: Int?, name: String, apps: List) { + val cv = ContentValues() + cv.put("name", name) + cv.put("apps", apps.joinToString(",")) + if (id == null) { + dbHelper.writableDatabase.insert("app_groups", null, cv) + } else { + dbHelper.writableDatabase.update("app_groups", cv, "id = ?", arrayOf(id.toString())) + } + } + + fun deleteAppGroup(id: Int) { + dbHelper.writableDatabase.delete("app_groups", "id = ?", arrayOf(id.toString())) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupScreen.kt new file mode 100644 index 0000000..6c59bca --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupScreen.kt @@ -0,0 +1,218 @@ +package com.bintianqi.owndroid.feature.applications + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.ui.PackageNameTextField +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.parsePackageNames +import kotlinx.coroutines.channels.Channel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppGroupsScreen( + vm: AppGroupViewModel, navigateToEditScreen: () -> Unit, navigateUp: () -> Unit +) { + val groups by vm.appGroupsState.collectAsStateWithLifecycle() + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) vm.exportGroups(it) + } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { + if (it != null) vm.importGroups(it) + } + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.app_group)) }, + navigationIcon = { NavIcon(navigateUp) }, + actions = { + var dropdown by remember { mutableStateOf(false) } + Box { + IconButton({ + dropdown = true + }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(dropdown, { dropdown = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.export)) }, + { + exportLauncher.launch("owndroid_app_groups") + dropdown = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_export_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.import_str)) }, + { + importLauncher.launch(arrayOf("application/json")) + dropdown = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_open_fill0), null) + } + ) + } + } + + } + ) + }, + floatingActionButton = { + FloatingActionButton({ + vm.selectAppGroup(-1) + navigateToEditScreen() + }) { + Icon(Icons.Default.Add, null) + } + } + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + itemsIndexed(groups, { _, it -> it.id }) { index, it -> + Column( + Modifier + .fillMaxWidth() + .clickable { + vm.selectAppGroup(index) + navigateToEditScreen() + } + .padding(HorizontalPadding, 8.dp) + ) { + Text(it.name) + Text( + it.apps.size.toString() + " apps", Modifier.alpha(0.7F), + style = typography.bodyMedium + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditAppGroupScreen( + vm: AppGroupViewModel, navigateUp: () -> Unit, + onChoosePackage: () -> Unit, chosenPackage: Channel +) { + val uiState by vm.editorUiState.collectAsState() + var input by rememberSaveable { mutableStateOf("") } + val inputPackages = parsePackageNames(input) + LaunchedEffect(Unit) { + parsePackageNames(chosenPackage.receive()).forEach { + vm.setGroupApp(it, true) + } + } + Scaffold( + topBar = { + TopAppBar( + {}, + navigationIcon = { NavIcon(navigateUp) }, + actions = { + if (uiState.id != null) IconButton({ + vm.deleteGroup() + navigateUp() + }) { + Icon(Icons.Outlined.Delete, null) + } + FilledIconButton( + { + vm.setGroup() + navigateUp() + }, + enabled = uiState.name.isNotBlank() && uiState.apps.isNotEmpty() + ) { + Icon(Icons.Default.Check, null) + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + item { + OutlinedTextField( + uiState.name, vm::setGroupName, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp), + label = { Text(stringResource(R.string.name)) } + ) + } + items(uiState.apps, { it.name }) { + ApplicationItem(it) { + vm.setGroupApp(it.name, false) + } + } + item { + PackageNameTextField(input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp)) { input = it } + Button( + { + inputPackages.forEach { + vm.setGroupApp(it, true) + } + input = "" + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .padding(bottom = 10.dp), + inputPackages.all { pkg -> pkg !in uiState.apps.map { it.name } } + ) { + Text(stringResource(R.string.add)) + } + Spacer(Modifier.height(BottomPadding)) + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupViewModel.kt new file mode 100644 index 0000000..1eb6c00 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppGroupViewModel.kt @@ -0,0 +1,79 @@ +package com.bintianqi.owndroid.feature.applications + +import android.net.Uri +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.utils.getAppInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.serialization.json.Json + +class AppGroupViewModel( + val application: MyApplication, val repo: AppGroupRepository, + val appGroupsState: MutableStateFlow> +) : ViewModel() { + + fun getAppGroups() { + appGroupsState.value = repo.getAppGroups() + } + + fun selectAppGroup(index: Int) { + if (index == -1) { + editorUiState.value = AppGroupEditorUiState() + } else { + val group = appGroupsState.value[index] + val pm = application.packageManager + editorUiState.value = AppGroupEditorUiState( + group.id, group.name, group.apps.map { getAppInfo(pm, it) } + ) + } + } + + val editorUiState = MutableStateFlow(AppGroupEditorUiState()) + + fun setGroupName(name: String) { + editorUiState.update { it.copy(name = name) } + } + + fun setGroupApp(name: String, state: Boolean) { + editorUiState.update { uiState -> + val newList = uiState.apps.let { list -> + if (state) { + list.plus(getAppInfo(application.packageManager, name)) + } else { + list.filter { it.name != name } + } + } + uiState.copy(apps = newList) + } + } + + fun setGroup() { + val uiState = editorUiState.value + repo.setAppGroup(uiState.id, uiState.name, uiState.apps.map { it.name }) + getAppGroups() + } + + fun deleteGroup() { + repo.deleteAppGroup(editorUiState.value.id!!) + appGroupsState.update { group -> + group.filter { it.id != editorUiState.value.id } + } + } + + fun exportGroups(uri: Uri) { + application.contentResolver.openOutputStream(uri)!!.use { + val list: List = appGroupsState.value + it.write(Json.encodeToString(list).encodeToByteArray()) + } + } + + fun importGroups(uri: Uri) { + application.contentResolver.openInputStream(uri)!!.use { + Json.decodeFromString>(it.readBytes().decodeToString()) + }.forEach { + repo.setAppGroup(null, it.name, it.apps) + } + getAppGroups() + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerModel.kt new file mode 100644 index 0000000..c0e5c91 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerModel.kt @@ -0,0 +1,13 @@ +package com.bintianqi.owndroid.feature.applications + +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import kotlinx.serialization.Serializable + +@Serializable +data class SessionParamsOptions( + val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL, + val keepOriginalEnabledSetting: Boolean = false, + val noKill: Boolean = false, + val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY, +) diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerScreen.kt similarity index 53% rename from app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt rename to app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerScreen.kt index 3d52595..e93c4f2 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerScreen.kt @@ -1,4 +1,4 @@ -package com.bintianqi.owndroid.ui +package com.bintianqi.owndroid.feature.applications import android.content.Intent import android.content.pm.PackageInfo @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -35,15 +34,15 @@ import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -52,32 +51,27 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.bintianqi.owndroid.APK_MIME -import com.bintianqi.owndroid.AppInstallerViewModel -import com.bintianqi.owndroid.AppLockDialog import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.SP -import com.bintianqi.owndroid.SerializableSaver -import com.bintianqi.owndroid.SessionParamsOptions -import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.screen.AppLockDialog +import com.bintianqi.owndroid.utils.APK_MIME +import com.bintianqi.owndroid.utils.SerializableSaver +import com.bintianqi.owndroid.utils.parsePackageInstallerMessage import kotlinx.coroutines.launch import java.net.URLDecoder - @OptIn(ExperimentalMaterial3Api::class) -@Preview @Composable fun AppInstaller( - uiState: AppInstallerViewModel.UiState = AppInstallerViewModel.UiState(), - onPackagesAdd: (List) -> Unit = {}, - onPackageRemove: (Uri) -> Unit = {}, - onStartInstall: (SessionParamsOptions) -> Unit = {}, - onResultDialogClose: () -> Unit = {} + vm: AppInstallerViewModel ) { + val uiState by vm.uiState.collectAsState() var appLockDialog by rememberSaveable { mutableStateOf(false) } - var options by rememberSaveable(stateSaver = SerializableSaver(SessionParamsOptions.serializer())) { + var options by rememberSaveable( + stateSaver = SerializableSaver(SessionParamsOptions.serializer()) + ) { mutableStateOf(SessionParamsOptions()) } val coroutine = rememberCoroutineScope() @@ -88,53 +82,52 @@ fun AppInstaller( ) }, floatingActionButton = { - if(uiState.packages.isNotEmpty()) ExtendedFloatingActionButton( + if (uiState.packages.isNotEmpty()) ExtendedFloatingActionButton( text = { Text(stringResource(R.string.start)) }, icon = { - if(uiState.installing) CircularProgressIndicator(modifier = Modifier.size(24.dp)) + if (uiState.installing) CircularProgressIndicator(Modifier.size(24.dp)) else Icon(Icons.Default.PlayArrow, null) }, onClick = { - if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall(options) else appLockDialog = true + if (vm.getAppLockConfig().passwordHash.isEmpty()) vm.startInstall(options) + else appLockDialog = true }, expanded = !uiState.installing ) } ) { paddingValues -> - var tab by rememberSaveable { mutableIntStateOf(0) } val pagerState = rememberPagerState { 2 } - val scrollState = rememberScrollState() - tab = pagerState.targetPage - Column(modifier = Modifier.padding(paddingValues)) { - TabRow(tab) { + val tab = pagerState.targetPage + Column(Modifier.padding(paddingValues)) { + PrimaryTabRow(tab) { Tab( tab == 0, - onClick = { - coroutine.launch { scrollState.animateScrollTo(0) } + { coroutine.launch { pagerState.animateScrollToPage(0) } }, text = { Text(stringResource(R.string.packages)) } ) Tab( tab == 1, - onClick = { - coroutine.launch { scrollState.animateScrollTo(0) } + { coroutine.launch { pagerState.animateScrollToPage(1) } }, text = { Text(stringResource(R.string.options)) } ) } - HorizontalPager(pagerState, Modifier.fillMaxHeight(), verticalAlignment = Alignment.Top) { page -> - if (page == 0) Packages(uiState, onPackageRemove, onPackagesAdd) + HorizontalPager( + pagerState, Modifier.fillMaxHeight(), verticalAlignment = Alignment.Top + ) { page -> + if (page == 0) Packages(uiState, vm::onPackageRemove, vm::onPackagesAdd) else Options(options) { options = it } } } - ResultDialog(uiState.result, onResultDialogClose) + ResultDialog(uiState.result, vm::closeResultDialog) } - if(appLockDialog) { - AppLockDialog({ + if (appLockDialog) { + AppLockDialog(vm.getAppLockConfig(), { appLockDialog = false - onStartInstall(options) + vm.startInstall(options) }) { appLockDialog = false } } } @@ -144,13 +137,14 @@ fun AppInstaller( private fun Packages( uiState: AppInstallerViewModel.UiState, onRemove: (Uri) -> Unit, onAdd: (List) -> Unit ) { - val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onAdd) + val chooseSplitPackage = + rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onAdd) LazyColumn(Modifier.padding(top = 8.dp)) { itemsIndexed(uiState.packages, { _, it -> it }) { i, it -> val status = when { uiState.packageWriting < 0 -> 0 i < uiState.packageWriting -> 3 - i == uiState.packageWriting -> 2 + i == uiState.packageWriting -> 2 else -> 1 } PackageItem(it, status) { onRemove(it) } @@ -158,13 +152,21 @@ private fun Packages( if (!uiState.installing) { item { Row( - Modifier.fillMaxWidth().animateItem().padding(vertical = 4.dp).clickable { - chooseSplitPackage.launch(APK_MIME) - }.padding(vertical = 12.dp), + Modifier + .fillMaxWidth() + .animateItem() + .padding(vertical = 4.dp) + .clickable { + chooseSplitPackage.launch(APK_MIME) + } + .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp)) - Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.add_packages), + style = MaterialTheme.typography.titleMedium + ) } } } @@ -179,7 +181,11 @@ private fun LazyItemScope.PackageItem(uri: Uri, status: Int, onRemove: () -> Uni Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth().animateItem().padding(start = 8.dp, end = 6.dp, bottom = 6.dp).heightIn(min = 40.dp) + modifier = Modifier + .fillMaxWidth() + .animateItem() + .padding(start = 8.dp, end = 6.dp, bottom = 6.dp) + .heightIn(min = 40.dp) ) { Text( URLDecoder.decode(URLDecoder.decode(uri.path ?: uri.toString())), @@ -190,56 +196,86 @@ private fun LazyItemScope.PackageItem(uri: Uri, status: Int, onRemove: () -> Uni 0 -> IconButton(onRemove) { Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove)) } - 2 -> CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp)) - 3 -> Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary) + + 2 -> CircularProgressIndicator(Modifier + .padding(end = 8.dp) + .size(24.dp)) + 3 -> Icon( + Icons.Default.Check, null, Modifier.padding(end = 8.dp), + MaterialTheme.colorScheme.secondary + ) } } } @Composable -private fun Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) = Column { - Text( - stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), - style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) - FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) { - onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false)) - } - FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { - onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING)) - } - if(Build.VERSION.SDK_INT >= 34) { - AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { - FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) { - onChange(options.copy(noKill = it)) +private fun Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) = + Column { + Text( + stringResource(R.string.mode), + modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + FullWidthRadioButtonItem( + R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL + ) { + onChange( + options.copy( + mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false + ) + ) + } + FullWidthRadioButtonItem( + R.string.inherit_existing, + options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING + ) { + onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING)) + } + if (Build.VERSION.SDK_INT >= 34) { + AnimatedVisibility( + options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING + ) { + FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) { + onChange(options.copy(noKill = it)) + } + } + FullWidthCheckBoxItem( + R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting + ) { + onChange(options.copy(keepOriginalEnabledSetting = it)) } } - FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) { - onChange(options.copy(keepOriginalEnabledSetting = it)) + Text( + stringResource(R.string.install_location), + modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + FullWidthRadioButtonItem( + R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO + ) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO)) + } + FullWidthRadioButtonItem( + R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY + ) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)) + } + FullWidthRadioButtonItem( + R.string.prefer_external, + options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL + ) { + onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL)) } } - Text( - stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp), - style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) - FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO)) - } - FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)) - } - FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) { - onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL)) - } -} @Composable private fun ResultDialog(result: Intent?, onDialogClose: () -> Unit) { - if(result != null) { + if (result != null) { val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) AlertDialog( title = { - val text = if(status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure + val text = + if (status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure Text(stringResource(text)) }, text = { diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerViewModel.kt similarity index 76% rename from app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt rename to app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerViewModel.kt index 75cc03c..c8f38dd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/AppInstallerViewModel.kt @@ -1,28 +1,30 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.feature.applications -import android.app.Application import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageInfo import android.content.pm.PackageInstaller import android.net.Uri import android.os.Build import androidx.core.content.ContextCompat -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.application +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.feature.settings.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -class AppInstallerViewModel(application: Application): AndroidViewModel(application) { +class AppInstallerViewModel( + val application: MyApplication, val settingsRepo: SettingsRepository +) : ViewModel() { + fun getAppLockConfig() = settingsRepo.data.appLock val uiState = MutableStateFlow(UiState()) + data class UiState( val packages: List = emptyList(), val installing: Boolean = false, @@ -36,16 +38,17 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { list += it } intent.getParcelableArrayExtra(Intent.EXTRA_STREAM)?.forEach { list += it as Uri } intent.clipData?.let { clipData -> - for(i in 0..= 34) { - if(options.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent() + if (Build.VERSION.SDK_INT >= 34) { + if (options.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent() setDontKillApp(options.noKill) } setInstallLocation(options.location) @@ -93,25 +96,27 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat } uiState.update { it.copy(packageWriting = it.packageWriting + 1) } } - } catch(e: Exception) { + } catch (e: Exception) { e.printStackTrace() session.abandon() uiState.update { it.copy(installing = false, packageWriting = -1) } return } val intent = Intent(ACTION).setPackage(application.packageName) - val pi = if(Build.VERSION.SDK_INT >= 34) { + val pi = if (Build.VERSION.SDK_INT >= 34) { PendingIntent.getBroadcast( application, sessionId, intent, PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE ).intentSender } else { - PendingIntent.getBroadcast(application, sessionId, intent, PendingIntent.FLAG_MUTABLE).intentSender + PendingIntent.getBroadcast( + application, sessionId, intent, PendingIntent.FLAG_MUTABLE + ).intentSender } session.commit(pi) } - inner class Receiver() : BroadcastReceiver() { + val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) if (statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { @@ -127,26 +132,23 @@ class AppInstallerViewModel(application: Application): AndroidViewModel(applicat } fun closeResultDialog() { - if (uiState.value.result?.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) == PackageInstaller.STATUS_SUCCESS) { - uiState.update { it.copy(emptyList(), packageWriting = -1, result = null) } + if (uiState.value.result?.getIntExtra( + PackageInstaller.EXTRA_STATUS, 999 + ) == PackageInstaller.STATUS_SUCCESS + ) { + uiState.update { it.copy(packages = emptyList(), packageWriting = -1, result = null) } } else { uiState.update { it.copy(packageWriting = -1, result = null) } } } override fun onCleared() { - super.onCleared() viewModelScope.cancel() + application.unregisterReceiver(broadcastReceiver) + super.onCleared() } + companion object { const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED" } } - -@Serializable -data class SessionParamsOptions( - val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL, - val keepOriginalEnabledSetting: Boolean = false, - val noKill: Boolean = false, - val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY, -) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/Components.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/Components.kt new file mode 100644 index 0000000..6fb02ef --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/Components.kt @@ -0,0 +1,192 @@ +package com.bintianqi.owndroid.feature.applications + +import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT +import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED +import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.PermissionItem +import com.google.accompanist.drawablepainter.rememberDrawablePainter + +@Composable +fun LazyItemScope.ApplicationItem(info: AppInfo, onClear: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp) + .animateItem(), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + rememberDrawablePainter(info.icon), null, + Modifier + .padding(start = 12.dp, end = 18.dp) + .size(30.dp) + ) + Column { + Text(info.label) + Text(info.name, Modifier.alpha(0.8F), style = typography.bodyMedium) + } + } + IconButton(onClear) { + Icon(Icons.Default.Clear, null) + } + } +} + +@Composable +fun PackagePermissionDialog( + permission: PermissionItem, currentState: Int, isProfileOwner: Boolean, onSet: (Int) -> Unit, + onClose: () -> Unit +) { + @Composable + fun GrantPermissionItem(label: Int, stateId: Int) { + val selected = currentState == stateId + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) colorScheme.primaryContainer else Color.Transparent) + .clickable { onSet(stateId) } + .padding(vertical = 16.dp, horizontal = 12.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically, + ) { + Text( + stringResource(label), + color = if(selected) colorScheme.primary else Color.Unspecified + ) + if (selected) Icon(Icons.Outlined.CheckCircle, null, tint = colorScheme.primary) + } + } + AlertDialog( + onDismissRequest = onClose, + confirmButton = { TextButton(onClose) { Text(stringResource(R.string.cancel)) } }, + title = { Text(stringResource(permission.label)) }, + text = { + Column { + Text(permission.id) + Spacer(Modifier.padding(vertical = 4.dp)) + if(!(VERSION.SDK_INT >= 31 && permission.profileOwnerRestricted && isProfileOwner)) { + GrantPermissionItem(R.string.granted, PERMISSION_GRANT_STATE_GRANTED) + } + GrantPermissionItem(R.string.denied, PERMISSION_GRANT_STATE_DENIED) + GrantPermissionItem(R.string.default_stringres, PERMISSION_GRANT_STATE_DEFAULT) + } + } + ) +} + +@RequiresApi(28) +@Composable +internal fun ClearAppStorageDialog( + onClear: () -> Unit, onClose: () -> Unit +) { + var clearing by rememberSaveable { mutableStateOf(false) } + AlertDialog( + title = { Text(stringResource(R.string.clear_app_storage)) }, + text = { + if (clearing) LinearProgressIndicator(Modifier.fillMaxWidth()) + else Text(stringResource(R.string.clear_app_storage_confirmation)) + }, + confirmButton = { + TextButton( + { + clearing = true + onClear() + }, + enabled = !clearing, + colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) + ) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton(onClose, enabled = !clearing) { Text(stringResource(R.string.cancel)) } + }, + onDismissRequest = { + if (!clearing) onClose() + }, + properties = DialogProperties(false, false) + ) +} + + +@Composable +internal fun UninstallAppDialog( + onUninstall: ((String?) -> Unit) -> Unit, onClose: (Boolean) -> Unit +) { + var uninstalling by rememberSaveable { mutableStateOf(false) } + var errorMessage by rememberSaveable { mutableStateOf(null) } + AlertDialog( + title = { Text(stringResource(R.string.uninstall)) }, + text = { + if (errorMessage != null) Text(errorMessage!!) + if (uninstalling) LinearProgressIndicator(Modifier.fillMaxWidth()) + }, + confirmButton = { + TextButton( + { + if (errorMessage == null) { + uninstalling = true + onUninstall { + uninstalling = false + if (it == null) onClose(true) else errorMessage = it + } + } else { + onClose(false) + } + }, + enabled = !uninstalling + ) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + if (errorMessage == null) TextButton({ + onClose(false) + }, enabled = !uninstalling) { Text(stringResource(R.string.cancel)) } + }, + onDismissRequest = { onClose(false) }, + properties = DialogProperties(false, false) + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationModel.kt new file mode 100644 index 0000000..272f150 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationModel.kt @@ -0,0 +1,42 @@ +package com.bintianqi.owndroid.feature.applications + +sealed class AppRestriction( + open val key: String, open val title: String?, open val description: String? +) { + data class IntItem( + override val key: String, + override val title: String?, + override val description: String?, + var value: Int?, + ) : AppRestriction(key, title, description) + data class StringItem( + override val key: String, + override val title: String?, + override val description: String?, + var value: String? + ) : AppRestriction(key, title, description) + data class BooleanItem( + override val key: String, + override val title: String?, + override val description: String?, + var value: Boolean? + ) : AppRestriction(key, title, description) + data class ChoiceItem( + override val key: String, + override val title: String?, + override val description: String?, + val entries: Array, + val entryValues: Array, + var value: String? + ) : AppRestriction(key, title, description) + data class MultiSelectItem( + override val key: String, + override val title: String?, + override val description: String?, + val entries: Array, + val entryValues: Array, + var value: Array? + ) : AppRestriction(key, title, description) +} + +data class MultiSelectEntry(val value: String, val title: String?, val selected: Boolean) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationScreen.kt new file mode 100644 index 0000000..06cf90d --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationScreen.kt @@ -0,0 +1,441 @@ +package com.bintianqi.owndroid.feature.applications + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.searchInString +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManagedConfigurationScreen( + vm: ManagedConfigurationViewModel, navigateUp: () -> Unit +) { + val restrictions by vm.restrictionsState.collectAsStateWithLifecycle() + var searchMode by rememberSaveable { mutableStateOf(false) } + var searchKeyword by rememberSaveable { mutableStateOf("") } + val displayRestrictions = if (!searchMode || searchKeyword.isBlank()) { + restrictions + } else { + restrictions.filter { + searchInString(searchKeyword, it.key) || it.title?.contains(searchKeyword, true) ?: true + } + } + var dialog by remember { mutableStateOf(null) } + var clearRestrictionDialog by remember { mutableStateOf(false) } + Scaffold( + topBar = { + TopAppBar( + { + if (searchMode) { + val fr = remember { FocusRequester() } + LaunchedEffect(Unit) { + fr.requestFocus() + } + OutlinedTextField( + searchKeyword, { searchKeyword = it }, + Modifier + .fillMaxWidth() + .focusRequester(fr), + textStyle = typography.bodyLarge, + placeholder = { Text(stringResource(R.string.search)) }, + trailingIcon = { + IconButton({ + searchKeyword = "" + searchMode = false + }) { + Icon(Icons.Outlined.Clear, null) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } else { + Text(stringResource(R.string.managed_configuration)) + } + }, + navigationIcon = { NavIcon(navigateUp) }, + actions = { + if (!searchMode) { + IconButton({ + searchMode = true + }) { + Icon(Icons.Outlined.Search, null) + } + IconButton({ + clearRestrictionDialog = true + }) { + Icon(Icons.Outlined.Delete, null) + } + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(displayRestrictions, { it.key }) { entry -> + Row( + Modifier + .fillMaxWidth() + .clickable { + dialog = entry + } + .padding(HorizontalPadding, 8.dp) + .animateItem(), + verticalAlignment = Alignment.CenterVertically + ) { + val iconId = when (entry) { + is AppRestriction.IntItem -> R.drawable.number_123_fill0 + is AppRestriction.StringItem -> R.drawable.abc_fill0 + is AppRestriction.BooleanItem -> R.drawable.toggle_off_fill0 + is AppRestriction.ChoiceItem -> R.drawable.radio_button_checked_fill0 + is AppRestriction.MultiSelectItem -> R.drawable.check_box_fill0 + } + Icon(painterResource(iconId), null, Modifier.padding(end = 12.dp)) + Column { + if (entry.title != null) { + Text(entry.title!!, style = typography.labelLarge) + Text(entry.key, style = typography.bodyMedium) + } else { + Text(entry.key, style = typography.labelLarge) + } + val text = when (entry) { + is AppRestriction.IntItem -> entry.value?.toString() + is AppRestriction.StringItem -> entry.value?.take(30) + is AppRestriction.BooleanItem -> entry.value?.toString() + is AppRestriction.ChoiceItem -> entry.value + is AppRestriction.MultiSelectItem -> entry.value?.joinToString( + limit = 30 + ) + } + Text( + text ?: "null", Modifier.alpha(0.7F), + fontStyle = if (text == null) FontStyle.Italic else null, + style = typography.bodyMedium + ) + } + } + } + item { + Spacer(Modifier.height(BottomPadding)) + } + } + } + if (dialog != null) Dialog({ + dialog = null + }) { + Surface( + color = AlertDialogDefaults.containerColor, + shape = AlertDialogDefaults.shape, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + ManagedConfigurationDialogContent(dialog!!) { + if (it != null) { + vm.setRestriction(it) + } + dialog = null + } + } + } + if (clearRestrictionDialog) AlertDialog( + text = { + Text(stringResource(R.string.clear_configurations)) + }, + confirmButton = { + TextButton({ + vm.clearRestrictions() + clearRestrictionDialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ + clearRestrictionDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { + clearRestrictionDialog = false + } + ) +} + +@Composable +fun ManagedConfigurationDialogContent( + restriction: AppRestriction, setRestriction: (AppRestriction?) -> Unit +) { + var specifyValue by remember { mutableStateOf(false) } + var input by remember { mutableStateOf("") } + var inputState by remember { mutableStateOf(false) } + val multiSelectList = remember { + mutableStateListOf( + *(if (restriction is AppRestriction.MultiSelectItem) { + restriction.entryValues.mapIndexed { index, value -> + MultiSelectEntry( + value, restriction.entries.getOrNull(index), + restriction.value?.contains(value) ?: false + ) + }.sortedBy { entry -> + val index = restriction.value?.indexOf(entry.value) + if (index == null || index == -1) Int.MAX_VALUE else index + } + } else emptyList()).toTypedArray() + ) + } + LaunchedEffect(Unit) { + when (restriction) { + is AppRestriction.IntItem -> restriction.value?.let { + input = it.toString() + specifyValue = true + } + + is AppRestriction.StringItem -> restriction.value?.let { + input = it + specifyValue = true + } + + is AppRestriction.BooleanItem -> restriction.value?.let { + inputState = it + specifyValue = true + } + + is AppRestriction.ChoiceItem -> restriction.value?.let { + input = it + specifyValue = true + } + + is AppRestriction.MultiSelectItem -> restriction.value?.let { + specifyValue = true + } + } + } + val listState = rememberLazyListState() + val reorderableListState = rememberReorderableLazyListState(listState) { from, to -> + // `-1` because there's an `item` before items + multiSelectList.add(from.index - 1, multiSelectList.removeAt(to.index - 1)) + } + LazyColumn(Modifier.padding(12.dp), listState) { + item { + SelectionContainer { + Column { + restriction.title?.let { + Text(it, style = typography.titleLarge) + } + Text( + restriction.key, Modifier.padding(vertical = 4.dp), + style = typography.labelLarge + ) + Spacer(Modifier.height(4.dp)) + restriction.description?.let { + Text(it, Modifier.alpha(0.8F), style = typography.bodyMedium) + } + Spacer(Modifier.height(8.dp)) + } + } + Row( + Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.specify_value)) + Switch(specifyValue, { specifyValue = it }) + } + } + if (specifyValue) when (restriction) { + is AppRestriction.IntItem -> item { + OutlinedTextField( + input, { input = it }, Modifier.fillMaxWidth(), + isError = input.toIntOrNull() == null + ) + } + + is AppRestriction.StringItem -> item { + OutlinedTextField( + input, { input = it }, Modifier.fillMaxWidth() + ) + } + + is AppRestriction.BooleanItem -> item { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton( + inputState, { inputState = true }, + SegmentedButtonDefaults.itemShape(0, 2) + ) { + Text("true") + } + SegmentedButton( + !inputState, { inputState = false }, + SegmentedButtonDefaults.itemShape(1, 2) + ) { + Text("false") + } + } + } + + is AppRestriction.ChoiceItem -> itemsIndexed(restriction.entryValues) { index, value -> + val label = restriction.entries.getOrNull(index) + Row( + Modifier + .fillMaxWidth() + .clickable { + input = value + } + .padding(8.dp, 4.dp) + ) { + RadioButton(input == value, { input = value }) + Spacer(Modifier.width(8.dp)) + if (label == null) { + Text(value) + } else { + Column { + Text(label) + Text(value, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + } + } + + is AppRestriction.MultiSelectItem -> itemsIndexed( + multiSelectList, { _, v -> v.value } + ) { index, entry -> + ReorderableItem(reorderableListState, entry.value) { + Row( + Modifier + .fillMaxWidth() + .clickable { + val old = multiSelectList[index] + multiSelectList[index] = old.copy(selected = !old.selected) + } + .padding(8.dp, 4.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Checkbox(entry.selected, null) + Spacer(Modifier.width(8.dp)) + if (entry.title == null) { + Text(entry.value) + } else { + Column { + Text(entry.title) + Text( + entry.value, Modifier.alpha(0.7F), + style = typography.bodyMedium + ) + } + } + } + Icon( + painterResource(R.drawable.drag_indicator_fill0), null, + Modifier.draggableHandle() + ) + } + } + } + } + item { + Row( + Modifier + .fillMaxWidth() + .padding(top = 4.dp), Arrangement.End + ) { + TextButton({ + setRestriction(null) + }, Modifier.padding(end = 4.dp)) { + Text(stringResource(R.string.cancel)) + } + TextButton({ + val newRestriction = when (restriction) { + is AppRestriction.IntItem -> restriction.copy( + value = if (specifyValue) input.toIntOrNull() else null + ) + + is AppRestriction.StringItem -> restriction.copy( + value = if (specifyValue) input else null + ) + + is AppRestriction.BooleanItem -> restriction.copy( + value = if (specifyValue) inputState else null + ) + + is AppRestriction.ChoiceItem -> restriction.copy( + value = if (specifyValue) input else null + ) + + is AppRestriction.MultiSelectItem -> restriction.copy( + value = if (specifyValue) + multiSelectList.filter { it.selected } + .map { it.value }.toTypedArray() + else null + ) + } + setRestriction(newRestriction) + }) { + Text(stringResource(R.string.confirm)) + } + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationViewModel.kt new file mode 100644 index 0000000..bb7cce9 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/applications/ManagedConfigurationViewModel.kt @@ -0,0 +1,118 @@ +package com.bintianqi.owndroid.feature.applications + +import android.content.RestrictionEntry +import android.content.RestrictionsManager +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class ManagedConfigurationViewModel( + val packageName: String, val application: MyApplication, val ph: PrivilegeHelper +) : ViewModel() { + val restrictionsState = MutableStateFlow(emptyList()) + + init { + viewModelScope.launch(Dispatchers.IO) { + getRestrictionsWithoutCoroutine() + } + } + + private fun getRestrictionsWithoutCoroutine() { + try { + val rm = application.getSystemService(RestrictionsManager::class.java) + ph.safeDpmCall { + val bundle = dpm.getApplicationRestrictions(dar, packageName) + restrictionsState.value = rm.getManifestRestrictions(packageName)?.mapNotNull { + transformRestrictionEntry(it) + }?.map { + if (bundle.containsKey(it.key)) { + when (it) { + is AppRestriction.BooleanItem -> it.value = bundle.getBoolean(it.key) + is AppRestriction.StringItem -> it.value = bundle.getString(it.key) + is AppRestriction.IntItem -> it.value = bundle.getInt(it.key) + is AppRestriction.ChoiceItem -> it.value = bundle.getString(it.key) + is AppRestriction.MultiSelectItem -> it.value = + bundle.getStringArray(it.key) + } + } + it + } ?: emptyList() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun setRestriction(item: AppRestriction) { + viewModelScope.launch(Dispatchers.IO) { + ph.safeDpmCall { + val bundle = transformAppRestriction( + restrictionsState.value.filter { it.key != item.key }.plus(item) + ) + dpm.setApplicationRestrictions(dar, packageName, bundle) + getRestrictionsWithoutCoroutine() + } + } + } + + fun clearRestrictions() { + viewModelScope.launch(Dispatchers.IO) { + ph.safeDpmCall { + dpm.setApplicationRestrictions(dar, packageName, Bundle()) + getRestrictionsWithoutCoroutine() + } + } + } + + private fun transformRestrictionEntry(e: RestrictionEntry): AppRestriction? { + return when (e.type) { + RestrictionEntry.TYPE_INTEGER -> + AppRestriction.IntItem(e.key, e.title, e.description, null) + + RestrictionEntry.TYPE_STRING -> + AppRestriction.StringItem(e.key, e.title, e.description, null) + + RestrictionEntry.TYPE_BOOLEAN -> + AppRestriction.BooleanItem(e.key, e.title, e.description, null) + + RestrictionEntry.TYPE_CHOICE -> AppRestriction.ChoiceItem( + e.key, e.title, + e.description, e.choiceEntries, e.choiceValues, null + ) + + RestrictionEntry.TYPE_MULTI_SELECT -> AppRestriction.MultiSelectItem( + e.key, e.title, + e.description, e.choiceEntries, e.choiceValues, null + ) + + else -> null + } + } + + private fun transformAppRestriction(list: List): Bundle { + val b = Bundle() + for (r in list) { + when (r) { + is AppRestriction.IntItem -> r.value?.let { b.putInt(r.key, it) } + is AppRestriction.StringItem -> r.value?.let { b.putString(r.key, it) } + is AppRestriction.BooleanItem -> r.value?.let { b.putBoolean(r.key, it) } + is AppRestriction.ChoiceItem -> r.value?.let { b.putString(r.key, it) } + is AppRestriction.MultiSelectItem -> r.value?.let { + b.putStringArray(r.key, r.value) + } + } + } + return b + } + + override fun onCleared() { + viewModelScope.cancel() + super.onCleared() + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingModel.kt new file mode 100644 index 0000000..bca9a8f --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingModel.kt @@ -0,0 +1,17 @@ +package com.bintianqi.owndroid.feature.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class NetworkLog( + val id: Long?, + @SerialName("package") val packageName: String, + val time: Long, + val type: String, + val host: String?, + val count: Int?, + val addresses: List?, + val address: String?, + val port: Int? +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingRepository.kt new file mode 100644 index 0000000..8300111 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingRepository.kt @@ -0,0 +1,97 @@ +package com.bintianqi.owndroid.feature.network + +import android.database.DatabaseUtils +import androidx.core.database.getIntOrNull +import androidx.core.database.getLongOrNull +import androidx.core.database.getStringOrNull +import com.bintianqi.owndroid.MyDbHelper +import kotlinx.serialization.json.Json +import java.io.OutputStream + +class NetworkLoggingRepository(val dbHelper: MyDbHelper) { + + fun getNetworkLogsCount(): Long { + return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "network_logs") + } + + fun writeNetworkLogs(logs: List) { + val db = dbHelper.writableDatabase + val statement = db.compileStatement( + "INSERT INTO network_logs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + db.beginTransaction() + logs.forEach { event -> + if (event.id == null) statement.bindNull(1) + else statement.bindLong(1, event.id) + statement.bindString(2, event.packageName) + statement.bindLong(3, event.time) + statement.bindString(4, event.type) + if (event.host == null) { + statement.bindNull(5) + } else { + statement.bindString(5, event.host) + } + if (event.count == null) { + statement.bindNull(6) + } else { + statement.bindLong(6, event.count.toLong()) + } + if (event.addresses == null) { + statement.bindNull(7) + } else { + statement.bindString(7, event.addresses.joinToString(",")) + } + if (event.address == null) { + statement.bindNull(8) + } else { + statement.bindString(8, event.address) + } + if (event.port == null) { + statement.bindNull(9) + } else { + statement.bindLong(9, event.port.toLong()) + } + statement.executeInsert() + statement.clearBindings() + } + db.setTransactionSuccessful() + db.endTransaction() + statement.close() + } + + fun exportNetworkLogs(stream: OutputStream) { + val bw = stream.bufferedWriter() + val json = Json { + explicitNulls = false + } + var offset = 0 + var addComma = false + bw.write("[") + while (true) { + val cursor = dbHelper.readableDatabase.rawQuery( + "SELECT * FROM network_logs LIMIT ? OFFSET ?", + arrayOf(100.toString(), offset.toString()) + ) + if (cursor.count == 0) break + while (cursor.moveToNext()) { + if (addComma) bw.write(",") + addComma = true + val log = NetworkLog( + cursor.getLongOrNull(0), cursor.getString(1), cursor.getLong(2), + cursor.getString(3), cursor.getStringOrNull(4), cursor.getIntOrNull(5), + cursor.getStringOrNull(6)?.split(',')?.filter { it.isNotEmpty() }, + cursor.getStringOrNull(7), cursor.getIntOrNull(8) + ) + bw.write(json.encodeToString(log)) + offset += 100 + } + cursor.close() + } + bw.write("]") + bw.close() + } + + fun deleteNetworkLogs() { + dbHelper.writableDatabase.execSQL("DELETE FROM network_logs") + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingScreen.kt new file mode 100644 index 0000000..9791628 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingScreen.kt @@ -0,0 +1,109 @@ +package com.bintianqi.owndroid.feature.network + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CircularProgressDialog +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.showOperationResultToast +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@RequiresApi(26) +@Composable +fun NetworkLoggingScreen( + vm: NetworkLoggingViewModel, onNavigateUp: () -> Unit +) { + val context = LocalContext.current + var enabled by remember { mutableStateOf(false) } + var count by remember { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableStateOf(false) } + var exporting by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + vm.getEnabled() + vm.getCount() + } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + if (uri != null) { + exporting = true + vm.exportLogs(uri) { + exporting = false + context.showOperationResultToast(true) + } + } + } + MyScaffold(R.string.network_logging, onNavigateUp, 0.dp) { + SwitchItem(R.string.enable, enabled, vm::setEnabled) + Text( + stringResource(R.string.n_logs_in_total, count), + Modifier.padding(HorizontalPadding, 5.dp) + ) + Button( + { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportLauncher.launch("network_logs_$date") + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + count > 0 + ) { + Text(stringResource(R.string.export_logs)) + } + if (count > 0) Button( + { + dialog = true + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + ) { + Text(stringResource(R.string.delete_logs)) + } + Spacer(Modifier.height(10.dp)) + Notes(R.string.info_network_log, HorizontalPadding) + } + if (exporting) CircularProgressDialog { } + if (dialog) AlertDialog( + text = { + Text(stringResource(R.string.delete_logs)) + }, + confirmButton = { + TextButton({ + vm.deleteLogs() + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingViewModel.kt new file mode 100644 index 0000000..9ff5e35 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkLoggingViewModel.kt @@ -0,0 +1,51 @@ +package com.bintianqi.owndroid.feature.network + +import android.net.Uri +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class NetworkLoggingViewModel( + val application: MyApplication, val ph: PrivilegeHelper, + val repo: NetworkLoggingRepository +) : ViewModel() { + val enabledState = MutableStateFlow(false) + + @RequiresApi(26) + fun getEnabled() = ph.safeDpmCall { + enabledState.value = dpm.isNetworkLoggingEnabled(dar) + } + + @RequiresApi(26) + fun setEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setNetworkLoggingEnabled(dar, enabled) + getEnabled() + } + + val countState = MutableStateFlow(0) + + fun getCount() { + viewModelScope.launch(Dispatchers.IO) { + countState.value = repo.getNetworkLogsCount().toInt() + } + } + + fun exportLogs(uri: Uri, callback: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + application.contentResolver.openOutputStream(uri)?.use { + repo.exportNetworkLogs(it) + } + withContext(Dispatchers.Main) { callback() } + } + } + + fun deleteLogs() { + repo.deleteNetworkLogs() + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkModel.kt new file mode 100644 index 0000000..080f8a3 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkModel.kt @@ -0,0 +1,19 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.admin.DevicePolicyManager +import com.bintianqi.owndroid.R + +@Suppress("InlinedApi") +enum class PrivateDnsMode(val id: Int, val text: Int) { + Opportunistic(DevicePolicyManager.PRIVATE_DNS_MODE_OPPORTUNISTIC, R.string.automatic), + Host(DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME, R.string.enabled) +} + +enum class ProxyType(val text: Int) { + Off(R.string.proxy_type_off), Pac(R.string.proxy_type_pac), Direct(R.string.proxy_type_direct) +} + +data class RecommendedProxyConf( + val type: ProxyType, val url: String, val host: String, val specifyPort: Boolean, + val port: Int, val exclude: List +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkScreen.kt new file mode 100644 index 0000000..c6c0e85 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkScreen.kt @@ -0,0 +1,271 @@ +package com.bintianqi.owndroid.feature.network + +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.FunctionItem +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.PackageNameTextField +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.HorizontalPadding +import kotlinx.coroutines.channels.Channel + +@Composable +fun NetworkScreen( + vm: NetworkViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit +) { + val privilege by vm.ps.collectAsStateWithLifecycle() + MyScaffold(R.string.network, onNavigateUp, 0.dp) { + if (!privilege.dhizuku) FunctionItem(R.string.wifi, icon = R.drawable.wifi_fill0) { + onNavigate(Destination.WiFi) + } + if (VERSION.SDK_INT >= 30) { + FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { + onNavigate(Destination.NetworkOptions) + } + } + if (!privilege.dhizuku) + FunctionItem(R.string.network_stats, icon = R.drawable.query_stats_fill0) { + onNavigate(Destination.NetworkStats) + } + if (VERSION.SDK_INT >= 29 && privilege.device) { + FunctionItem(R.string.private_dns, icon = R.drawable.dns_fill0) { + onNavigate(Destination.PrivateDns) + } + } + if (VERSION.SDK_INT >= 24) { + FunctionItem(R.string.always_on_vpn, icon = R.drawable.vpn_key_fill0) { + onNavigate(Destination.AlwaysOnVpnPackage) + } + } + if (privilege.device) { + FunctionItem(R.string.recommended_global_proxy, icon = R.drawable.vpn_key_fill0) { + onNavigate(Destination.RecommendedGlobalProxy) + } + } + if (VERSION.SDK_INT >= 26 && !privilege.dhizuku && (privilege.device || privilege.work)) { + FunctionItem(R.string.network_logging, icon = R.drawable.description_fill0) { + onNavigate(Destination.NetworkLogging) + } + } + /*if(VERSION.SDK_INT >= 31) { + FunctionItem(R.string.wifi_auth_keypair, icon = R.drawable.key_fill0) { onNavigate(Destination.WifiAuthKeypair) } + }*/ + if (VERSION.SDK_INT >= 33 && (privilege.work || privilege.device)) { + FunctionItem(R.string.preferential_network_service, icon = R.drawable.globe_fill0) { + onNavigate(Destination.PreferentialNetworkService) + } + } + if (VERSION.SDK_INT >= 28 && privilege.device) { + FunctionItem(R.string.override_apn, icon = R.drawable.cell_tower_fill0) { + onNavigate(Destination.OverrideApn) + } + } + } +} + +@Composable +fun NetworkOptionsScreen( + vm: NetworkViewModel, onNavigateUp: () -> Unit +) { + val privilege by vm.ps.collectAsState() + MyScaffold(R.string.options, onNavigateUp, 0.dp) { + if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { + val lanEnabled by vm.lanEnabledState.collectAsState() + LaunchedEffect(Unit) { + vm.getLanEnabled() + } + SwitchItem( + R.string.lockdown_admin_configured_network, lanEnabled, vm::setLanEnabled, + R.drawable.wifi_password_fill0 + ) + Notes(R.string.info_lockdown_admin_configured_network, HorizontalPadding) + } + } +} + +@RequiresApi(29) +@Composable +fun PrivateDnsScreen( + vm: NetworkViewModel, onNavigateUp: () -> Unit +) { + val mode by vm.privateDnsModeState.collectAsState() + val host by vm.privateDnsHostState.collectAsState() + LaunchedEffect(Unit) { + vm.getPrivateDnsConf() + } + MyScaffold(R.string.private_dns, onNavigateUp, 0.dp) { + PrivateDnsMode.entries.forEach { + FullWidthRadioButtonItem(it.text, mode == it) { vm.setPrivateDnsMode(it) } + } + if (mode == PrivateDnsMode.Host) { + OutlinedTextField( + host, vm::setPrivateDnsHost, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp), + label = { Text(stringResource(R.string.dns_hostname)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + Button( + vm::applyPrivateDnsConf, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + mode != null + ) { + Text(stringResource(R.string.apply)) + } + } +} + +@RequiresApi(24) +@Composable +fun AlwaysOnVpnPackageScreen( + vm: NetworkViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit, + onNavigateUp: () -> Unit +) { + val lockdown by vm.alwaysOnVpnLockdownState.collectAsState() + val pkgName by vm.alwaysOnVpnPackageState.collectAsState() + var initialized by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (!initialized) { + if (VERSION.SDK_INT >= 29) vm.getAlwaysOnVpnLockdown() + vm.getAlwaysOnVpnPackage() + } + vm.setAlwaysOnVpnPackage(chosenPackage.receive()) + } + MyScaffold(R.string.always_on_vpn, onNavigateUp, 0.dp) { + PackageNameTextField( + pkgName, onChoosePackage, + Modifier.padding(HorizontalPadding, 4.dp), + vm::setAlwaysOnVpnPackage + ) + SwitchItem(R.string.enable_lockdown, lockdown, vm::setAlwaysOnVpnLockdown) + Button( + vm::applyAlwaysOnVpn, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + ) { + Text(stringResource(R.string.apply)) + } + Button( + vm::clearAlwaysOnVpnConfig, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 5.dp) + ) { + Text(stringResource(R.string.clear_current_config)) + } + Notes(R.string.info_always_on_vpn, HorizontalPadding) + } +} + +@Composable +fun RecommendedGlobalProxyScreen( + vm: NetworkViewModel, onNavigateUp: () -> Unit +) { + var type by rememberSaveable { mutableStateOf(ProxyType.Off) } + var pacUrl by rememberSaveable { mutableStateOf("") } + var specifyPort by rememberSaveable { mutableStateOf(false) } + var host by rememberSaveable { mutableStateOf("") } + var port by rememberSaveable { mutableStateOf("") } + var exclList by rememberSaveable { mutableStateOf("") } + MyScaffold(R.string.recommended_global_proxy, onNavigateUp, 0.dp) { + ProxyType.entries.forEach { + FullWidthRadioButtonItem(it.text, type == it) { type = it } + } + AnimatedVisibility(type == ProxyType.Pac) { + OutlinedTextField( + pacUrl, { pacUrl = it }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp), + label = { Text("URL") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + AnimatedVisibility(type == ProxyType.Direct) { + OutlinedTextField( + host, { host = it }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp), + label = { Text("Host") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + AnimatedVisibility(type == ProxyType.Pac && VERSION.SDK_INT >= 30) { + FullWidthCheckBoxItem(R.string.specify_port, specifyPort) { specifyPort = it } + } + AnimatedVisibility((specifyPort && VERSION.SDK_INT >= 30) || type == ProxyType.Direct) { + OutlinedTextField( + port, { port = it }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp), + label = { Text(stringResource(R.string.port)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + } + AnimatedVisibility(type == ProxyType.Direct) { + OutlinedTextField( + exclList, { exclList = it }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp), + label = { Text(stringResource(R.string.excluded_hosts)) }, + maxLines = 5, + minLines = 2, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + Button( + { + vm.setRecommendedGlobalProxy( + RecommendedProxyConf( + type, pacUrl, host, specifyPort, port.toIntOrNull() ?: 0, + exclList.lines().filter { it.isNotBlank() } + )) + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp), + enabled = type == ProxyType.Off || + (type == ProxyType.Pac && pacUrl.isNotBlank() && + (!specifyPort || port.toIntOrNull() != null)) || + (type == ProxyType.Direct && port.toIntOrNull() != null) + ) { + Text(stringResource(R.string.apply)) + } + Notes(R.string.info_recommended_global_proxy, HorizontalPadding) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsModel.kt new file mode 100644 index 0000000..c3a372c --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsModel.kt @@ -0,0 +1,66 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.usage.NetworkStats +import android.net.ConnectivityManager +import com.bintianqi.owndroid.R + +enum class NetworkStatsMenu { + None, Type, Target, NetworkType, StartTime, EndTime, Uid, Tag, State +} + +enum class NetworkStatsType(val text: Int) { Summary(R.string.summary), Details(R.string.details) } + +@Suppress("DEPRECATION") +enum class NetworkType(val type: Int, val text: Int) { + Mobile(ConnectivityManager.TYPE_MOBILE, R.string.mobile), + Wifi(ConnectivityManager.TYPE_WIFI, R.string.wifi), + Bluetooth(ConnectivityManager.TYPE_BLUETOOTH, R.string.bluetooth), + Ethernet(ConnectivityManager.TYPE_ETHERNET, R.string.ethernet), + Vpn(ConnectivityManager.TYPE_VPN, R.string.vpn), +} + +enum class NetworkStatsTarget(val text: Int, val type: NetworkStatsType, val minApi: Int = 23) { + Device(R.string.device, NetworkStatsType.Summary), + User(R.string.user, NetworkStatsType.Summary), + Uid(R.string.uid, NetworkStatsType.Details), + UidTag(R.string.uid_tag, NetworkStatsType.Details, 24), + UidTagState(R.string.uid_tag_state, NetworkStatsType.Details, 28) +} + +@Suppress("InlinedApi") +enum class NetworkStatsState(val id: Int, val text: Int) { + All(NetworkStats.Bucket.STATE_ALL, R.string.all), + Default(NetworkStats.Bucket.STATE_DEFAULT, R.string.default_str), + Foreground(NetworkStats.Bucket.STATE_FOREGROUND, R.string.foreground) +} + +enum class NetworkStatsUID(val uid: Int, val text: Int) { + All(NetworkStats.Bucket.UID_ALL, R.string.all), + Removed(NetworkStats.Bucket.UID_REMOVED, R.string.uninstalled), + Tethering(NetworkStats.Bucket.UID_TETHERING, R.string.tethering) +} + +class QueryNetworkStatsParams( + val type: NetworkStatsType, + val target: NetworkStatsTarget, + val networkType: NetworkType, + val startTime: Long, + val endTime: Long, + val uid: Int, + val tag: Int, + val state: NetworkStatsState +) + +class NetworkStatsData( + val rxBytes: Long, + val rxPackets: Long, + val txBytes: Long, + val txPackets: Long, + val uid: Int, + val state: Int, + val startTime: Long, + val endTime: Long, + val tag: Int?, + val roaming: Int?, + val metered: Int? +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsScreen.kt new file mode 100644 index 0000000..1ef8c60 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsScreen.kt @@ -0,0 +1,524 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.usage.NetworkStats +import android.os.Build.VERSION +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CircularProgressDialog +import com.bintianqi.owndroid.ui.ErrorDialog +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.MySmallTitleScaffold +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.clickableTextField +import com.bintianqi.owndroid.utils.formatDate +import com.bintianqi.owndroid.utils.formatFileSize +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkStatsScreen( + vm: NetworkStatsViewModel, choosePackage: () -> Unit, chosenPackage: Channel, + navigateUp: () -> Unit, navigateToViewer: () -> Unit +) { + val res = LocalResources.current + val privilege by vm.privilegeState.collectAsState() + fun getDefaultSummaryTarget(): NetworkStatsTarget { + return if (privilege.device) NetworkStatsTarget.Device else NetworkStatsTarget.User + } + var menu by remember { mutableStateOf(NetworkStatsMenu.None) } + var type by rememberSaveable { mutableStateOf(NetworkStatsType.Summary) } + var target by rememberSaveable { mutableStateOf(getDefaultSummaryTarget()) } + var networkType by rememberSaveable { mutableStateOf(NetworkType.Mobile) } + var startTime by rememberSaveable { + mutableLongStateOf(System.currentTimeMillis() - 7 * 24 * 60 * 60 * 1000) + } + var endTime by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) } + var uid by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.UID_ALL) } + var tag by rememberSaveable { mutableIntStateOf(NetworkStats.Bucket.TAG_NONE) } + var state by rememberSaveable { mutableStateOf(NetworkStatsState.All) } + var errorMessage by rememberSaveable { mutableStateOf(null) } + var querying by rememberSaveable { mutableStateOf(false) } + MyScaffold(R.string.network_stats, navigateUp) { + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.Type, + { menu = if (it) NetworkStatsMenu.Type else NetworkStatsMenu.None }, + Modifier.padding(top = 8.dp, bottom = 4.dp) + ) { + OutlinedTextField( + stringResource(type.text), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Type) + } + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.Type, { menu = NetworkStatsMenu.None } + ) { + NetworkStatsType.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + type = it + target = if (it == NetworkStatsType.Summary) getDefaultSummaryTarget() + else NetworkStatsTarget.Uid + menu = NetworkStatsMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.Target, + { menu = if (it) NetworkStatsMenu.Target else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) + ) { + OutlinedTextField( + stringResource(target.text), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.target)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Target) + } + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.Target, { menu = NetworkStatsMenu.None } + ) { + NetworkStatsTarget.entries.filter { + VERSION.SDK_INT >= it.minApi && type == it.type + }.forEach { + DropdownMenuItem( + text = { Text(stringResource(it.text)) }, + onClick = { + target = it + menu = NetworkStatsMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.NetworkType, + { menu = if (it) NetworkStatsMenu.NetworkType else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) + ) { + OutlinedTextField( + stringResource(networkType.text), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.network_type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.NetworkType) + } + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.NetworkType, { menu = NetworkStatsMenu.None } + ) { + NetworkType.entries.forEach { + DropdownMenuItem( + text = { Text(stringResource(it.text)) }, + onClick = { + networkType = it + menu = NetworkStatsMenu.None + } + ) + } + } + } + OutlinedTextField( + formatDate(startTime), {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = NetworkStatsMenu.StartTime } + .padding(bottom = 4.dp), + readOnly = true, label = { Text(stringResource(R.string.start_time)) }, + isError = startTime >= endTime + ) + OutlinedTextField( + formatDate(endTime), {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = NetworkStatsMenu.EndTime } + .padding(bottom = 4.dp), + readOnly = true, + label = { Text(stringResource(R.string.end_time)) }, + isError = startTime >= endTime + ) + if ( + target == NetworkStatsTarget.Uid || target == NetworkStatsTarget.UidTag || + target == NetworkStatsTarget.UidTagState + ) { + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.Uid, + { menu = if (it) NetworkStatsMenu.Uid else NetworkStatsMenu.None } + ) { + var uidText by rememberSaveable { + mutableStateOf(res.getString(NetworkStatsUID.All.text)) + } + var readOnly by rememberSaveable { mutableStateOf(true) } + if (VERSION.SDK_INT >= 24) LaunchedEffect(Unit) { + val pkg = chosenPackage.receive() + uid = vm.getPackageUid(pkg) + uidText = "$uid ($pkg)" + } + OutlinedTextField( + uidText, + { + uidText = it + it.toIntOrNull()?.let { num -> uid = num } + }, + readOnly = readOnly, + label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Uid) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = !readOnly && uidText.toIntOrNull() == null, + modifier = Modifier + .menuAnchor( + if (readOnly) ExposedDropdownMenuAnchorType.PrimaryNotEditable + else ExposedDropdownMenuAnchorType.PrimaryEditable + ) + .fillMaxWidth() + .padding(bottom = 4.dp) + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.Uid, { menu = NetworkStatsMenu.None } + ) { + NetworkStatsUID.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + uid = it.uid + readOnly = true + uidText = res.getString(it.text) + menu = NetworkStatsMenu.None + } + ) + } + if (VERSION.SDK_INT >= 24) DropdownMenuItem( + { Text(stringResource(R.string.choose_an_app)) }, + { + readOnly = true + menu = NetworkStatsMenu.None + choosePackage() + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.input)) }, + { + readOnly = false + uidText = "" + menu = NetworkStatsMenu.None + } + ) + } + } + } + if ( + VERSION.SDK_INT >= 24 && + (target == NetworkStatsTarget.UidTag || target == NetworkStatsTarget.UidTagState) + ) { + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.Tag, + { menu = if (it) NetworkStatsMenu.Tag else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) + ) { + var tagText by rememberSaveable { mutableStateOf(res.getString(R.string.all)) } + var readOnly by rememberSaveable { mutableStateOf(true) } + OutlinedTextField( + tagText, + { + tagText = it + it.toIntOrNull()?.let { num -> tag = num } + }, + Modifier + .menuAnchor( + if (readOnly) ExposedDropdownMenuAnchorType.PrimaryNotEditable + else ExposedDropdownMenuAnchorType.PrimaryEditable + ) + .fillMaxWidth() + .padding(bottom = 4.dp), + readOnly = readOnly, + label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.Tag) + }, + isError = !readOnly && tagText.toIntOrNull() == null + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.Tag, { menu = NetworkStatsMenu.None } + ) { + DropdownMenuItem( + { Text(stringResource(R.string.all)) }, + { + tag = NetworkStats.Bucket.TAG_NONE + tagText = res.getString(R.string.all) + readOnly = true + menu = NetworkStatsMenu.None + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.input)) }, + { + tagText = "" + readOnly = false + menu = NetworkStatsMenu.None + } + ) + } + } + } + if (VERSION.SDK_INT >= 28 && target == NetworkStatsTarget.UidTagState) { + ExposedDropdownMenuBox( + menu == NetworkStatsMenu.State, + { menu = if (it) NetworkStatsMenu.State else NetworkStatsMenu.None }, + Modifier.padding(bottom = 4.dp) + ) { + OutlinedTextField( + stringResource(state.text), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.uid)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == NetworkStatsMenu.State) + } + ) + ExposedDropdownMenu( + menu == NetworkStatsMenu.State, { menu = NetworkStatsMenu.None } + ) { + NetworkStatsState.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + state = it + menu = NetworkStatsMenu.None + } + ) + } + } + } + } + Button( + { + querying = true + vm.queryStats( + QueryNetworkStatsParams( + type, target, networkType, startTime, endTime, uid, tag, state + ) + ) { + querying = false + errorMessage = it + if (it == null) navigateToViewer() + } + }, + Modifier.fillMaxWidth().padding(top = 8.dp), + !querying + ) { + Text(stringResource(R.string.query)) + } + if (menu == NetworkStatsMenu.StartTime || menu == NetworkStatsMenu.EndTime) { + val datePickerState = rememberDatePickerState( + if (menu == NetworkStatsMenu.StartTime) startTime else endTime + ) + DatePickerDialog( + onDismissRequest = { menu = NetworkStatsMenu.None }, + dismissButton = { + TextButton(onClick = { menu = NetworkStatsMenu.None }) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + if (menu == NetworkStatsMenu.StartTime) { + startTime = datePickerState.selectedDateMillis!! + } else { + endTime = datePickerState.selectedDateMillis!! + } + menu = NetworkStatsMenu.None + }, + enabled = datePickerState.selectedDateMillis != null + ) { + Text(stringResource(R.string.confirm)) + } + } + ) { + DatePicker(datePickerState) + } + } + } + if (querying) CircularProgressDialog { } + ErrorDialog(errorMessage) { errorMessage = null } +} + +@Composable +fun NetworkStatsViewerScreen( + vm: NetworkStatsViewModel, onNavigateUp: () -> Unit +) { + var index by rememberSaveable { mutableIntStateOf(0) } + val size = vm.statsData.size + val ps = rememberPagerState { size } + index = ps.currentPage + val coroutine = rememberCoroutineScope() + MySmallTitleScaffold(R.string.network_stats, onNavigateUp, 0.dp) { + if (size > 1) Row( + Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + { + coroutine.launch { + ps.animateScrollToPage(index - 1) + } + }, + enabled = index > 0 + ) { + Icon(Icons.AutoMirrored.Default.KeyboardArrowLeft, null) + } + Text("${index + 1} / $size", modifier = Modifier.padding(horizontal = 8.dp)) + IconButton( + { + coroutine.launch { + ps.animateScrollToPage(index + 1) + } + }, + enabled = index < size - 1 + ) { + Icon(Icons.AutoMirrored.Default.KeyboardArrowRight, null) + } + } + HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page -> + val item = vm.statsData[page] + Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { + Text( + formatDate(item.startTime) + "\n~\n" + formatDate(item.endTime), + Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center + ) + Spacer(Modifier.height(5.dp)) + val txBytes = item.txBytes + Text( + stringResource(R.string.transmitted), + style = MaterialTheme.typography.titleMedium + ) + Column(Modifier.padding(start = 8.dp, bottom = 4.dp)) { + Text("$txBytes bytes (${formatFileSize(txBytes)})") + Text(item.txPackets.toString() + " packets") + } + val rxBytes = item.rxBytes + Text( + stringResource(R.string.received), style = MaterialTheme.typography.titleMedium + ) + Column(Modifier.padding(start = 8.dp, bottom = 8.dp)) { + Text("$rxBytes bytes (${formatFileSize(rxBytes)})") + Text(item.rxPackets.toString() + " packets") + } + Row(verticalAlignment = Alignment.CenterVertically) { + val text = NetworkStatsState.entries.find { it.id == item.state }!!.text + Text( + stringResource(R.string.state), Modifier.padding(end = 8.dp), + style = MaterialTheme.typography.titleMedium + ) + Text(stringResource(text)) + } + if (VERSION.SDK_INT >= 24) { + Row(verticalAlignment = Alignment.CenterVertically) { + val tag = item.tag + Text( + stringResource(R.string.tag), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + if (tag == NetworkStats.Bucket.TAG_NONE) stringResource( + R.string.all + ) else tag.toString() + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + val text = when (item.roaming) { + NetworkStats.Bucket.ROAMING_ALL -> R.string.all + NetworkStats.Bucket.ROAMING_YES -> R.string.yes + NetworkStats.Bucket.ROAMING_NO -> R.string.no + else -> R.string.unknown + } + Text( + stringResource(R.string.roaming), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(text)) + } + } + if (VERSION.SDK_INT >= 26) Row(verticalAlignment = Alignment.CenterVertically) { + val text = when (item.metered) { + NetworkStats.Bucket.METERED_ALL -> R.string.all + NetworkStats.Bucket.METERED_YES -> R.string.yes + NetworkStats.Bucket.METERED_NO -> R.string.no + else -> R.string.unknown + } + Text( + stringResource(R.string.metered), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(text)) + } + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsViewModel.kt new file mode 100644 index 0000000..6b4492e --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkStatsViewModel.kt @@ -0,0 +1,106 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.usage.NetworkStats +import android.app.usage.NetworkStatsManager +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.utils.PrivilegeStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class NetworkStatsViewModel( + val application: MyApplication, val privilegeState: StateFlow +) : ViewModel() { + var statsData = emptyList() + fun readNetworkStats(stats: NetworkStats): List { + val list = mutableListOf() + while (stats.hasNextBucket()) { + val bucket = NetworkStats.Bucket() + stats.getNextBucket(bucket) + list += readDataFromBucket(bucket) + } + stats.close() + return list + } + + @RequiresApi(24) + fun getPackageUid(name: String): Int { + return application.packageManager.getPackageUid(name, 0) + } + + fun readDataFromBucket(bucket: NetworkStats.Bucket): NetworkStatsData { + return NetworkStatsData( + bucket.rxBytes, bucket.rxPackets, bucket.txBytes, bucket.txPackets, + bucket.uid, bucket.state, bucket.startTimeStamp, bucket.endTimeStamp, + if (VERSION.SDK_INT >= 24) bucket.tag else null, + if (VERSION.SDK_INT >= 24) bucket.roaming else null, + if (VERSION.SDK_INT >= 26) bucket.metered else null + ) + } + + @Suppress("NewApi") + fun queryStats(params: QueryNetworkStatsParams, callback: (String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val nsm = application.getSystemService(NetworkStatsManager::class.java) + try { + val data = when (params.target) { + NetworkStatsTarget.Device -> listOf( + readDataFromBucket( + nsm.querySummaryForDevice( + params.networkType.type, null, params.startTime, params.endTime + ) + ) + ) + + NetworkStatsTarget.User -> listOf( + readDataFromBucket( + nsm.querySummaryForUser( + params.networkType.type, null, params.startTime, params.endTime + ) + ) + ) + + NetworkStatsTarget.Uid -> readNetworkStats( + nsm.queryDetailsForUid( + params.networkType.type, null, params.startTime, params.endTime, + params.uid + ) + ) + + NetworkStatsTarget.UidTag -> readNetworkStats( + nsm.queryDetailsForUidTag( + params.networkType.type, null, params.startTime, params.endTime, + params.uid, params.tag + ) + ) + + NetworkStatsTarget.UidTagState -> readNetworkStats( + nsm.queryDetailsForUidTagState( + params.networkType.type, null, params.startTime, params.endTime, + params.uid, params.tag, params.state.id + ) + ) + } + statsData = data + withContext(Dispatchers.Main) { + if (data.isEmpty()) { + callback(application.getString(R.string.no_data)) + } else { + callback(null) + } + } + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + callback(e.message ?: "") + } + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkViewModel.kt new file mode 100644 index 0000000..f2a2501 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/NetworkViewModel.kt @@ -0,0 +1,128 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.admin.DevicePolicyManager +import android.app.admin.IDevicePolicyManager +import android.net.ProxyInfo +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow + +class NetworkViewModel( + val ph: PrivilegeHelper, val toastChannel: ToastChannel, + val ps: MutableStateFlow +) : ViewModel() { + // Lockdown admin configured networks + val lanEnabledState = MutableStateFlow(false) + + @RequiresApi(30) + fun getLanEnabled() = ph.safeDpmCall { + lanEnabledState.value = dpm.hasLockdownAdminConfiguredNetworks(dar) + } + + @RequiresApi(30) + fun setLanEnabled(state: Boolean) = ph.safeDpmCall { + dpm.setConfiguredNetworksLockdownState(dar, state) + getLanEnabled() + } + + val privateDnsModeState = MutableStateFlow(null) + val privateDnsHostState = MutableStateFlow("") + + @RequiresApi(29) + fun getPrivateDnsConf() = ph.safeDpmCall { + val mode = dpm.getGlobalPrivateDnsMode(dar) + privateDnsModeState.value = PrivateDnsMode.entries.find { it.id == mode } + privateDnsHostState.value = dpm.getGlobalPrivateDnsHost(dar) ?: "" + } + + fun setPrivateDnsMode(mode: PrivateDnsMode) { + privateDnsModeState.value = mode + } + + fun setPrivateDnsHost(host: String) { + privateDnsHostState.value = host + } + + @Suppress("PrivateApi") + @RequiresApi(29) + fun applyPrivateDnsConf() = ph.safeDpmCall { + val result = try { + val field = DevicePolicyManager::class.java.getDeclaredField("mService") + field.isAccessible = true + val dpm = field.get(dpm) as IDevicePolicyManager + val host = + if (privateDnsModeState.value == PrivateDnsMode.Host) privateDnsHostState.value + else null + val ret = dpm.setGlobalPrivateDns(dar, privateDnsModeState.value!!.id, host) + ret == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR + } catch (e: Exception) { + e.printStackTrace() + false + } + toastChannel.sendStatus(result) + } + + val alwaysOnVpnPackageState = MutableStateFlow("") + val alwaysOnVpnLockdownState = MutableStateFlow(false) + + @RequiresApi(24) + fun getAlwaysOnVpnPackage() = ph.safeDpmCall { + alwaysOnVpnPackageState.value = dpm.getAlwaysOnVpnPackage(dar) ?: "" + } + + @RequiresApi(29) + fun getAlwaysOnVpnLockdown() = ph.safeDpmCall { + alwaysOnVpnLockdownState.value = dpm.isAlwaysOnVpnLockdownEnabled(dar) + } + + fun setAlwaysOnVpnPackage(name: String) { + alwaysOnVpnPackageState.value = name + } + + fun setAlwaysOnVpnLockdown(state: Boolean) { + alwaysOnVpnLockdownState.value = state + } + + @RequiresApi(24) + fun applyAlwaysOnVpn() = ph.safeDpmCall { + val result = try { + dpm.setAlwaysOnVpnPackage( + dar, alwaysOnVpnPackageState.value, alwaysOnVpnLockdownState.value + ) + true + } catch (_: Exception) { + false + } + toastChannel.sendStatus(result) + } + + @RequiresApi(24) + fun clearAlwaysOnVpnConfig() = ph.safeDpmCall { + dpm.setAlwaysOnVpnPackage(dar, null, false) + alwaysOnVpnPackageState.value = "" + alwaysOnVpnLockdownState.value = false + } + + fun setRecommendedGlobalProxy(conf: RecommendedProxyConf) = ph.safeDpmCall { + val info = when (conf.type) { + ProxyType.Off -> null + ProxyType.Pac -> { + if (VERSION.SDK_INT >= 30 && conf.specifyPort) { + ProxyInfo.buildPacProxy(conf.url.toUri(), conf.port) + } else { + ProxyInfo.buildPacProxy(conf.url.toUri()) + } + } + ProxyType.Direct -> { + ProxyInfo.buildDirectProxy(conf.host, conf.port, conf.exclude) + } + } + dpm.setRecommendedGlobalProxy(dar, info) + toastChannel.sendStatus(true) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnModel.kt new file mode 100644 index 0000000..3281797 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnModel.kt @@ -0,0 +1,112 @@ +package com.bintianqi.owndroid.feature.network + +import android.annotation.SuppressLint +import android.os.Build.VERSION +import android.telephony.TelephonyManager +import android.telephony.data.ApnSetting + +enum class ApnMenu { + None, ApnType, AuthType, Protocol, RoamingProtocol, NetworkType, MvnoType, OperatorNumeric +} + +data class ApnType(val id: Int, val name: String, val requiresApi: Int = 0) + +@SuppressLint("InlinedApi") +val apnTypes = listOf( + ApnType(ApnSetting.TYPE_DEFAULT, "Default"), + ApnType(ApnSetting.TYPE_MMS, "MMS"), + ApnType(ApnSetting.TYPE_SUPL, "SUPL"), + ApnType(ApnSetting.TYPE_DUN, "DUN"), + ApnType(ApnSetting.TYPE_HIPRI, "HiPri"), + ApnType(ApnSetting.TYPE_FOTA, "FOTA"), + ApnType(ApnSetting.TYPE_IMS, "IMS"), + ApnType(ApnSetting.TYPE_CBS, "CBS"), + ApnType(ApnSetting.TYPE_IA, "IA"), + ApnType(ApnSetting.TYPE_EMERGENCY, "Emergency"), + ApnType(ApnSetting.TYPE_MCX, "MCX", 29), + ApnType(ApnSetting.TYPE_XCAP, "XCAP", 30), + ApnType(ApnSetting.TYPE_VSIM, "VSIM", 31), + ApnType(ApnSetting.TYPE_BIP, "BIP", 31), + ApnType(ApnSetting.TYPE_ENTERPRISE, "Enterprise", 33), + ApnType(ApnSetting.TYPE_RCS, "RCS", 35), + ApnType(ApnSetting.TYPE_OEM_PAID, "OEM paid"), + ApnType(ApnSetting.TYPE_OEM_PRIVATE, "OEM private") +).filter { VERSION.SDK_INT >= it.requiresApi } + +class ApnProtocol(val id: Int, val text: String, val requiresApi: Int = 28) + +@Suppress("InlinedApi") +val apnProtocols = listOf( + ApnProtocol(ApnSetting.PROTOCOL_IP, "IPv4"), + ApnProtocol(ApnSetting.PROTOCOL_IPV6, "IPv6"), + ApnProtocol(ApnSetting.PROTOCOL_IPV4V6, "IPv4/IPv6"), + ApnProtocol(ApnSetting.PROTOCOL_PPP, "PPP"), + ApnProtocol(ApnSetting.PROTOCOL_NON_IP, "Non-IP", 29), + ApnProtocol(ApnSetting.PROTOCOL_UNSTRUCTURED, "Unstructured", 29) +) + +class ApnAuthType(val id: Int, val text: String) + +@Suppress("InlinedApi") +val apnAuthTypes = listOf( + ApnAuthType(ApnSetting.AUTH_TYPE_NONE, "None"), + ApnAuthType(ApnSetting.AUTH_TYPE_PAP, "PAP"), + ApnAuthType(ApnSetting.AUTH_TYPE_CHAP, "CHAP"), + ApnAuthType(ApnSetting.AUTH_TYPE_PAP_OR_CHAP, "PAP/CHAP") +) + +data class ApnNetworkType(val id: Int, val text: String, val requiresApi: Int = 0) + +@Suppress("InlinedApi", "DEPRECATION") +val apnNetworkTypes = listOf( + ApnNetworkType(TelephonyManager.NETWORK_TYPE_LTE, "LTE"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPAP, "HSPA+"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSPA, "HSPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSUPA, "HSUPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_HSDPA, "HSDPA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_UMTS, "UMTS"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EDGE, "EDGE"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_GPRS, "GPRS"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EHRPD, "CDMA - eHRPD"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_B, "CDMA - EvDo rev. B"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_A, "CDMA - EvDo rev. A"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_EVDO_0, "CDMA - EvDo rev. 0"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_1xRTT, "CDMA - 1xRTT"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_CDMA, "CDMA"), + ApnNetworkType(TelephonyManager.NETWORK_TYPE_NR, "NR", 29) +).filter { VERSION.SDK_INT >= it.requiresApi } + +@Suppress("InlinedApi") +enum class ApnMvnoType(val id: Int, val text: String) { + SPN(ApnSetting.MVNO_TYPE_SPN, "SPN"), + IMSI(ApnSetting.MVNO_TYPE_IMSI, "IMSI"), + GID(ApnSetting.MVNO_TYPE_GID, "GID"), + ICCID(ApnSetting.MVNO_TYPE_ICCID, "ICCID") +} + +data class ApnConfig( + val enabled: Boolean, + val name: String, + val apn: String, + val proxy: String, + val port: Int?, + val username: String, + val password: String, + val apnType: Int, + val mmsc: String, + val mmsProxy: String, + val mmsPort: Int?, + val authType: Int, + val protocol: Int, + val roamingProtocol: Int, + val networkType: Int, + val profileId: Int?, + val carrierId: Int?, + val mtuV4: Int?, + val mtuV6: Int?, + val mvno: ApnMvnoType, + val operatorNumeric: String, + val persistent: Boolean, + val alwaysOn: Boolean, + val id: Int = -1 +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnScreen.kt new file mode 100644 index 0000000..a326d5d --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnScreen.kt @@ -0,0 +1,495 @@ +package com.bintianqi.owndroid.feature.network + +import android.os.Build.VERSION +import android.provider.Telephony +import androidx.annotation.RequiresApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.MySmallTitleScaffold +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.utils.clickableTextField + +@RequiresApi(28) +@Composable +fun OverrideApnScreen( + vm: OverrideApnViewModel, onNavigateUp: () -> Unit, onNavigateToAddSetting: () -> Unit +) { + val enabled by vm.enabledState.collectAsState() + val configs by vm.configsState.collectAsState() + LaunchedEffect(Unit) { vm.getConfigs() } + MyScaffold(R.string.override_apn, onNavigateUp, 0.dp) { + SwitchItem(R.string.enable, enabled, vm::setEnabled) + configs.forEach { + Row( + Modifier + .fillMaxWidth() + .padding(16.dp, 8.dp, 8.dp, 8.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row { + Text(it.id.toString(), Modifier.padding(end = 8.dp)) + Column { + Text(it.name) + Text( + it.apn, Modifier.alpha(0.7F), + style = MaterialTheme.typography.bodyMedium + ) + } + } + IconButton({ + vm.selectedConfig = it + onNavigateToAddSetting() + }) { + Icon(Icons.Outlined.Edit, null) + } + } + } + Row( + Modifier + .fillMaxWidth() + .clickable { + vm.selectedConfig = null + onNavigateToAddSetting() + } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Add, null, Modifier.padding(horizontal = 8.dp)) + Text(stringResource(R.string.add_config), style = MaterialTheme.typography.labelLarge) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@RequiresApi(28) +@Composable +fun AddApnSettingScreen( + vm: OverrideApnViewModel, onNavigateUp: () -> Unit +) { + val origin = vm.selectedConfig + var menu by remember { mutableStateOf(ApnMenu.None) } + var enabled by rememberSaveable { mutableStateOf(true) } + var entryName by rememberSaveable { mutableStateOf(origin?.name ?: "") } + var apnName by rememberSaveable { mutableStateOf(origin?.apn ?: "") } + var apnType by rememberSaveable { mutableIntStateOf(origin?.apnType ?: 0) } + var profileId by rememberSaveable { mutableStateOf(origin?.profileId?.toString() ?: "") } + var carrierId by rememberSaveable { mutableStateOf(origin?.carrierId?.toString() ?: "") } + var authType by rememberSaveable { mutableIntStateOf(0) } + var user by rememberSaveable { mutableStateOf(origin?.username ?: "") } + var password by rememberSaveable { mutableStateOf(origin?.password ?: "") } + var proxy by rememberSaveable { mutableStateOf(origin?.proxy ?: "") } + var port by rememberSaveable { mutableStateOf(origin?.port?.toString() ?: "") } + var mmsProxy by rememberSaveable { mutableStateOf(origin?.mmsProxy ?: "") } + var mmsPort by rememberSaveable { mutableStateOf(origin?.mmsPort?.toString() ?: "") } + var mmsc by rememberSaveable { mutableStateOf(origin?.mmsc ?: "") } + var mtuV4 by rememberSaveable { mutableStateOf(origin?.mtuV4?.toString() ?: "") } + var mtuV6 by rememberSaveable { mutableStateOf(origin?.mtuV6?.toString() ?: "") } + var mvnoType by rememberSaveable { mutableStateOf(origin?.mvno ?: ApnMvnoType.SPN) } + var networkType by rememberSaveable { mutableIntStateOf(origin?.networkType ?: 0) } + var operatorNumeric by rememberSaveable { mutableStateOf(origin?.operatorNumeric ?: "") } + var protocol by rememberSaveable { mutableIntStateOf(origin?.protocol ?: 0) } + var roamingProtocol by rememberSaveable { mutableIntStateOf(origin?.roamingProtocol ?: 0) } + var persistent by rememberSaveable { mutableStateOf(origin?.persistent == true) } + var alwaysOn by rememberSaveable { mutableStateOf(origin?.alwaysOn == true) } + var errorMessage: String? by rememberSaveable { mutableStateOf(null) } + MySmallTitleScaffold(R.string.apn_setting, onNavigateUp) { + SwitchItem( + R.string.enabled, state = enabled, onCheckedChange = { enabled = it }, padding = false + ) + OutlinedTextField( + entryName, { entryName = it }, Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + label = { Text("Name") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + apnName, { apnName = it }, Modifier.fillMaxWidth(), + label = { Text("APN") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + proxy, { proxy = it }, Modifier.fillMaxWidth(), + label = { Text("Proxy") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + port, { port = it }, Modifier.fillMaxWidth(), + label = { Text("Port") }, + isError = port.isNotEmpty() && port.toIntOrNull() == null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + OutlinedTextField( + user, { user = it }, Modifier.fillMaxWidth(), + label = { Text("Username") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + password, { password = it }, Modifier.fillMaxWidth(), + label = { Text("Password") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + apnTypes.filter { apnType and it.id == it.id }.joinToString { it.name }, {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = ApnMenu.ApnType }, + readOnly = true, label = { Text("APN type") } + ) + OutlinedTextField( + mmsc, { mmsc = it }, Modifier.fillMaxWidth(), + label = { Text("MMSC") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + mmsProxy, { mmsProxy = it }, Modifier.fillMaxWidth(), + label = { Text("MMS proxy") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + OutlinedTextField( + mmsPort, { mmsPort = it }, Modifier.fillMaxWidth(), + label = { Text("MMS port") }, + isError = mmsPort.isNotEmpty() && mmsPort.toIntOrNull() == null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + ExposedDropdownMenuBox( + menu == ApnMenu.AuthType, { menu = if (it) ApnMenu.AuthType else ApnMenu.None } + ) { + OutlinedTextField( + apnAuthTypes.find { it.id == authType }!!.text, {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text("Authentication type") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.AuthType) + } + ) + ExposedDropdownMenu(menu == ApnMenu.AuthType, { menu = ApnMenu.None }) { + apnAuthTypes.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + authType = it.id + menu = ApnMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == ApnMenu.Protocol, { menu = if (it) ApnMenu.Protocol else ApnMenu.None } + ) { + OutlinedTextField( + apnProtocols.find { it.id == protocol }!!.text, {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text("APN protocol") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.Protocol) + } + ) + ExposedDropdownMenu(menu == ApnMenu.Protocol, { menu = ApnMenu.None }) { + apnProtocols.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + protocol = it.id + menu = ApnMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == ApnMenu.RoamingProtocol, + { menu = if (it) ApnMenu.RoamingProtocol else ApnMenu.None } + ) { + OutlinedTextField( + apnProtocols.find { it.id == roamingProtocol }!!.text, {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text("APN roaming protocol") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol) + } + ) + ExposedDropdownMenu(menu == ApnMenu.RoamingProtocol, { menu = ApnMenu.None }) { + apnProtocols.filter { VERSION.SDK_INT >= it.requiresApi }.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + roamingProtocol = it.id + menu = ApnMenu.None + } + ) + } + } + } + OutlinedTextField( + apnNetworkTypes.filter { networkType and it.id == it.id }.joinToString { it.text }, {}, + Modifier + .fillMaxWidth() + .clickableTextField { menu = ApnMenu.NetworkType }, + readOnly = true, label = { Text("Network type") } + ) + if (VERSION.SDK_INT >= 33) OutlinedTextField( + profileId, { profileId = it }, + Modifier.fillMaxWidth(), + label = { Text("Profile id") }, + isError = profileId.isNotEmpty() && profileId.toIntOrNull() == null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + if (VERSION.SDK_INT >= 29) OutlinedTextField( + carrierId, { carrierId = it }, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + label = { Text("Carrier id") }, + isError = carrierId.isNotEmpty() && carrierId.toIntOrNull() == null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + if (VERSION.SDK_INT >= 33) Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), Arrangement.SpaceBetween + ) { + OutlinedTextField( + mtuV4, { mtuV4 = it }, Modifier.fillMaxWidth(0.49F), + label = { Text("MTU (IPv4)") }, + isError = mtuV4.isNotEmpty() && mtuV4.toIntOrNull() == null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + OutlinedTextField( + mtuV6, { mtuV6 = it }, Modifier.fillMaxWidth(0.96F), + label = { Text("MTU (IPv6)") }, + isError = mtuV6.isNotEmpty() && mtuV6.toIntOrNull() == null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + } + ExposedDropdownMenuBox( + menu == ApnMenu.MvnoType, { menu = if (it) ApnMenu.MvnoType else ApnMenu.None } + ) { + OutlinedTextField( + mvnoType.text, {}, + Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("MVNO type") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == ApnMenu.RoamingProtocol) + } + ) + ExposedDropdownMenu(menu == ApnMenu.MvnoType, { menu = ApnMenu.None }) { + ApnMvnoType.entries.forEach { + DropdownMenuItem( + { Text(it.text) }, + { + mvnoType = it + menu = ApnMenu.None + } + ) + } + } + } + ExposedDropdownMenuBox( + menu == ApnMenu.OperatorNumeric, + { menu = if (it) ApnMenu.OperatorNumeric else ApnMenu.None } + ) { + OutlinedTextField( + operatorNumeric, {}, + Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("Numeric operator ID") } + ) + ExposedDropdownMenu(menu == ApnMenu.OperatorNumeric, { menu = ApnMenu.None }) { + listOf(Telephony.Carriers.MCC, Telephony.Carriers.MNC).forEach { + DropdownMenuItem({ Text(it) }, { + operatorNumeric = it + menu = ApnMenu.None + }) + } + } + } + if (VERSION.SDK_INT >= 33) Row( + Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text("Persistent") + Switch(persistent, { persistent = it }) + } + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Always on") + Switch(alwaysOn, { alwaysOn = it }) + } + Button( + { + vm.setConfig( + ApnConfig( + enabled, entryName, apnName, proxy, port.toIntOrNull(), user, password, + apnType, + mmsc, mmsProxy, mmsPort.toIntOrNull(), authType, protocol, roamingProtocol, + networkType, profileId.toIntOrNull(), carrierId.toIntOrNull(), + mtuV4.toIntOrNull(), mtuV6.toIntOrNull(), mvnoType, + operatorNumeric, persistent, alwaysOn + ) + ) { + onNavigateUp() + } + }, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text(stringResource(if (origin != null) R.string.update else R.string.add)) + } + if (origin != null) Button( + { + vm.removeConfig(origin.id) { + onNavigateUp() + } + }, + Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError + ) + ) { + Text(stringResource(R.string.delete)) + } + if (errorMessage != null) AlertDialog( + title = { Text(stringResource(R.string.error)) }, + text = { Text(errorMessage ?: "") }, + confirmButton = { + TextButton({ errorMessage = null }) { Text(stringResource(R.string.confirm)) } + }, + onDismissRequest = { errorMessage = null } + ) + } + if (menu == ApnMenu.ApnType) AlertDialog( + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + apnTypes.forEach { type -> + val checked = apnType and type.id == type.id + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clickable { + apnType = if (checked) apnType and type.id.inv() + else apnType or type.id + } + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked, null) + Text( + type.name, Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + }, + confirmButton = { + TextButton({ menu = ApnMenu.None }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { menu = ApnMenu.None } + ) + if (menu == ApnMenu.NetworkType) AlertDialog( + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + apnNetworkTypes.forEach { type -> + val checked = type.id and networkType == type.id + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clickable { + networkType = if (checked) networkType and type.id.inv() + else networkType or type.id + } + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked, null) + Text( + type.text, Modifier.padding(start = 6.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + }, + confirmButton = { + TextButton({ menu = ApnMenu.None }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { + menu = ApnMenu.None + } + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnViewModel.kt new file mode 100644 index 0000000..7af5a87 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/OverrideApnViewModel.kt @@ -0,0 +1,116 @@ +package com.bintianqi.owndroid.feature.network + +import android.os.Build.VERSION +import android.telephony.data.ApnSetting +import androidx.annotation.RequiresApi +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import java.net.InetAddress + +class OverrideApnViewModel( + val ph: PrivilegeHelper, val tc: ToastChannel +) : ViewModel() { + val enabledState = MutableStateFlow(false) + @RequiresApi(28) + fun getEnabled() = ph.safeDpmCall { + dpm.isOverrideApnEnabled(dar) + } + + @RequiresApi(28) + fun setEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setOverrideApnsEnabled(dar, enabled) + getEnabled() + } + + val configsState = MutableStateFlow(listOf()) + + @RequiresApi(28) + fun getConfigs() = ph.safeDpmCall { + configsState.value = dpm.getOverrideApns(dar).map { + val proxy = + if (VERSION.SDK_INT >= 29) it.proxyAddressAsString else it.proxyAddress.hostName + val mmsProxy = + if (VERSION.SDK_INT >= 29) it.mmsProxyAddressAsString + else it.mmsProxyAddress.hostName + ApnConfig( + it.isEnabled, it.entryName, it.apnName, proxy, it.proxyPort, + it.user, it.password, it.apnTypeBitmask, it.mmsc.toString(), + mmsProxy, it.mmsProxyPort, + it.authType, + it.protocol, + it.roamingProtocol, + it.networkTypeBitmask, + if (VERSION.SDK_INT >= 33) it.profileId else 0, + if (VERSION.SDK_INT >= 29) it.carrierId else 0, + if (VERSION.SDK_INT >= 33) it.mtuV4 else 0, + if (VERSION.SDK_INT >= 33) it.mtuV6 else 0, + ApnMvnoType.entries.find { type -> type.id == it.mvnoType }!!, + it.operatorNumeric, + if (VERSION.SDK_INT >= 33) it.isPersistent else true, + if (VERSION.SDK_INT >= 35) it.isAlwaysOn else true, + it.id + ) + } + } + + var selectedConfig: ApnConfig? = null + + @RequiresApi(28) + fun buildApnSetting(config: ApnConfig): ApnSetting? { + val builder = ApnSetting.Builder() + builder.setCarrierEnabled(config.enabled) + builder.setEntryName(config.name) + builder.setApnName(config.apn) + if (VERSION.SDK_INT >= 29) builder.setProxyAddress(config.proxy) + else builder.setProxyAddress(InetAddress.getByName(config.proxy)) + config.port?.let { builder.setProxyPort(it) } + builder.setUser(config.username) + builder.setPassword(config.password) + builder.setApnTypeBitmask(config.apnType) + builder.setMmsc(config.mmsc.toUri()) + if (VERSION.SDK_INT >= 29) builder.setMmsProxyAddress(config.mmsProxy) + else builder.setMmsProxyAddress(InetAddress.getByName(config.mmsProxy)) + builder.setAuthType(config.authType) + builder.setProtocol(config.protocol) + builder.setRoamingProtocol(config.roamingProtocol) + builder.setNetworkTypeBitmask(config.networkType) + if (VERSION.SDK_INT >= 33) config.profileId?.let { builder.setProfileId(it) } + if (VERSION.SDK_INT >= 29) config.carrierId?.let { builder.setCarrierId(it) } + if (VERSION.SDK_INT >= 33) { + config.mtuV4?.let { builder.setMtuV4(it) } + config.mtuV6?.let { builder.setMtuV6(it) } + } + builder.setMvnoType(config.mvno.id) + builder.setOperatorNumeric(config.operatorNumeric) + if (VERSION.SDK_INT >= 33) builder.setPersistent(config.persistent) + if (VERSION.SDK_INT >= 35) builder.setAlwaysOn(config.alwaysOn) + return builder.build() + } + + @RequiresApi(28) + fun setConfig(config: ApnConfig, succeedCallback: () -> Unit) = ph.safeDpmCall { + val settings = buildApnSetting(config) + val result = if (settings == null) { + false + } else { + if (config.id == -1) { + dpm.addOverrideApn(dar, settings) != -1 + } else { + dpm.updateOverrideApn(dar, config.id, settings) + } + } + if (result) succeedCallback() else tc.sendStatus(false) + } + + @RequiresApi(28) + fun removeConfig(id: Int, succeedCallback: () -> Unit) = ph.safeDpmCall { + val result = dpm.removeOverrideApn(dar, id) + if (result) { + succeedCallback() + getConfigs() + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkModel.kt new file mode 100644 index 0000000..ac94f36 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkModel.kt @@ -0,0 +1,10 @@ +package com.bintianqi.owndroid.feature.network + +class PreferentialNetworkServiceInfo( + val enabled: Boolean = true, + val id: Int = -1, + val allowFallback: Boolean = false, + val blockNonMatching: Boolean = false, + val excludedUids: List = emptyList(), + val includedUids: List = emptyList() +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkScreen.kt new file mode 100644 index 0000000..382a2df --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkScreen.kt @@ -0,0 +1,199 @@ +package com.bintianqi.owndroid.feature.network + +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MySmallTitleScaffold +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.HorizontalPadding + +@RequiresApi(33) +@Composable +fun PreferentialNetworkServiceScreen( + vm: PreferentialNetworkViewModel, + onNavigateUp: () -> Unit, onNavigate: (Destination.AddPreferentialNetworkServiceConfig) -> Unit +) { + val masterEnabled by vm.enabledState.collectAsState() + val configs by vm.configsState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + vm.getConfigs() + } + MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp, 0.dp) { + SwitchItem(R.string.enabled, masterEnabled, vm::setEnabled) + Spacer(Modifier.padding(vertical = 4.dp)) + configs.forEachIndexed { index, config -> + Row( + Modifier + .fillMaxWidth() + .padding(16.dp, 4.dp, 8.dp, 4.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(config.id.toString()) + IconButton({ + vm.selectedConfigIndex = index + onNavigate(Destination.AddPreferentialNetworkServiceConfig) + }) { + Icon(Icons.Default.Edit, stringResource(R.string.edit)) + } + } + } + Row( + Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clickable { + vm.selectedConfigIndex = -1 + onNavigate(Destination.AddPreferentialNetworkServiceConfig) + } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Add, null, Modifier.padding(horizontal = 8.dp)) + Text(stringResource(R.string.add_config)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@RequiresApi(33) +@Composable +fun AddPreferentialNetworkServiceConfigScreen( + vm: PreferentialNetworkViewModel, onNavigateUp: () -> Unit +) { + val configList by vm.configsState.collectAsState() + val origin = + if (vm.selectedConfigIndex != -1) configList[vm.selectedConfigIndex] + else PreferentialNetworkServiceInfo() + val updateMode = origin.id != -1 + var enabled by rememberSaveable { mutableStateOf(origin.enabled) } + var id by rememberSaveable { mutableIntStateOf(origin.id) } + var allowFallback by rememberSaveable { mutableStateOf(origin.allowFallback) } + var blockNonMatching by rememberSaveable { mutableStateOf(origin.blockNonMatching) } + var excludedUids by rememberSaveable { mutableStateOf(origin.excludedUids.joinToString("\n")) } + var includedUids by rememberSaveable { mutableStateOf(origin.includedUids.joinToString("\n")) } + var dropdown by remember { mutableStateOf(false) } + MySmallTitleScaffold(R.string.preferential_network_service, onNavigateUp, 0.dp) { + SwitchItem(R.string.enabled, enabled, { enabled = it }) + ExposedDropdownMenuBox( + dropdown, { dropdown = it }, Modifier.padding(horizontal = HorizontalPadding) + ) { + OutlinedTextField( + if (id == -1) "" else id.toString(), {}, + Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + readOnly = true, label = { Text("id") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + for (i in 1..5) { + DropdownMenuItem( + { Text(i.toString()) }, + { + id = i + dropdown = false + } + ) + } + } + } + SwitchItem( + R.string.allow_fallback_to_default_connection, + allowFallback, { allowFallback = it } + ) + if (VERSION.SDK_INT >= 34) SwitchItem( + R.string.block_non_matching_networks, blockNonMatching, { blockNonMatching = it } + ) + val includedUidsLegal = includedUids.lines().filter { it.isNotBlank() }.let { uid -> + uid.isEmpty() || (uid.all { it.toIntOrNull() != null } && excludedUids.isBlank()) + } + OutlinedTextField( + includedUids, { includedUids = it }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .padding(bottom = 6.dp), + minLines = 2, + label = { Text(stringResource(R.string.included_uids)) }, + supportingText = { Text(stringResource(R.string.one_uid_per_line)) }, + isError = !includedUidsLegal + ) + val excludedUidsLegal = excludedUids.lines().filter { it.isNotBlank() }.let { uid -> + uid.isEmpty() || (uid.all { it.toIntOrNull() != null } && includedUids.isBlank()) + } + OutlinedTextField( + excludedUids, { excludedUids = it }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + .padding(bottom = 6.dp), + minLines = 2, + label = { Text(stringResource(R.string.excluded_uids)) }, + supportingText = { Text(stringResource(R.string.one_uid_per_line)) }, + isError = !excludedUidsLegal + ) + Button( + { + vm.setConfig( + PreferentialNetworkServiceInfo( + enabled, id, allowFallback, blockNonMatching, + excludedUids.lines().mapNotNull { it.toIntOrNull() }, + includedUids.lines().mapNotNull { it.toIntOrNull() } + ), true) + onNavigateUp() + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp), + includedUidsLegal && excludedUidsLegal && id in 1..5 + ) { + Text(stringResource(if (updateMode) R.string.update else R.string.add)) + } + if (updateMode) FilledTonalButton( + { + vm.setConfig(origin, false) + onNavigateUp() + }, + Modifier + .padding(horizontal = HorizontalPadding) + .fillMaxWidth() + ) { + Text(stringResource(R.string.delete)) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkViewModel.kt new file mode 100644 index 0000000..286f500 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/PreferentialNetworkViewModel.kt @@ -0,0 +1,67 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.admin.PreferentialNetworkServiceConfig +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.PrivilegeHelper +import kotlinx.coroutines.flow.MutableStateFlow + +class PreferentialNetworkViewModel( + val ph: PrivilegeHelper +) : ViewModel() { + val enabledState = MutableStateFlow(false) + + @RequiresApi(31) + fun getEnabled() = ph.safeDpmCall { + enabledState.value = dpm.isPreferentialNetworkServiceEnabled + } + + @RequiresApi(31) + fun setEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.isPreferentialNetworkServiceEnabled = enabled + getEnabled() + } + + val configsState = MutableStateFlow(emptyList()) + + @RequiresApi(33) + fun getConfigs() = ph.safeDpmCall { + configsState.value = dpm.preferentialNetworkServiceConfigs.map { + PreferentialNetworkServiceInfo( + it.isEnabled, it.networkId, it.isFallbackToDefaultConnectionAllowed, + if (VERSION.SDK_INT >= 34) it.shouldBlockNonMatchingNetworks() else false, + it.excludedUids.toList(), it.includedUids.toList() + ) + } + } + + var selectedConfigIndex = -1 + + @RequiresApi(33) + private fun buildConfig( + info: PreferentialNetworkServiceInfo + ): PreferentialNetworkServiceConfig { + return PreferentialNetworkServiceConfig.Builder().apply { + setEnabled(info.enabled) + @Suppress("WrongConstant") + setNetworkId(info.id) + setFallbackToDefaultConnectionAllowed(info.allowFallback) + if (VERSION.SDK_INT >= 34) setShouldBlockNonMatchingNetworks(info.blockNonMatching) + setIncludedUids(info.includedUids.toIntArray()) + setExcludedUids(info.excludedUids.toIntArray()) + }.build() + } + + @RequiresApi(33) + fun setConfig(info: PreferentialNetworkServiceInfo, state: Boolean) = ph.safeDpmCall { + val originList = configsState.value.toMutableList() + if (selectedConfigIndex == -1) { + originList += info + } else { + if (state) originList[selectedConfigIndex] = info + else originList.removeAt(selectedConfigIndex) + } + dpm.preferentialNetworkServiceConfigs = originList.map { buildConfig(it) } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiModel.kt new file mode 100644 index 0000000..6ff989b --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiModel.kt @@ -0,0 +1,69 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.admin.WifiSsidPolicy +import android.net.wifi.WifiConfiguration +import com.bintianqi.owndroid.R + +class WifiInfo( + val id: Int, + val ssid: String, + val hiddenSsid: Boolean?, + val bssid: String, + val macRandomization: WifiMacRandomization?, + val status: WifiStatus, + val security: WifiSecurity?, + val password: String, + val ipMode: IpMode?, + val ipConf: IpConf?, + val proxyMode: ProxyMode?, + val proxyConf: ProxyConf? +) + +enum class AddNetworkMenu { + None, Status, Security, MacRandomization, Ip, Proxy, HiddenSSID +} + +@Suppress("InlinedApi", "DEPRECATION") +enum class WifiMacRandomization(val id: Int, val text: Int) { + None(WifiConfiguration.RANDOMIZATION_NONE, R.string.none), + Persistent(WifiConfiguration.RANDOMIZATION_PERSISTENT, R.string.persistent), + NonPersistent(WifiConfiguration.RANDOMIZATION_NON_PERSISTENT, R.string.non_persistent), + Auto(WifiConfiguration.RANDOMIZATION_AUTO, R.string.auto) +} + +@Suppress("InlinedApi", "DEPRECATION") +enum class WifiSecurity(val id: Int, val text: Int) { + Open(WifiConfiguration.SECURITY_TYPE_OPEN, R.string.wifi_security_open), + Psk(WifiConfiguration.SECURITY_TYPE_PSK, R.string.wifi_security_psk) +} + +@Suppress("DEPRECATION") +enum class WifiStatus(val id: Int, val text: Int) { + Current(WifiConfiguration.Status.CURRENT, R.string.current), + Enabled(WifiConfiguration.Status.ENABLED, R.string.enabled), + Disabled(WifiConfiguration.Status.DISABLED, R.string.disabled) +} + +class IpConf(val address: String, val gateway: String, val dns: List) + +class ProxyConf(val host: String, val port: Int, val exclude: List) + +enum class IpMode(val text: Int) { + Dhcp(R.string.wifi_mode_dhcp), Static(R.string.static_str) +} + +enum class ProxyMode(val text: Int) { + None(R.string.none), Http(R.string.http) +} + +class SsidPolicy( + val type: SsidPolicyType = SsidPolicyType.None, + val list: List = emptyList() +) + +@Suppress("InlinedApi") +enum class SsidPolicyType(val id: Int, val text: Int) { + None(-1, R.string.none), + Whitelist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_ALLOWLIST, R.string.whitelist), + Blacklist(WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_DENYLIST, R.string.blacklist) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiScreen.kt new file mode 100644 index 0000000..43b711c --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiScreen.kt @@ -0,0 +1,782 @@ +package com.bintianqi.owndroid.feature.network + +import android.Manifest +import android.app.admin.DevicePolicyManager +import android.os.Build.VERSION +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.FunctionItem +import com.bintianqi.owndroid.ui.ListItem +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.showOperationResultToast +import com.bintianqi.owndroid.utils.writeClipBoard +import com.bintianqi.owndroid.utils.yesOrNo +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WifiScreen( + vm: WifiViewModel, navigate: (Destination) -> Unit, navigateUp: () -> Unit +) { + val coroutine = rememberCoroutineScope() + val pagerState = rememberPagerState { 3 } + var tabIndex by rememberSaveable { mutableIntStateOf(0) } + tabIndex = pagerState.currentPage + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.wifi)) }, + navigationIcon = { NavIcon(navigateUp) }, + colors = TopAppBarDefaults.topAppBarColors( + MaterialTheme.colorScheme.surfaceContainer + ) + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + PrimaryTabRow(tabIndex) { + Tab( + tabIndex == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.overview)) } + ) + Tab( + tabIndex == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.saved_networks)) } + ) + Tab( + tabIndex == 2, { coroutine.launch { pagerState.animateScrollToPage(2) } }, + text = { Text(stringResource(R.string.add_network)) } + ) + } + HorizontalPager(state = pagerState, verticalAlignment = Alignment.Top) { page -> + @Suppress("NewApi") + when (page) { + 0 -> WifiOverviewScreen(vm, navigate) + 1 -> SavedNetworks(vm) { + navigate(Destination.UpdateNetwork(it)) + } + 2 -> AddNetworkScreenContent(vm) { + coroutine.launch { pagerState.animateScrollToPage(1) } + } + } + } + } + } +} + +@Composable +private fun WifiOverviewScreen( + vm: WifiViewModel, navigate: (Destination) -> Unit +) { + val context = LocalContext.current + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + var macDialog by rememberSaveable { mutableStateOf(false) } + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(10.dp)) + Row( + Modifier.fillMaxWidth(), + Arrangement.Center, + ) { + Button({ vm.setWifiEnabled(true) }) { + Text(stringResource(R.string.enable)) + } + Spacer(Modifier.width(8.dp)) + Button({ vm.setWifiEnabled(false) }) { + Text(stringResource(R.string.disable)) + } + } + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + Arrangement.Center + ) { + Button({ vm.disconnect() }) { + Text(stringResource(R.string.disconnect)) + } + Spacer(Modifier.width(8.dp)) + Button({ vm.reconnect() }) { + Text(stringResource(R.string.reconnect)) + } + } + if (VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { + FunctionItem(R.string.wifi_mac_address) { macDialog = true } + } + if (VERSION.SDK_INT >= 33 && (privilege.device || privilege.org)) { + FunctionItem(R.string.min_wifi_security_level) { + navigate(Destination.WifiSecurityLevel) + } + FunctionItem(R.string.wifi_ssid_policy) { + navigate(Destination.WifiSsidPolicy) + } + } + } + if (macDialog && VERSION.SDK_INT >= 24) { + val mac by vm.macState.collectAsState() + LaunchedEffect(Unit) { + vm.getMac() + } + AlertDialog( + title = { Text(stringResource(R.string.wifi_mac_address)) }, + text = { + OutlinedTextField( + mac ?: stringResource(R.string.none), {}, + Modifier.fillMaxWidth(), + readOnly = true, + textStyle = MaterialTheme.typography.bodyLarge, + trailingIcon = { + if (mac != null) IconButton({ writeClipBoard(context, mac!!) }) { + Icon(painterResource(R.drawable.content_copy_fill0), null) + } + } + ) + }, + onDismissRequest = { macDialog = false }, + confirmButton = { + TextButton({ macDialog = false }) { + Text(stringResource(R.string.confirm)) + } + } + ) + } +} + +@Suppress("DEPRECATION") +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun SavedNetworks( + vm: WifiViewModel, editNetwork: (Int) -> Unit +) { + var dialog by rememberSaveable { mutableIntStateOf(-1) } + val list by vm.configuredNetworksState.collectAsState() + LaunchedEffect(Unit) { + vm.getConfiguredNetworks() + } + val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) vm.getConfiguredNetworks() + } + LazyColumn { + item { + if (!locationPermission.status.isGranted) Row( + Modifier + .padding(10.dp) + .fillMaxWidth() + .padding(12.dp) + .clip(RoundedCornerShape(15)) + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }, + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.LocationOn, null, + Modifier.padding(start = 8.dp, end = 4.dp), + MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + stringResource(R.string.request_location_permission_description), + Modifier.padding(8.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + itemsIndexed(list) { index, network -> + Row( + Modifier.fillMaxWidth().padding(12.dp, 4.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(network.ssid) + IconButton({ dialog = index }) { + Icon(painterResource(R.drawable.more_horiz_fill0), null) + } + } + } + } + if (dialog != -1) AlertDialog( + text = { + val network = list[dialog] + Column { + Text(stringResource(R.string.network_id) + ": " + network.id.toString()) + Spacer(Modifier.height(4.dp)) + Text("SSID", style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text(network.ssid) + } + Spacer(Modifier.height(4.dp)) + if (network.bssid.isNotEmpty()) { + Text("BSSID", style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text(network.bssid) + } + Spacer(Modifier.height(4.dp)) + } + Text(stringResource(R.string.status), style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text(stringResource(network.status.text)) + } + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp), Arrangement.SpaceBetween + ) { + FilledTonalButton({ + if (network.status == WifiStatus.Disabled) { + vm.enableNetwork(network.id) + } else { + vm.disableNetwork(network.id) + } + dialog = -1 + }) { + if (network.status == WifiStatus.Disabled) { + Text(stringResource(R.string.enable)) + } else { + Text(stringResource(R.string.disable)) + } + } + Row { + FilledTonalIconButton({ + editNetwork(dialog) + dialog = -1 + }) { + Icon(Icons.Outlined.Edit, stringResource(R.string.edit)) + } + FilledTonalIconButton({ + vm.removeNetwork(network.id) + dialog = -1 + }) { + Icon(Icons.Outlined.Delete, stringResource(R.string.delete)) + } + } + } + } + }, + confirmButton = { + TextButton({ dialog = -1 }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { dialog = -1 } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdateNetworkScreen( + vm: WifiViewModel, onNavigateUp: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.update_network)) }, + navigationIcon = { NavIcon(onNavigateUp) } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + Modifier.fillMaxSize().padding(paddingValues) + ) { + AddNetworkScreenContent(vm, onNavigateUp) + } + } +} + +@Composable +fun UnchangedMenuItem(onClick: () -> Unit) { + DropdownMenuItem({ Text(stringResource(R.string.unchanged)) }, onClick) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddNetworkScreenContent( + vm: WifiViewModel, onNavigateUp: () -> Unit +) { + val wifiInfo = vm.selectedWifiInfo + val updating = wifiInfo != null + val context = LocalContext.current + var menu by remember { mutableStateOf(AddNetworkMenu.None) } + var status by rememberSaveable { mutableStateOf(WifiStatus.Enabled) } + var ssid by rememberSaveable { mutableStateOf(wifiInfo?.ssid ?: "") } + var hiddenSsid by rememberSaveable { mutableStateOf(false) } + var security by rememberSaveable { mutableStateOf(WifiSecurity.Open) } + var password by rememberSaveable { mutableStateOf("") } + var macRandomization by rememberSaveable { + mutableStateOf(WifiMacRandomization.None) + } + var ipMode by rememberSaveable { mutableStateOf(IpMode.Dhcp) } + var ipAddress by rememberSaveable { mutableStateOf("") } + var gatewayAddress by rememberSaveable { mutableStateOf("") } + var dnsServers by rememberSaveable { mutableStateOf("") } + var proxyMode by rememberSaveable { mutableStateOf(ProxyMode.None) } + var httpProxyHost by rememberSaveable { mutableStateOf("") } + var httpProxyPort by rememberSaveable { mutableStateOf("") } + var httpProxyExclList by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + if (updating) { + hiddenSsid = null + security = null + macRandomization = null + ipMode = null + proxyMode = null + status = wifiInfo.status + ssid = wifiInfo.ssid + } + } + Column( + Modifier.verticalScroll(rememberScrollState()).padding(horizontal = HorizontalPadding) + ) { + Spacer(Modifier.height(4.dp)) + ExposedDropdownMenuBox( + menu == AddNetworkMenu.Status, + { menu = if (it) AddNetworkMenu.Status else AddNetworkMenu.None }, + Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(status.text), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.status)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Status) + }, + ) + ExposedDropdownMenu(menu == AddNetworkMenu.Status, { menu = AddNetworkMenu.None }) { + WifiStatus.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + status = it + menu = AddNetworkMenu.None + } + ) + } + } + } + OutlinedTextField( + ssid, { ssid = it }, + Modifier.fillMaxWidth().padding(bottom = 8.dp), + label = { Text("SSID") } + ) + ExposedDropdownMenuBox( + menu == AddNetworkMenu.HiddenSSID, + { menu = if (it) AddNetworkMenu.HiddenSSID else AddNetworkMenu.None }, + Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(hiddenSsid?.yesOrNo ?: R.string.unchanged), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.hidden_ssid)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.HiddenSSID) + } + ) + DropdownMenu(menu == AddNetworkMenu.HiddenSSID, { menu = AddNetworkMenu.None }) { + if (updating) UnchangedMenuItem { + hiddenSsid = null + menu = AddNetworkMenu.None + } + DropdownMenuItem( + { Text(stringResource(R.string.yes)) }, + { + hiddenSsid = true + menu = AddNetworkMenu.None + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.no)) }, + { + hiddenSsid = false + menu = AddNetworkMenu.None + } + ) + } + } + ExposedDropdownMenuBox( + menu == AddNetworkMenu.Security, + { menu = if (it) AddNetworkMenu.Security else AddNetworkMenu.None }, + Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(security?.text ?: R.string.unchanged), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.security)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Security) } + ) + ExposedDropdownMenu(menu == AddNetworkMenu.Security, { menu = AddNetworkMenu.None }) { + if (updating) UnchangedMenuItem { security = null } + WifiSecurity.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + security = it + menu = AddNetworkMenu.None + } + ) + } + } + } + AnimatedVisibility(security == WifiSecurity.Psk) { + OutlinedTextField( + password, { password = it }, + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { Text(stringResource(R.string.password)) } + ) + } + if (VERSION.SDK_INT >= 33) { + ExposedDropdownMenuBox( + menu == AddNetworkMenu.MacRandomization, + { menu = if (it) AddNetworkMenu.MacRandomization else AddNetworkMenu.None }, + Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(macRandomization?.text ?: R.string.unchanged), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.mac_randomization)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + menu == AddNetworkMenu.MacRandomization + ) + } + ) + ExposedDropdownMenu( + menu == AddNetworkMenu.MacRandomization, { menu = AddNetworkMenu.None } + ) { + if (updating) UnchangedMenuItem { macRandomization = null } + WifiMacRandomization.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + macRandomization = it + menu = AddNetworkMenu.MacRandomization + } + ) + } + } + } + } + if (VERSION.SDK_INT >= 33) { + ExposedDropdownMenuBox( + menu == AddNetworkMenu.Ip, + { menu = if (it) AddNetworkMenu.Ip else AddNetworkMenu.None }, + Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(ipMode?.text ?: R.string.unchanged), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, label = { Text(stringResource(R.string.ip_settings)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Ip) + } + ) + ExposedDropdownMenu(menu == AddNetworkMenu.Ip, { menu = AddNetworkMenu.None }) { + if (updating) UnchangedMenuItem { ipMode = null } + IpMode.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + ipMode = it + menu = AddNetworkMenu.None + } + ) + } + } + } + AnimatedVisibility(ipMode == IpMode.Static) { + Column { + OutlinedTextField( + ipAddress, { ipAddress = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.ip_address)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + OutlinedTextField( + gatewayAddress, { gatewayAddress = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.gateway_address)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + OutlinedTextField( + dnsServers, { dnsServers = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.dns_servers)) }, + minLines = 2, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + } + } + if (VERSION.SDK_INT >= 26) { + ExposedDropdownMenuBox( + menu == AddNetworkMenu.Proxy, + { menu = if (it) AddNetworkMenu.Proxy else AddNetworkMenu.None }, + Modifier.padding(bottom = 8.dp) + ) { + OutlinedTextField( + stringResource(proxyMode?.text ?: R.string.unchanged), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.proxy)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(menu == AddNetworkMenu.Proxy) + } + ) + ExposedDropdownMenu(menu == AddNetworkMenu.Proxy, { menu = AddNetworkMenu.None }) { + if (updating) UnchangedMenuItem { proxyMode = null } + ProxyMode.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + proxyMode = it + menu = AddNetworkMenu.None + } + ) + } + } + } + AnimatedVisibility(proxyMode == ProxyMode.Http) { + Column { + OutlinedTextField( + httpProxyHost, { httpProxyHost = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.host)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + OutlinedTextField( + httpProxyPort, { httpProxyPort = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.port)) }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, keyboardType = KeyboardType.Number + ) + ) + OutlinedTextField( + httpProxyExclList, { httpProxyExclList = it }, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.excluded_hosts)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + minLines = 2 + ) + } + } + } + Button( + onClick = { + val proxyConf = if (proxyMode == ProxyMode.Http) { + ProxyConf( + httpProxyHost, httpProxyPort.toInt(), + httpProxyExclList.lines().filter { it.isNotBlank() } + ) + } else null + val ipConf = if (ipMode == IpMode.Static) { + IpConf(ipAddress, gatewayAddress, dnsServers.lines().filter { it.isNotBlank() }) + } else null + val result = vm.setWifi( + WifiInfo( + -1, ssid, hiddenSsid, "", macRandomization, status, security, password, + ipMode, ipConf, proxyMode, proxyConf + ) + ) + context.showOperationResultToast(result) + if (result) onNavigateUp() + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + enabled = (proxyMode != ProxyMode.Http || + (httpProxyPort.toIntOrNull() != null && httpProxyHost.isNotBlank())) + ) { + Text(stringResource(if (updating) R.string.update else R.string.add)) + } + Spacer(Modifier.height(BottomPadding)) + } +} + +@RequiresApi(33) +@Composable +fun WifiSecurityLevelScreen( + vm: WifiViewModel, onNavigateUp: () -> Unit +) { + val level by vm.minWifiSecurityLevelState.collectAsState() + MyScaffold(R.string.min_wifi_security_level, onNavigateUp, 0.dp) { + FullWidthRadioButtonItem( + R.string.wifi_security_open, level == DevicePolicyManager.WIFI_SECURITY_OPEN + ) { + vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_OPEN) + } + FullWidthRadioButtonItem( + "WEP, WPA(2)-PSK", level == DevicePolicyManager.WIFI_SECURITY_PERSONAL + ) { + vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_PERSONAL) + } + FullWidthRadioButtonItem( + "WPA-EAP", level == DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_EAP + ) { + vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_EAP) + } + FullWidthRadioButtonItem( + "WPA3-192bit", level == DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_192 + ) { + vm.setMinimumWifiSecurityLevel(DevicePolicyManager.WIFI_SECURITY_ENTERPRISE_192) + } + Spacer(Modifier.height(12.dp)) + Notes(R.string.info_minimum_wifi_security_level, HorizontalPadding) + } +} + +@RequiresApi(33) +@Composable +fun WifiSsidPolicyScreen( + vm: WifiViewModel, onNavigateUp: () -> Unit +) { + MyScaffold(R.string.wifi_ssid_policy, onNavigateUp, 0.dp) { + var type by rememberSaveable { mutableStateOf(SsidPolicyType.None) } + val list = rememberSaveable { mutableStateListOf() } + LaunchedEffect(Unit) { + vm.getSsidPolicy().let { + type = it.type + list.addAll(it.list) + } + } + SsidPolicyType.entries.forEach { + FullWidthRadioButtonItem(it.text, type == it) { type = it } + } + AnimatedVisibility(type != SsidPolicyType.None) { + var inputSsid by remember { mutableStateOf("") } + Column(Modifier.padding(horizontal = HorizontalPadding)) { + Column(Modifier.animateContentSize()) { + for(i in list) { + ListItem(i) { list -= i } + } + } + Spacer(Modifier.padding(vertical = 5.dp)) + OutlinedTextField( + inputSsid, { inputSsid = it }, + Modifier.fillMaxWidth(), + label = { Text("SSID") }, + trailingIcon = { + IconButton( + onClick = { + list += inputSsid + inputSsid = "" + }, + enabled = inputSsid.isNotEmpty() + ) { + Icon(Icons.Default.Add, stringResource(R.string.add)) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + } + Button( + { + vm.setSsidPolicy(SsidPolicy(type, list)) + }, + Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + type == SsidPolicyType.None || list.isNotEmpty() + ) { + Text(stringResource(R.string.apply)) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiViewModel.kt new file mode 100644 index 0000000..0cfac89 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/network/WifiViewModel.kt @@ -0,0 +1,160 @@ +package com.bintianqi.owndroid.feature.network + +import android.app.admin.WifiSsidPolicy +import android.content.Context +import android.net.IpConfiguration +import android.net.LinkAddress +import android.net.ProxyInfo +import android.net.StaticIpConfiguration +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.net.wifi.WifiSsid +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.net.InetAddress +import kotlin.reflect.jvm.jvmErasure + +class WifiViewModel( + val application: MyApplication, val privilegeHelper: PrivilegeHelper, + val toastChannel: ToastChannel, val privilegeState: StateFlow +) : ViewModel() { + val wm = application.getSystemService(Context.WIFI_SERVICE) as WifiManager + + fun setWifiEnabled(enabled: Boolean) { + toastChannel.sendStatus(wm.setWifiEnabled(enabled)) + } + + fun disconnect() { + toastChannel.sendStatus(wm.disconnect()) + } + + fun reconnect() { + toastChannel.sendStatus(wm.reconnect()) + } + + val macState = MutableStateFlow(null) + + @RequiresApi(24) + fun getMac() { + macState.value = privilegeHelper.dpm.getWifiMacAddress(privilegeHelper.dar) + } + + val configuredNetworksState = MutableStateFlow(emptyList()) + + @Suppress("MissingPermission") + fun getConfiguredNetworks() { + configuredNetworksState.value = wm.configuredNetworks.distinctBy { it.networkId }.map { conf -> + WifiInfo( + conf.networkId, conf.SSID.removeSurrounding("\""), null, conf.BSSID ?: "", null, + WifiStatus.entries.find { it.id == conf.status }!!, null, "", null, null, null, null + ) + } + } + + fun enableNetwork(id: Int) { + toastChannel.sendStatus(wm.enableNetwork(id, false)) + getConfiguredNetworks() + } + + fun disableNetwork(id: Int) { + toastChannel.sendStatus(wm.disableNetwork(id)) + getConfiguredNetworks() + } + + fun removeNetwork(id: Int) { + toastChannel.sendStatus(wm.removeNetwork(id)) + getConfiguredNetworks() + } + + var selectedWifiInfo: WifiInfo? = null + + fun setWifi(info: WifiInfo): Boolean { + val conf = WifiConfiguration() + conf.SSID = "\"" + info.ssid + "\"" + info.hiddenSsid?.let { conf.hiddenSSID = it } + if (VERSION.SDK_INT >= 30) info.security?.let { conf.setSecurityParams(it.id) } + if (info.security == WifiSecurity.Psk) conf.preSharedKey = info.password + if (VERSION.SDK_INT >= 33) info.macRandomization?.let { + conf.macRandomizationSetting = it.id + } + if (VERSION.SDK_INT >= 33 && info.ipMode != null) { + val ipConf = if (info.ipMode == IpMode.Static && info.ipConf != null) { + val constructor = LinkAddress::class.constructors.find { + it.parameters.size == 1 && it.parameters[0].type.jvmErasure == String::class + } + val address = constructor!!.call(info.ipConf.address) + val staticIpConf = StaticIpConfiguration.Builder() + .setIpAddress(address) + .setGateway(InetAddress.getByName(info.ipConf.gateway)) + .setDnsServers(info.ipConf.dns.map { InetAddress.getByName(it) }) + .build() + IpConfiguration.Builder().setStaticIpConfiguration(staticIpConf).build() + } else null + conf.setIpConfiguration(ipConf) + } + if (VERSION.SDK_INT >= 26 && info.proxyMode != null) { + val proxy = if (info.proxyMode == ProxyMode.Http) { + info.proxyConf?.let { + ProxyInfo.buildDirectProxy(it.host, it.port, it.exclude) + } + } else null + conf.httpProxy = proxy + } + val result = if (info.id != -1) { + conf.networkId = info.id + wm.updateNetwork(conf) + } else { + wm.addNetwork(conf) + } + if (result != -1) { + when (info.status) { + WifiStatus.Current -> wm.enableNetwork(result, true) + WifiStatus.Enabled -> wm.enableNetwork(result, false) + WifiStatus.Disabled -> wm.disableNetwork(result) + } + getConfiguredNetworks() + } + return result != -1 + } + + val minWifiSecurityLevelState = MutableStateFlow(0) + + @RequiresApi(33) + fun getMinimumWifiSecurityLevel() { + minWifiSecurityLevelState.value = privilegeHelper.dpm.minimumRequiredWifiSecurityLevel + } + + @RequiresApi(33) + fun setMinimumWifiSecurityLevel(level: Int) { + privilegeHelper.dpm.minimumRequiredWifiSecurityLevel = level + getMinimumWifiSecurityLevel() + } + + @RequiresApi(33) + fun getSsidPolicy(): SsidPolicy { + val policy = privilegeHelper.dpm.wifiSsidPolicy + return SsidPolicy( + SsidPolicyType.entries.find { it.id == policy?.policyType } ?: SsidPolicyType.None, + policy?.ssids?.map { it.bytes.decodeToString() } ?: emptyList() + ) + } + + @RequiresApi(33) + fun setSsidPolicy(policy: SsidPolicy) { + val newPolicy = if (policy.type != SsidPolicyType.None) { + WifiSsidPolicy( + policy.type.id, + policy.list.map { WifiSsid.fromBytes(it.encodeToByteArray()) }.toSet() + ) + } else null + privilegeHelper.dpm.wifiSsidPolicy = newPolicy + toastChannel.sendStatus(true) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordModel.kt new file mode 100644 index 0000000..6d8f494 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordModel.kt @@ -0,0 +1,66 @@ +package com.bintianqi.owndroid.feature.password + +import android.app.admin.DevicePolicyManager +import android.os.Build.VERSION +import com.bintianqi.owndroid.R + +class PasswordInfo( + val complexity: Int = 0, + val complexitySufficient: Boolean = false, + val unified: Boolean = false +) + +class RpTokenState(val set: Boolean = false, val active: Boolean = false) + + +class KeyguardDisabledFeature(val id: Int, val text: Int, val requiresApi: Int = 0) + +@Suppress("InlinedApi") +val keyguardDisabledFeatures = listOf( + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_WIDGETS_ALL, R.string.disable_keyguard_features_widgets + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_SECURE_CAMERA, + R.string.disable_keyguard_features_camera + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_SECURE_NOTIFICATIONS, + R.string.disable_keyguard_features_notification + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS, + R.string.disable_keyguard_features_unredacted_notification + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_TRUST_AGENTS, + R.string.disable_keyguard_features_trust_agents + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_FINGERPRINT, + R.string.disable_keyguard_features_fingerprint + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_FACE, R.string.disable_keyguard_features_face, 28 + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_IRIS, R.string.disable_keyguard_features_iris, 28 + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_BIOMETRICS, + R.string.disable_keyguard_features_biometrics, 28 + ), + KeyguardDisabledFeature( + DevicePolicyManager.KEYGUARD_DISABLE_SHORTCUTS_ALL, + R.string.disable_keyguard_features_shortcuts, 34 + ) +).filter { VERSION.SDK_INT >= it.requiresApi } + +enum class KeyguardDisableMode(val text: Int) { + None(R.string.enable_all), Custom(R.string.custom), All(R.string.disable_all) +} + +data class KeyguardDisableConfig( + val mode: KeyguardDisableMode = KeyguardDisableMode.None, + val flags: Int = 0 +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordScreen.kt new file mode 100644 index 0000000..ccd6e27 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordScreen.kt @@ -0,0 +1,487 @@ +package com.bintianqi.owndroid.feature.password + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC +import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC +import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK +import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC +import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX +import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_SOMETHING +import android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED +import android.app.admin.DevicePolicyManager.RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT +import android.app.admin.DevicePolicyManager.RESET_PASSWORD_REQUIRE_ENTRY +import android.content.Context +import android.os.Build.VERSION +import android.os.UserManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.FunctionItem +import com.bintianqi.owndroid.ui.InfoItem +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.RadioButtonItem +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.generateBase64Key +import com.bintianqi.owndroid.utils.yesOrNo + +@SuppressLint("NewApi") +@Composable +fun PasswordScreen( + vm: PasswordViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit +) { + val context = LocalContext.current + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + var dialog by rememberSaveable { mutableIntStateOf(0) } + MyScaffold(R.string.password_and_keyguard, onNavigateUp, 0.dp) { + FunctionItem(R.string.password_info, icon = R.drawable.info_fill0) { + onNavigate(Destination.PasswordInfo) + } + if (vm.getDisplayDangerousFeatures()) { + if (VERSION.SDK_INT >= 26) { + FunctionItem(R.string.reset_password_token, icon = R.drawable.key_vertical_fill0) { + onNavigate(Destination.ResetPasswordToken) + } + } + FunctionItem(R.string.reset_password, icon = R.drawable.lock_reset_fill0) { + onNavigate(Destination.ResetPassword) + } + } + if (VERSION.SDK_INT >= 31) { + FunctionItem(R.string.required_password_complexity, icon = R.drawable.password_fill0) { + onNavigate(Destination.RequiredPasswordComplexity) + } + } + FunctionItem( + R.string.disable_keyguard_features, icon = R.drawable.screen_lock_portrait_fill0 + ) { + onNavigate(Destination.KeyguardDisabledFeatures) + } + if (privilege.device) { + FunctionItem(R.string.max_time_to_lock, icon = R.drawable.schedule_fill0) { dialog = 1 } + FunctionItem( + R.string.pwd_expiration_timeout, icon = R.drawable.lock_clock_fill0 + ) { dialog = 3 } + if (vm.getDisplayDangerousFeatures()) { + FunctionItem( + R.string.max_pwd_fail, icon = R.drawable.no_encryption_fill0 + ) { dialog = 4 } + } + } + if (VERSION.SDK_INT >= 26) { + FunctionItem( + R.string.required_strong_auth_timeout, icon = R.drawable.fingerprint_off_fill0 + ) { dialog = 2 } + } + FunctionItem(R.string.pwd_history, icon = R.drawable.history_fill0) { dialog = 5 } + if (VERSION.SDK_INT < 31) { + FunctionItem(R.string.required_password_quality, icon = R.drawable.password_fill0) { + onNavigate(Destination.RequiredPasswordQuality) + } + } + } + if (dialog != 0) { + val input by when (dialog) { + 1 -> vm.maxTimeToLockState + 2 -> vm.strongAutoTimeoutState + 3 -> vm.expirationTimeoutState + 4 -> vm.maxFailedForWipeState + else -> vm.historyLengthState + }.collectAsState() + LaunchedEffect(Unit) { + when (dialog) { + 1 -> vm.getMaxTimeToLock() + 2 -> vm.getStrongAuthTimeout() + 3 -> vm.getExpirationTimeout() + 4 -> vm.getMaxFailedForWipe() + 5 -> vm.getHistoryLength() + } + } + AlertDialog( + title = { + Text( + stringResource( + when (dialog) { + 1 -> R.string.max_time_to_lock + 2 -> R.string.required_strong_auth_timeout + 3 -> R.string.pwd_expiration_timeout + 4 -> R.string.max_pwd_fail + 5 -> R.string.pwd_history + else -> R.string.password + } + ) + ) + }, + text = { + val um = context.getSystemService(Context.USER_SERVICE) as UserManager + Column { + OutlinedTextField( + input, { + when (dialog) { + 1 -> vm.setMaxTimeToLock(it) + 2 -> vm.setStrongAuthTimeout(it) + 3 -> vm.setExpirationTimeout(it) + 4 -> vm.setMaxFailedForWipe(it) + 5 -> vm.setHistoryLength(it) + } + }, + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { + Text( + stringResource( + when (dialog) { + 1, 2, 3 -> R.string.time_unit_ms + 4 -> R.string.max_pwd_fail_textfield + 5 -> R.string.length + else -> R.string.password + } + ) + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ), + textStyle = typography.bodyLarge + ) + Text( + stringResource( + when (dialog) { + 1 -> R.string.info_screen_timeout + 2 -> R.string.info_required_strong_auth_timeout + 3 -> R.string.info_password_expiration_timeout + 4 -> if (um.isSystemUser) R.string.info_max_failed_password_system_user else R.string.info_max_failed_password_other_user + 5 -> R.string.info_password_history_length + else -> R.string.password + } + ) + ) + } + }, + confirmButton = { + TextButton( + { + when (dialog) { + 1 -> vm.applyMaxTimeToLock() + 2 -> vm.applyStrongAuthTimeout() + 3 -> vm.applyExpirationTimeout() + 4 -> vm.applyMaxFiledForWipe() + 5 -> vm.applyHistoryLength() + } + dialog = 0 + }, + enabled = input.toLongOrNull() != null + ) { + Text(stringResource(R.string.apply)) + } + }, + dismissButton = { + TextButton({ dialog = 0 }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { + dialog = 0 + } + ) + } +} + +fun getComplexityText(complexity: Int): Int { + return when (complexity) { + DevicePolicyManager.PASSWORD_COMPLEXITY_NONE -> R.string.none + DevicePolicyManager.PASSWORD_COMPLEXITY_LOW -> R.string.low + DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM -> R.string.medium + DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH -> R.string.high + else -> R.string.unknown + } +} + +@Composable +fun PasswordInfoScreen( + vm: PasswordViewModel, onNavigateUp: () -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + val info by vm.passwordInfoState.collectAsState() + var dialog by rememberSaveable { mutableIntStateOf(0) } // 0:none, 1:password complexity + LaunchedEffect(Unit) { + vm.getPasswordInfo() + } + MyScaffold(R.string.password_info, onNavigateUp, 0.dp) { + if (VERSION.SDK_INT >= 31) { + InfoItem( + R.string.current_password_complexity, getComplexityText(info.complexity), true + ) { dialog = 1 } + } + InfoItem(R.string.password_sufficient, info.complexitySufficient.yesOrNo) + if (VERSION.SDK_INT >= 28 && privilege.work) { + InfoItem(R.string.unified_password, info.unified.yesOrNo) + } + } + if (dialog != 0) AlertDialog( + text = { Text(stringResource(R.string.info_password_complexity)) }, + confirmButton = { + TextButton({ dialog = 0 }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { dialog = 0 } + ) +} + +@RequiresApi(26) +@Composable +fun ResetPasswordTokenScreen( + vm: PasswordViewModel, onNavigateUp: () -> Unit +) { + var token by rememberSaveable { mutableStateOf("") } + val tokenSize = token.encodeToByteArray().size + val state by vm.rpTokenState.collectAsState() + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) vm.getRpTokenState() + } + MyScaffold(R.string.reset_password_token, onNavigateUp) { + OutlinedTextField( + token, { token = it }, Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.token)) }, + supportingText = { Text("${tokenSize}/32 bytes") }, + trailingIcon = { + IconButton({ token = generateBase64Key(24) }) { + Icon(painterResource(R.drawable.casino_fill0), null) + } + } + ) + Button( + { + vm.setRpToken(token) + }, + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + tokenSize >= 32 + ) { + Text(stringResource(R.string.set)) + } + if (state.set && !state.active) Button( + { + val intent = vm.createActivateRpTokenIntent() + if (intent != null) { + launcher.launch(intent) + } + }, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.activate)) + } + if (state.set) Button( + vm::clearRpToken, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.clear)) + } + Spacer(Modifier.padding(vertical = 5.dp)) + Notes(R.string.activate_token_not_required_when_no_password) + } +} + +@Composable +fun ResetPasswordScreen(vm: PasswordViewModel, onNavigateUp: () -> Unit) { + var password by rememberSaveable { mutableStateOf("") } + var token by rememberSaveable { mutableStateOf("") } + var flags by rememberSaveable { mutableIntStateOf(0) } + var confirmPassword by rememberSaveable { mutableStateOf("") } + MyScaffold(R.string.reset_password, onNavigateUp) { + if (VERSION.SDK_INT >= 26) { + OutlinedTextField( + token, { token = it }, Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + label = { Text(stringResource(R.string.token)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + } + OutlinedTextField( + password, { password = it }, + Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.password)) }, + isError = password.length in 1..3, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Next + ), + visualTransformation = PasswordVisualTransformation() + ) + OutlinedTextField( + confirmPassword, { confirmPassword = it }, + Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + isError = confirmPassword != password, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Done + ), + visualTransformation = PasswordVisualTransformation() + ) + Spacer(Modifier.padding(vertical = 5.dp)) + CheckBoxItem( + R.string.do_not_ask_credentials_on_boot, + flags and RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT != 0 + ) { flags = flags xor RESET_PASSWORD_DO_NOT_ASK_CREDENTIALS_ON_BOOT } + CheckBoxItem( + R.string.reset_password_require_entry, + flags and RESET_PASSWORD_REQUIRE_ENTRY != 0 + ) { flags = flags xor RESET_PASSWORD_REQUIRE_ENTRY } + Spacer(Modifier.padding(vertical = 5.dp)) + Button( + { + vm.resetPassword(password, token, flags) + }, + Modifier.fillMaxWidth(), + password == confirmPassword, + colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError) + ) { + Text(stringResource(R.string.reset_password)) + } + Notes(R.string.info_reset_password) + } +} + +@RequiresApi(31) +@Composable +fun RequiredPasswordComplexityScreen( + vm: PasswordViewModel, onNavigateUp: () -> Unit +) { + val complexity by vm.requiredComplexityState.collectAsState() + LaunchedEffect(Unit) { + vm.getRequiredComplexity() + } + MyScaffold(R.string.required_password_complexity, onNavigateUp, 0.dp) { + listOf( + DevicePolicyManager.PASSWORD_COMPLEXITY_NONE to R.string.none, + DevicePolicyManager.PASSWORD_COMPLEXITY_LOW to R.string.low, + DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM to R.string.medium, + DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH to R.string.high + ).forEach { + FullWidthRadioButtonItem(it.second, complexity == it.first) { + vm.setRequiredComplexity(it.first) + } + } + Button( + vm::applyRequiredComplexity, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp) + ) { + Text(text = stringResource(R.string.apply)) + } + Notes(R.string.info_password_complexity, HorizontalPadding) + } +} + + +@Composable +fun KeyguardDisabledFeaturesScreen( + vm: PasswordViewModel, onNavigateUp: () -> Unit +) { + val config by vm.keyguardDisableState.collectAsState() + LaunchedEffect(Unit) { + vm.getKeyguardDisableConfig() + } + MyScaffold(R.string.disable_keyguard_features, onNavigateUp, 0.dp) { + KeyguardDisableMode.entries.forEach { + FullWidthRadioButtonItem(it.text, config.mode == it) { + vm.setKeyguardDisableConfig(config.copy(mode = it)) + } + } + Spacer(Modifier.height(8.dp)) + AnimatedVisibility(config.mode == KeyguardDisableMode.Custom) { + Column { + keyguardDisabledFeatures.forEach { + FullWidthCheckBoxItem(it.text, config.flags and it.id == it.id) { _ -> + vm.setKeyguardDisableConfig(config.copy(flags = config.flags xor it.id)) + } + } + } + } + Button( + vm::applyKeyguardDisableConfig, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp) + ) { + Text(text = stringResource(R.string.apply)) + } + } +} + +@Composable +fun RequiredPasswordQualityScreen( + vm: PasswordViewModel, + onNavigateUp: () -> Unit +) { + val quality by vm.qualityState.collectAsState() + LaunchedEffect(Unit) { + vm.getQuality() + } + MyScaffold(R.string.required_password_quality, onNavigateUp) { + mapOf( + PASSWORD_QUALITY_UNSPECIFIED to R.string.password_quality_unspecified, + PASSWORD_QUALITY_SOMETHING to R.string.password_quality_something, + PASSWORD_QUALITY_ALPHABETIC to R.string.password_quality_alphabetic, + PASSWORD_QUALITY_NUMERIC to R.string.password_quality_numeric, + PASSWORD_QUALITY_ALPHANUMERIC to R.string.password_quality_alphanumeric, + PASSWORD_QUALITY_BIOMETRIC_WEAK to R.string.password_quality_biometrics_weak, + PASSWORD_QUALITY_NUMERIC_COMPLEX to R.string.password_quality_numeric_complex + ).forEach { + RadioButtonItem(it.value, quality == it.key) { vm.setQuality(it.key) } + } + Spacer(Modifier.padding(vertical = 5.dp)) + Button( + vm::applyQuality, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.apply)) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordViewModel.kt new file mode 100644 index 0000000..b7f56b0 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/password/PasswordViewModel.kt @@ -0,0 +1,219 @@ +package com.bintianqi.owndroid.feature.password + +import android.app.KeyguardManager +import android.app.admin.DevicePolicyManager +import android.content.Intent +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class PasswordViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val settingsRepo: SettingsRepository, + val privilegeState: StateFlow, val toastChannel: ToastChannel +) : ViewModel() { + fun getDisplayDangerousFeatures() = settingsRepo.data.displayDangerousFeatures + + val passwordInfoState = MutableStateFlow(PasswordInfo()) + + fun getPasswordInfo() = ph.safeDpmCall { + val privilege = privilegeState.value + passwordInfoState.value = PasswordInfo( + complexity = if (VERSION.SDK_INT >= 31) dpm.passwordComplexity else 0, + complexitySufficient = dpm.isActivePasswordSufficient, + unified = + if (VERSION.SDK_INT >= 28 && privilege.work) dpm.isUsingUnifiedPassword(dar) + else false + ) + } + + // Reset password token + val rpTokenState = MutableStateFlow(RpTokenState()) + + @RequiresApi(26) + fun getRpTokenState() { + rpTokenState.value = try { + var active = false + ph.safeDpmCall { + active = dpm.isResetPasswordTokenActive(dar) + } + RpTokenState(true, active) + } catch (_: IllegalStateException) { + RpTokenState(false, false) + } + } + + @RequiresApi(26) + fun setRpToken(token: String) { + try { + ph.safeDpmCall { + val result = dpm.setResetPasswordToken(dar, token.encodeToByteArray()) + toastChannel.sendStatus(result) + } + } catch (e: Exception) { + e.printStackTrace() + toastChannel.sendStatus(false) + } + getRpTokenState() + } + + @RequiresApi(26) + fun clearRpToken() = ph.safeDpmCall { + val result = dpm.clearResetPasswordToken(dar) + toastChannel.sendStatus(result) + getRpTokenState() + } + + @RequiresApi(26) + fun createActivateRpTokenIntent(): Intent? { + val km = application.getSystemService(KeyguardManager::class.java) + val title = application.getString(R.string.activate_reset_password_token) + return km.createConfirmDeviceCredentialIntent(title, "") + } + + fun resetPassword(password: String, token: String, flags: Int) = ph.safeDpmCall { + val result = if (VERSION.SDK_INT >= 26) { + dpm.resetPasswordWithToken(dar, password, token.encodeToByteArray(), flags) + } else { + dpm.resetPassword(password, flags) + } + toastChannel.sendStatus(result) + } + + val requiredComplexityState = MutableStateFlow(0) + + @RequiresApi(31) + fun getRequiredComplexity() = ph.safeDpmCall { + requiredComplexityState.value = dpm.requiredPasswordComplexity + } + + fun setRequiredComplexity(complexity: Int) = ph.safeDpmCall { + requiredComplexityState.value = complexity + } + + @RequiresApi(31) + fun applyRequiredComplexity() = ph.safeDpmCall { + dpm.requiredPasswordComplexity = requiredComplexityState.value + toastChannel.sendStatus(true) + } + + val keyguardDisableState = MutableStateFlow(KeyguardDisableConfig()) + + fun getKeyguardDisableConfig() = ph.safeDpmCall { + val flags = dpm.getKeyguardDisabledFeatures(dar) + val mode = when (flags) { + DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE -> KeyguardDisableMode.None + DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL -> KeyguardDisableMode.All + else -> KeyguardDisableMode.Custom + } + keyguardDisableState.value = KeyguardDisableConfig(mode, flags) + } + + fun setKeyguardDisableConfig(config: KeyguardDisableConfig) { + keyguardDisableState.value = config + } + + fun applyKeyguardDisableConfig() = ph.safeDpmCall { + val flags = when (keyguardDisableState.value.mode) { + KeyguardDisableMode.None -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_NONE + KeyguardDisableMode.All -> DevicePolicyManager.KEYGUARD_DISABLE_FEATURES_ALL + else -> keyguardDisableState.value.flags + } + dpm.setKeyguardDisabledFeatures(dar, flags) + toastChannel.sendStatus(true) + } + + val maxTimeToLockState = MutableStateFlow("") + + fun getMaxTimeToLock() = ph.safeDpmCall { + maxTimeToLockState.value = dpm.getMaximumTimeToLock(dar).toString() + } + + fun setMaxTimeToLock(time: String) { + maxTimeToLockState.value = time + } + + fun applyMaxTimeToLock() = ph.safeDpmCall { + dpm.setMaximumTimeToLock(dar, maxTimeToLockState.value.toLong()) + } + + val strongAutoTimeoutState = MutableStateFlow("") + + @RequiresApi(26) + fun getStrongAuthTimeout() = ph.safeDpmCall { + strongAutoTimeoutState.value = dpm.getRequiredStrongAuthTimeout(dar).toString() + } + + fun setStrongAuthTimeout(time: String) { + strongAutoTimeoutState.value = time + } + + @RequiresApi(26) + fun applyStrongAuthTimeout() = ph.safeDpmCall { + dpm.setRequiredStrongAuthTimeout(dar, strongAutoTimeoutState.value.toLong()) + } + + val expirationTimeoutState = MutableStateFlow("") + + fun getExpirationTimeout() = ph.safeDpmCall { + expirationTimeoutState.value = dpm.getPasswordExpirationTimeout(dar).toString() + } + + fun setExpirationTimeout(time: String) { + expirationTimeoutState.value = time + } + + fun applyExpirationTimeout() = ph.safeDpmCall { + dpm.setPasswordExpirationTimeout(dar, expirationTimeoutState.value.toLong()) + } + + val maxFailedForWipeState = MutableStateFlow("") + + fun getMaxFailedForWipe() = ph.safeDpmCall { + maxFailedForWipeState.value = dpm.getMaximumFailedPasswordsForWipe(dar).toString() + } + + fun setMaxFailedForWipe(times: String) { + maxFailedForWipeState.value = times + } + + fun applyMaxFiledForWipe() = ph.safeDpmCall { + dpm.setMaximumFailedPasswordsForWipe(dar, maxFailedForWipeState.value.toInt()) + } + + val historyLengthState = MutableStateFlow("") + + fun getHistoryLength() = ph.safeDpmCall { + historyLengthState.value = dpm.getPasswordHistoryLength(dar).toString() + } + + fun setHistoryLength(length: String) { + historyLengthState.value = length + } + + fun applyHistoryLength() = ph.safeDpmCall { + dpm.setPasswordHistoryLength(dar, historyLengthState.value.toInt()) + } + + val qualityState = MutableStateFlow(0) + + fun getQuality() = ph.safeDpmCall { + qualityState.value = dpm.getPasswordQuality(dar) + } + + fun setQuality(level: Int) { + qualityState.value = level + } + + fun applyQuality() = ph.safeDpmCall { + dpm.setPasswordQuality(dar, qualityState.value) + toastChannel.sendStatus(true) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsModel.kt new file mode 100644 index 0000000..6c98c35 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsModel.kt @@ -0,0 +1,25 @@ +package com.bintianqi.owndroid.feature.privilege + +import android.app.admin.DevicePolicyManager +import android.os.Build.VERSION +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.utils.AppInfo + +class DelegatedScope(val id: String, val string: Int, val requiresApi: Int = 26) + +@Suppress("InlinedApi") +val delegatedScopesList = listOf( + DelegatedScope(DevicePolicyManager.DELEGATION_APP_RESTRICTIONS, R.string.manage_application_restrictions), + DelegatedScope(DevicePolicyManager.DELEGATION_BLOCK_UNINSTALL, R.string.block_uninstall), + DelegatedScope(DevicePolicyManager.DELEGATION_CERT_INSTALL, R.string.manage_certificates), + DelegatedScope(DevicePolicyManager.DELEGATION_CERT_SELECTION, R.string.select_keychain_certificates, 29), + DelegatedScope(DevicePolicyManager.DELEGATION_ENABLE_SYSTEM_APP, R.string.enable_system_app), + DelegatedScope(DevicePolicyManager.DELEGATION_INSTALL_EXISTING_PACKAGE, R.string.install_existing_packages, 28), + DelegatedScope(DevicePolicyManager.DELEGATION_KEEP_UNINSTALLED_PACKAGES, R.string.manage_uninstalled_packages, 28), + DelegatedScope(DevicePolicyManager.DELEGATION_NETWORK_LOGGING, R.string.network_logging, 29), + DelegatedScope(DevicePolicyManager.DELEGATION_PACKAGE_ACCESS, R.string.change_package_state), + DelegatedScope(DevicePolicyManager.DELEGATION_PERMISSION_GRANT, R.string.grant_permissions), + DelegatedScope(DevicePolicyManager.DELEGATION_SECURITY_LOGGING, R.string.security_logging, 31) +).filter { VERSION.SDK_INT >= it.requiresApi } + +class DelegatedAdmin(val app: AppInfo, val scopes: List) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsScreen.kt new file mode 100644 index 0000000..29bc3d0 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsScreen.kt @@ -0,0 +1,187 @@ +package com.bintianqi.owndroid.feature.privilege + +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MyLazyScaffold +import com.bintianqi.owndroid.ui.MySmallTitleScaffold +import com.bintianqi.owndroid.ui.PackageNameTextField +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.channels.Channel + +@RequiresApi(26) +@Composable +fun DelegatedAdminsScreen( + vm: DelegatedAdminsViewModel, + onNavigateUp: () -> Unit, onNavigate: (Destination.DelegatedAdminDetails) -> Unit +) { + val admins by vm.delegatedAdminsState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.getDelegatedAdmins() } + MyLazyScaffold(R.string.delegated_admins, onNavigateUp) { + itemsIndexed(admins, { _, it -> it.app.name }) { index, admin -> + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp) + .animateItem(), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + rememberDrawablePainter(admin.app.icon), null, + Modifier + .padding(start = 12.dp, end = 18.dp) + .size(40.dp) + ) + Column { + Text(admin.app.label) + Text(admin.app.name, Modifier.alpha(0.8F), style = typography.bodyMedium) + } + } + IconButton({ + vm.selectedDelegatedAdminIndex = index + onNavigate(Destination.DelegatedAdminDetails) + }) { + Icon(Icons.Outlined.Edit, null) + } + } + } + item { + Row( + Modifier + .fillMaxWidth() + .clickable { + vm.selectedDelegatedAdminIndex = -1 + onNavigate(Destination.DelegatedAdminDetails) + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Add, null, Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.add_delegated_admin), style = typography.titleMedium) + } + } + } +} + +@RequiresApi(26) +@Composable +fun AddDelegatedAdminScreen( + vm: DelegatedAdminsViewModel, chosenPackage: Channel, onChoosePackage: () -> Unit, + onNavigateUp: () -> Unit +) { + val adminsList by vm.delegatedAdminsState.collectAsState() + val origin = adminsList.getOrNull(vm.selectedDelegatedAdminIndex) + val updateMode = origin != null + var input by rememberSaveable { mutableStateOf(origin?.app?.name ?: "") } + val scopes = rememberSaveable { + mutableStateListOf(*(origin?.scopes?.toTypedArray() ?: emptyArray())) + } + LaunchedEffect(Unit) { + input = chosenPackage.receive() + } + MySmallTitleScaffold( + if (updateMode) R.string.place_holder else R.string.add_delegated_admin, onNavigateUp, 0.dp + ) { + if (updateMode) { + OutlinedTextField( + input, {}, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp), + readOnly = true, label = { Text(stringResource(R.string.package_name)) } + ) + } else { + PackageNameTextField( + input, onChoosePackage, + Modifier.padding(HorizontalPadding, 8.dp) + ) { input = it } + } + delegatedScopesList.forEach { scope -> + val checked = scope.id in scopes + Row( + Modifier + .fillMaxWidth() + .clickable { if (!checked) scopes += scope.id else scopes -= scope.id } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked, { if (it) scopes += scope.id else scopes -= scope.id }, + Modifier.padding(horizontal = 4.dp) + ) + Column { + Text(stringResource(scope.string)) + Text( + scope.id, style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant + ) + } + } + } + Button( + { + vm.setDelegatedAdmin(input, scopes) + onNavigateUp() + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, vertical = 4.dp), + input.isNotBlank() + ) { + Text(stringResource(if (updateMode) R.string.update else R.string.add)) + } + if (updateMode) Button( + { + vm.setDelegatedAdmin(input, emptyList()) + onNavigateUp() + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + colors = ButtonDefaults.buttonColors(colorScheme.error, colorScheme.onError) + ) { + Text(stringResource(R.string.delete)) + } + Spacer(Modifier.height(BottomPadding)) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsViewModel.kt new file mode 100644 index 0000000..9e1a077 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DelegatedAdminsViewModel.kt @@ -0,0 +1,39 @@ +package com.bintianqi.owndroid.feature.privilege + +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.getAppInfo +import kotlinx.coroutines.flow.MutableStateFlow + +class DelegatedAdminsViewModel( + val application: MyApplication, val ph: PrivilegeHelper +) : ViewModel() { + val delegatedAdminsState = MutableStateFlow(emptyList()) + + @RequiresApi(26) + fun getDelegatedAdmins() = ph.safeDpmCall { + val pm = application.packageManager + val list = mutableListOf() + delegatedScopesList.forEach { scope -> + dpm.getDelegatePackages(dar, scope.id)?.forEach { pkg -> + val index = list.indexOfFirst { it.app.name == pkg } + if (index == -1) { + list += DelegatedAdmin(getAppInfo(pm, pkg), listOf(scope.id)) + } else { + list[index] = DelegatedAdmin(list[index].app, list[index].scopes + scope.id) + } + } + } + delegatedAdminsState.value = list + } + + var selectedDelegatedAdminIndex = -1 + + @RequiresApi(26) + fun setDelegatedAdmin(name: String, scopes: List) = ph.safeDpmCall { + dpm.setDelegatedScopes(dar, name, scopes) + getDelegatedAdmins() + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerModel.kt new file mode 100644 index 0000000..3254cfe --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerModel.kt @@ -0,0 +1,7 @@ +package com.bintianqi.owndroid.feature.privilege + +data class DhizukuClientInfo( + val uid: Int, + val signature: String?, + val permissions: List = emptyList() +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerRepository.kt new file mode 100644 index 0000000..434f331 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerRepository.kt @@ -0,0 +1,53 @@ +package com.bintianqi.owndroid.feature.privilege + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import com.bintianqi.owndroid.MyDbHelper + +class DhizukuServerRepository(val dbHelper: MyDbHelper) { + + fun getDhizukuClients(): List { + val list = mutableListOf() + dbHelper.readableDatabase.rawQuery("SELECT * FROM dhizuku_clients", null).use { cursor -> + while (cursor.moveToNext()) { + list += DhizukuClientInfo( + cursor.getInt(0), cursor.getString(1), + cursor.getString(2).split(",").filter { it.isNotEmpty() } + ) + } + } + return list + } + + fun checkDhizukuClientPermission(uid: Int, signature: String?, permission: String): Boolean { + val cursor = if (signature == null) { + dbHelper.readableDatabase.rawQuery( + "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature IS NULL", + null + ) + } else { + dbHelper.readableDatabase.rawQuery( + "SELECT permissions FROM dhizuku_clients WHERE uid = $uid AND signature = ?", + arrayOf(signature) + ) + } + return cursor.use { + it.moveToNext() && permission in it.getString(0).split(",") + } + } + + fun setDhizukuClient(info: DhizukuClientInfo) { + val cv = ContentValues() + cv.put("uid", info.uid) + cv.put("signature", info.signature) + cv.put("permissions", info.permissions.joinToString(",")) + dbHelper.writableDatabase.insertWithOnConflict( + "dhizuku_clients", null, cv, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + + fun deleteDhizukuClient(info: DhizukuClientInfo) { + dbHelper.writableDatabase.delete("dhizuku_clients", "uid = ${info.uid}", null) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerScreen.kt new file mode 100644 index 0000000..3317ef0 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerScreen.kt @@ -0,0 +1,140 @@ +package com.bintianqi.owndroid.feature.privilege + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.DhizukuPermissions +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MyLazyScaffold +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.google.accompanist.drawablepainter.rememberDrawablePainter + + +@Composable +fun DhizukuServerSettingsScreen( + vm: DhizukuServerViewModel, onNavigateUp: () -> Unit +) { + val enabled by vm.serverEnabledState.collectAsState() + val clients by vm.clientsState.collectAsState() + LaunchedEffect(Unit) { + vm.getEnabled() + vm.getClients() + } + MyLazyScaffold(R.string.dhizuku_server, onNavigateUp) { + item { + SwitchItem(R.string.enable, enabled, vm::setEnabled) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + } + if (enabled) items(clients) { (client, app) -> + Card( + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp) + ) { + DhizukuClientCardContent(client, app, vm::updateClient) + } + } + } +} + +@Composable +private fun DhizukuClientCardContent( + client: DhizukuClientInfo, app: AppInfo, update: (DhizukuClientInfo) -> Unit +) { + var expand by remember { mutableStateOf(false) } + Row( + Modifier + .fillMaxWidth() + .padding(8.dp, 8.dp, 0.dp, 8.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { + Image( + rememberDrawablePainter(app.icon), null, + Modifier + .padding(end = 16.dp) + .size(45.dp) + ) + Column { + Text(app.label, style = typography.titleMedium) + Text(app.name, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + val ts = when (DhizukuPermissions.filter { it !in client.permissions }.size) { + 0 -> ToggleableState.On + DhizukuPermissions.size -> ToggleableState.Off + else -> ToggleableState.Indeterminate + } + Row(verticalAlignment = Alignment.CenterVertically) { + TriStateCheckbox(ts, { + if (ts == ToggleableState.Off) { + update(client.copy(permissions = DhizukuPermissions)) + } else { + update(client.copy(permissions = emptyList())) + } + }) + val degrees by animateFloatAsState(if (expand) 180F else 0F) + IconButton({ expand = !expand }) { + Icon(Icons.Default.ArrowDropDown, null, Modifier.rotate(degrees)) + } + } + } + AnimatedVisibility(expand, Modifier.padding(8.dp, 0.dp, 8.dp, 8.dp)) { + Column { + mapOf( + "remote_transact" to "Remote transact", + "remote_process" to "Remote process", + "user_service" to "User service", + "delegated_scopes" to "Delegated scopes", + "other" to "Other" + ).forEach { (k, v) -> + Row( + Modifier.fillMaxWidth(), Arrangement.SpaceBetween, + Alignment.CenterVertically + ) { + Text(v) + Checkbox(k in client.permissions, { + update( + client.copy( + permissions = client.permissions.run { + if (it) plus(k) else minus(k) + } + )) + }) + } + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerViewModel.kt new file mode 100644 index 0000000..d05a6c6 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/DhizukuServerViewModel.kt @@ -0,0 +1,54 @@ +package com.bintianqi.owndroid.feature.privilege + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.getAppInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class DhizukuServerViewModel( + val application: MyApplication, val repo: DhizukuServerRepository, + val settingsRepo: SettingsRepository +) : ViewModel() { + val serverEnabledState = MutableStateFlow(false) + + fun getEnabled() { + serverEnabledState.value = settingsRepo.data.privilege.dhizukuServer + } + + fun setEnabled(status: Boolean) { + settingsRepo.update { it.privilege.dhizukuServer = status } + serverEnabledState.value = status + } + + val clientsState = MutableStateFlow(emptyList>()) + fun getClients() { + viewModelScope.launch(Dispatchers.IO) { + val pm = application.packageManager + clientsState.value = repo.getDhizukuClients().mapNotNull { + val packageName = pm.getNameForUid(it.uid) + if (packageName == null) { + repo.deleteDhizukuClient(it) + null + } else { + it to getAppInfo(pm, packageName) + } + } + } + } + + fun updateClient(info: DhizukuClientInfo) { + repo.setDhizukuClient(info) + clientsState.update { list -> + val ml = list.toMutableList() + val index = ml.indexOfFirst { it.first.uid == info.uid } + ml[index] = info to ml[index].second + ml + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipModel.kt new file mode 100644 index 0000000..0ac5bee --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipModel.kt @@ -0,0 +1,6 @@ +package com.bintianqi.owndroid.feature.privilege + +import android.content.ComponentName +import com.bintianqi.owndroid.utils.AppInfo + +class DeviceAdmin(val app: AppInfo, val dar: ComponentName) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipScreen.kt new file mode 100644 index 0000000..681b4a0 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipScreen.kt @@ -0,0 +1,110 @@ +package com.bintianqi.owndroid.feature.privilege + +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MyLazyScaffold +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.google.accompanist.drawablepainter.rememberDrawablePainter + +@RequiresApi(28) +@Composable +fun TransferOwnershipScreen( + vm: TransferOwnershipViewModel, onNavigateUp: () -> Unit, onTransferred: () -> Unit +) { + val privilege by vm.ps.collectAsState() + var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } + var dialog by rememberSaveable { mutableStateOf(false) } + val receivers by vm.deviceAdminReceivers.collectAsState() + LaunchedEffect(Unit) { vm.getDeviceAdminReceivers() } + MyLazyScaffold(R.string.transfer_ownership, onNavigateUp) { + itemsIndexed(receivers) { index, admin -> + Row( + Modifier + .fillMaxWidth() + .clickable { selectedIndex = index } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selectedIndex == index, { selectedIndex = index }) + Image(rememberDrawablePainter(admin.app.icon), null, Modifier.size(40.dp)) + Column(Modifier.padding(start = 8.dp)) { + Text(admin.app.label) + Text(admin.app.name, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + } + item { + Button( + { dialog = true }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 10.dp), + receivers.getOrNull(selectedIndex) != null + ) { + Text(stringResource(R.string.transfer)) + } + Notes(R.string.info_transfer_ownership, HorizontalPadding) + } + } + if (dialog) AlertDialog( + text = { + Text( + stringResource( + R.string.transfer_ownership_warning, + stringResource( + if (privilege.device) R.string.device_owner else R.string.profile_owner + ), + receivers[selectedIndex].app.name + ) + ) + }, + confirmButton = { + TextButton( + { + vm.transferOwnership(receivers[selectedIndex].dar) + dialog = false + onTransferred() + }, + colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) + ) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipViewModel.kt new file mode 100644 index 0000000..e754b02 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/TransferOwnershipViewModel.kt @@ -0,0 +1,53 @@ +package com.bintianqi.owndroid.feature.privilege + +import android.app.admin.DeviceAdminInfo +import android.app.admin.DeviceAdminReceiver +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.getAppInfo +import com.bintianqi.owndroid.utils.getPrivilegeStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class TransferOwnershipViewModel( + val application: MyApplication, val ph: PrivilegeHelper, + val ps: MutableStateFlow +) : ViewModel() { + val deviceAdminReceivers = MutableStateFlow(emptyList()) + + fun getDeviceAdminReceivers() { + viewModelScope.launch(Dispatchers.IO) { + val pm = application.packageManager + deviceAdminReceivers.value = pm.queryBroadcastReceivers( + Intent(DeviceAdminReceiver.ACTION_DEVICE_ADMIN_ENABLED), + PackageManager.GET_META_DATA + ).mapNotNull { + try { + DeviceAdminInfo(application, it) + } catch (_: Exception) { + null + } + }.filter { + it.isVisible && it.packageName != "com.bintianqi.owndroid" && + it.activityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 0 + }.map { + DeviceAdmin(getAppInfo(pm, it.packageName), it.component) + } + } + } + + @RequiresApi(28) + fun transferOwnership(component: ComponentName) = ph.safeDpmCall { + dpm.transferOwnership(dar, component, null) + ps.value = getPrivilegeStatus(ph) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesScreen.kt new file mode 100644 index 0000000..aa23155 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesScreen.kt @@ -0,0 +1,377 @@ +package com.bintianqi.owndroid.feature.privilege + +import android.os.Build.VERSION +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CircularProgressDialog +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.ACTIVATE_DEVICE_OWNER_COMMAND +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.activateOrgProfileCommand +import com.bintianqi.owndroid.utils.adaptiveInsets +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun WorkModesScreen( + vm: WorkingModesViewModel, params: Destination.WorkingModes, onNavigateUp: () -> Unit, + onActivate: () -> Unit, onDeactivate: () -> Unit, onNavigate: (Destination) -> Unit +) { + val privilege by vm.ps.collectAsStateWithLifecycle() + // 0: none, 1: device owner, 2: circular progress indicator, 3: result, 4: deactivate + // 5: command, 6: org profile, 7: org profile command + var dialog by rememberSaveable { mutableIntStateOf(0) } + var operationSucceed by rememberSaveable { mutableStateOf(false) } + var resultText by rememberSaveable { mutableStateOf("") } + LaunchedEffect(privilege) { + if (!params.canNavigateUp && privilege.device) { + delay(1000) + if (dialog != 3) { // Activated by ADB command + operationSucceed = true + resultText = "" + dialog = 3 + } + } + } + Scaffold( + topBar = { + TopAppBar( + { + if (!params.canNavigateUp) { + Column { + Text(stringResource(R.string.app_name)) + Text( + stringResource(R.string.choose_working_mode), Modifier.alpha(0.8F), + style = typography.bodyLarge + ) + } + } + }, + navigationIcon = { + if (params.canNavigateUp) NavIcon(onNavigateUp) + }, + actions = { + var expanded by remember { mutableStateOf(false) } + if (privilege.device || privilege.profile) Box { + IconButton({ expanded = true }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(expanded, { expanded = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.deactivate)) }, + { + expanded = false + dialog = 4 + }, + leadingIcon = { Icon(Icons.Default.Close, null) } + ) + if (VERSION.SDK_INT >= 26) DropdownMenuItem( + { Text(stringResource(R.string.delegated_admins)) }, + { + expanded = false + onNavigate(Destination.DelegatedAdmins) + }, + leadingIcon = { + Icon( + painterResource(R.drawable.admin_panel_settings_fill0), null + ) + } + ) + if (!privilege.dhizuku && VERSION.SDK_INT >= 28) DropdownMenuItem( + { Text(stringResource(R.string.transfer_ownership)) }, + { + expanded = false + onNavigate(Destination.TransferOwnership) + }, + leadingIcon = { + Icon( + painterResource(R.drawable.swap_horiz_fill0), null + ) + } + ) + } + } + if (!params.canNavigateUp) IconButton({ onNavigate(Destination.Settings) }) { + Icon(Icons.Default.Settings, null) + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + fun handleResult(succeeded: Boolean, output: String?) { + operationSucceed = succeeded + resultText = output ?: "" + dialog = 3 + } + Column( + Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (!privilege.profile) { + WorkingModeItem(R.string.device_owner, privilege.device) { + if (!privilege.device || (VERSION.SDK_INT >= 28 && privilege.dhizuku)) { + dialog = 1 + } + } + } + if (privilege.profile) WorkingModeItem(R.string.profile_owner, true) { } + if (privilege.dhizuku || !privilege.activated) { + WorkingModeItem(R.string.dhizuku, privilege.dhizuku) { + if (!privilege.dhizuku) { + dialog = 2 + vm.activateDhizukuMode(::handleResult) + } + } + } + if ( + privilege.work || (VERSION.SDK_INT < 24 || vm.isCreatingWorkProfileAllowed()) + ) { + WorkingModeItem(R.string.work_profile, privilege.work) { + if (!privilege.work) onNavigate(Destination.CreateWorkProfile) + } + } + if (privilege.work) { + WorkingModeItem(R.string.org_owned_work_profile, privilege.org) { + if (!privilege.org) dialog = 6 + } + } + if (privilege.activated && !privilege.dhizuku) Row( + Modifier + .padding(top = 20.dp) + .fillMaxWidth() + .clickable { onNavigate(Destination.DhizukuServerSettings) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(R.drawable.dhizuku_icon), null, + Modifier + .padding(8.dp) + .size(28.dp) + ) + Text(stringResource(R.string.dhizuku_server), style = typography.titleLarge) + } + + Column(Modifier.padding(HorizontalPadding, 20.dp)) { + Row( + Modifier.padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Warning, null, Modifier.padding(end = 4.dp), + colorScheme.error + ) + Text( + stringResource(R.string.warning), color = colorScheme.error, + style = typography.labelLarge + ) + } + Text(stringResource(R.string.owndroid_warning)) + } + } + if (dialog == 1) AlertDialog( + title = { Text(stringResource(R.string.activate_method)) }, + text = { + FlowRow(Modifier.fillMaxWidth()) { + if (!privilege.dhizuku) { + Button({ dialog = 5 }, Modifier.padding(end = 8.dp)) { + Text(stringResource(R.string.adb_command)) + } + Button({ + dialog = 2 + vm.activateDoByShizuku(::handleResult) + }, Modifier.padding(end = 8.dp)) { + Text(stringResource(R.string.shizuku)) + } + Button({ + dialog = 2 + vm.activateDoByRoot(::handleResult) + }, Modifier.padding(end = 8.dp)) { + Text("Root") + } + } + if (VERSION.SDK_INT >= 28 && privilege.dhizuku) Button({ + dialog = 2 + vm.activateDoByDhizuku(::handleResult) + }, Modifier.padding(end = 8.dp)) { + Text(stringResource(R.string.dhizuku)) + } + } + }, + confirmButton = { + TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } + }, + onDismissRequest = { dialog = 0 } + ) + if (dialog == 2) CircularProgressDialog { } + if (dialog == 3) AlertDialog( + title = { + Text( + stringResource(if (operationSucceed) R.string.succeeded else R.string.failed) + ) + }, + text = { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text(resultText) + } + }, + confirmButton = { + TextButton({ + dialog = 0 + if (operationSucceed && !params.canNavigateUp) onActivate() + }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = {} + ) + if (dialog == 4) AlertDialog( + title = { Text(stringResource(R.string.deactivate)) }, + text = { Text(stringResource(R.string.info_deactivate)) }, + confirmButton = { + var time by remember { mutableIntStateOf(if (privilege.dhizuku) 0 else 3) } + if (!privilege.dhizuku) LaunchedEffect(Unit) { + for (i in (0..2).reversed()) { + delay(1000) + time = i + } + } + val timeText = if (time != 0) " (${time}s)" else "" + TextButton( + { + vm.deactivate() + dialog = 0 + onDeactivate() + }, + enabled = time == 0, + colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) + ) { + Text(stringResource(R.string.confirm) + timeText) + } + }, + dismissButton = { + TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } + }, + onDismissRequest = { dialog = 0 } + ) + if (dialog == 5) AlertDialog( + text = { + SelectionContainer { + Text(ACTIVATE_DEVICE_OWNER_COMMAND) + } + }, + confirmButton = { + TextButton({ dialog = 0 }) { Text(stringResource(R.string.confirm)) } + }, + onDismissRequest = { dialog = 0 } + ) + if (dialog == 6) AlertDialog( + text = { + Column { + Button({ + dialog = 2 + vm.activateOrgProfileByShizuku { dialog = 0 } + }) { + Text(stringResource(R.string.shizuku)) + } + Button({ dialog = 7 }) { + Text(stringResource(R.string.adb_command)) + } + } + }, + confirmButton = { + TextButton({ dialog = 0 }) { Text(stringResource(R.string.cancel)) } + }, + onDismissRequest = { dialog = 0 } + ) + if (dialog == 7) AlertDialog( + text = { + SelectionContainer { + Text(activateOrgProfileCommand) + } + }, + confirmButton = { + TextButton({ dialog = 0 }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { dialog = 0 } + ) + } +} + +@Composable +private fun WorkingModeItem(text: Int, active: Boolean, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(if (active) colorScheme.primaryContainer else Color.Transparent) + .padding(HorizontalPadding, 10.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(text), style = typography.titleLarge) + Icon( + if (active) Icons.Default.Check else Icons.AutoMirrored.Default.KeyboardArrowRight, + null, + tint = if (active) colorScheme.primary else colorScheme.onBackground + ) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesViewModel.kt new file mode 100644 index 0000000..051af33 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/privilege/WorkingModesViewModel.kt @@ -0,0 +1,155 @@ +package com.bintianqi.owndroid.feature.privilege + +import android.app.admin.DevicePolicyManager +import android.content.pm.PackageManager +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.IUserService +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.useShizuku +import com.bintianqi.owndroid.utils.ACTIVATE_DEVICE_OWNER_COMMAND +import com.bintianqi.owndroid.utils.MyAdminComponent +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import com.bintianqi.owndroid.utils.activateOrgProfileCommand +import com.bintianqi.owndroid.utils.getPrivilegeStatus +import com.bintianqi.owndroid.utils.handlePrivilegeChange +import com.rosan.dhizuku.api.Dhizuku +import com.rosan.dhizuku.api.DhizukuRequestPermissionListener +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class WorkingModesViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val sr: SettingsRepository, + val ps: MutableStateFlow, val toastChannel: ToastChannel +) : ViewModel() { + + @RequiresApi(24) + fun isCreatingWorkProfileAllowed(): Boolean { + return ph.myDpm.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) + } + + fun activateDoByShizuku(callback: (Boolean, String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + useShizuku(application) { service -> + if (service == null) { + callback(false, null) + return@useShizuku + } + try { + val result = IUserService.Stub.asInterface(service) + .execute(ACTIVATE_DEVICE_OWNER_COMMAND) + if (result == null) { + callback(false, null) + } else if (result.getInt("code", -1) != 0) { + callback( + false, result.getString("output") + "\n" + result.getString("error") + ) + } else { + updateStatus() + callback( + true, result.getString("output") + "\n" + result.getString("error") + ) + } + } catch (e: Exception) { + e.printStackTrace() + callback(false, null) + } + } + } + } + + fun activateDoByRoot(callback: (Boolean, String?) -> Unit) { + Shell.getShell { shell -> + if (shell.isRoot) { + val result = Shell.cmd(ACTIVATE_DEVICE_OWNER_COMMAND).exec() + val output = result.out.joinToString("\n") + "\n" + result.err.joinToString("\n") + if (result.isSuccess) updateStatus() + callback(result.isSuccess, output) + } else { + callback(false, application.getString(R.string.permission_denied)) + } + } + } + + @RequiresApi(28) + fun activateDoByDhizuku(callback: (Boolean, String?) -> Unit) = ph.safeDpmCall { + dpm.transferOwnership(dar, MyAdminComponent, null) + sr.update { it.privilege.dhizuku = false } + ph.dhizuku = false + updateStatus() + callback(true, null) + } + + fun activateDhizukuMode(callback: (Boolean, String?) -> Unit) { + fun onSucceed() { + sr.update { it.privilege.dhizuku = true } + ph.dhizuku = true + updateStatus() + callback(true, null) + } + if (Dhizuku.init(application)) { + if (Dhizuku.isPermissionGranted()) { + onSucceed() + } else { + Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { + override fun onRequestPermission(grantResult: Int) { + if (grantResult == PackageManager.PERMISSION_GRANTED) onSucceed() + else callback( + false, application.getString(R.string.dhizuku_permission_not_granted) + ) + } + }) + } + } else { + callback(false, application.getString(R.string.failed_to_init_dhizuku)) + } + } + + fun activateOrgProfileByShizuku(callback: (Boolean) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + useShizuku(application) { service -> + if (service == null) { + callback(false) + toastChannel.sendStatus(false) + return@useShizuku + } + val result = + IUserService.Stub.asInterface(service).execute(activateOrgProfileCommand) + val succeed = result?.getInt("code", -1) == 0 + callback(succeed) + if (succeed) { + updateStatus() + } else { + toastChannel.sendStatus(false) + } + } + } + } + + fun deactivate() { + if (ps.value.dhizuku) { + sr.update { it.privilege.dhizuku = false } + ph.dhizuku = false + } else { + if (ps.value.device) { + ph.myDpm.clearDeviceOwnerApp(application.packageName) + } else if (VERSION.SDK_INT >= 24) { + ph.myDpm.clearProfileOwner(MyAdminComponent) + } + } + updateStatus() + } + + private fun updateStatus() { + ps.value = getPrivilegeStatus(ph) + handlePrivilegeChange(application, ps.value, ph, sr) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningModel.kt new file mode 100644 index 0000000..0d1b13e --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningModel.kt @@ -0,0 +1,9 @@ +package com.bintianqi.owndroid.feature.provisioning + +class ProvisioningParams( + val imei: String?, val serial: String?, val modes: List +) + +class ProvisioningOptions( + val skipEncryption: Boolean +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningScreen.kt new file mode 100644 index 0000000..1c6d1c9 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningScreen.kt @@ -0,0 +1,87 @@ +package com.bintianqi.owndroid.feature.provisioning + +import android.app.admin.DevicePolicyManager +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets + +@RequiresApi(29) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProvisioningScreen(params: ProvisioningParams, callback: (ProvisioningOptions) -> Unit) { + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.app_name)) } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + Column(Modifier.padding(horizontal = HorizontalPadding)) { + if (!params.imei.isNullOrEmpty()) { + Text("IMEI", style = MaterialTheme.typography.titleMedium) + Text(params.imei, Modifier.padding(bottom = 8.dp)) + } + if (!params.serial.isNullOrEmpty()) { + Text( + stringResource(R.string.serial_number), + style = MaterialTheme.typography.titleMedium + ) + Text(params.serial, Modifier.padding(bottom = 8.dp)) + } + } + if (DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE in params.modes) { + Spacer(Modifier.height(10.dp)) + var skipEncryption by remember { mutableStateOf(false) } + FullWidthCheckBoxItem(R.string.skip_encryption, skipEncryption) { + skipEncryption = it + } + Button( + { + callback(ProvisioningOptions(skipEncryption)) + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp) + ) { + Text(stringResource(R.string.continue_str)) + } + } else { + Text( + stringResource(R.string.unsupported), + style = MaterialTheme.typography.titleLarge + ) + } + Spacer(Modifier.height(BottomPadding)) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningViewModel.kt new file mode 100644 index 0000000..9de3114 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/provisioning/ProvisioningViewModel.kt @@ -0,0 +1,31 @@ +package com.bintianqi.owndroid.feature.provisioning + +import android.app.admin.DevicePolicyManager +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel + +@RequiresApi(29) +class ProvisioningViewModel : ViewModel() { + lateinit var params: ProvisioningParams + + fun getParamsFromIntent(intent: Intent): ProvisioningParams { + val modes = if (Build.VERSION.SDK_INT >= 31) intent.getIntegerArrayListExtra( + DevicePolicyManager.EXTRA_PROVISIONING_ALLOWED_PROVISIONING_MODES + ) else null + return ProvisioningParams( + intent.getStringExtra(DevicePolicyManager.EXTRA_PROVISIONING_IMEI), + intent.getStringExtra(DevicePolicyManager.EXTRA_PROVISIONING_SERIAL_NUMBER), + modes ?: listOf(DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE) + ) + } + + fun buildResultIntent(options: ProvisioningOptions): Intent { + val intent = Intent() + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, options.skipEncryption + ) + return intent + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsModel.kt new file mode 100644 index 0000000..6880c18 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsModel.kt @@ -0,0 +1,51 @@ +package com.bintianqi.owndroid.feature.settings + +import androidx.annotation.Keep +import com.bintianqi.owndroid.R +import kotlinx.serialization.Serializable + +@Serializable +data class MySettings( + val privilege: Privilege = Privilege(), + var theme: Theme = Theme(), + val appLock: AppLock = AppLock(), + val shortcut: Shortcut = Shortcut(), + val notifications: MutableList = mutableListOf(), + var displayDangerousFeatures: Boolean = false, + var applicationsListView: Boolean = true, + var apiKeyHash: String = "", +) { + @Serializable + data class Privilege( + var dhizuku: Boolean = false, + var dhizukuServer: Boolean = false, + var managedProfileActivated: Boolean = false, + var defaultAffiliationIdSet: Boolean = false, + ) + + // Use `val` since it is used as UI state + @Serializable + data class Theme( + val materialYou: Boolean = true, + val dark: DarkMode = DarkMode.FollowSystem, + val black: Boolean = false, + ) + + @Keep + enum class DarkMode(val text: Int) { + FollowSystem(R.string.follow_system), On(R.string.on), Off(R.string.off) + } + + @Serializable + data class AppLock( + var passwordHash: String = "", + var biometrics: Boolean = false, + var lockWhenLeaving: Boolean = false, + ) + + @Serializable + data class Shortcut( + var enabled: Boolean = true, + var key: String = "", + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsRepository.kt new file mode 100644 index 0000000..42eb0d1 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsRepository.kt @@ -0,0 +1,30 @@ +package com.bintianqi.owndroid.feature.settings + +import kotlinx.serialization.json.Json +import java.io.File + +class SettingsRepository(val file: File) { + var data: MySettings + + init { + if (file.exists()) { + data = readData() + } else { + data = MySettings() + write() + } + } + + fun readData(): MySettings { + return Json.Default.decodeFromString(file.readText()) + } + + fun update(block: (MySettings) -> Unit) { + block(data) + write() + } + + fun write() { + file.writeText(Json.encodeToString(data)) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsScreen.kt similarity index 57% rename from app/src/main/java/com/bintianqi/owndroid/Settings.kt rename to app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsScreen.kt index a005535..5c4619f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.feature.settings import android.content.Context import android.content.Intent @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -35,7 +36,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,28 +55,33 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem -import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.Serializable +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.MyNotificationChannel +import com.bintianqi.owndroid.utils.NotificationType +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.generateBase64Key import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.system.exitProcess -@Serializable object Settings - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { - val context = LocalContext.current - val privilege by Privilege.status.collectAsStateWithLifecycle() - val exportLogsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { - if(it != null) exportLogs(context, it) - } +fun SettingsScreen( + vm: SettingsViewModel, onNavigate: (Destination) -> Unit, onNavigateUp: () -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + val exportLogsLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { + if (it != null) vm.exportLogs(it) + } val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() var dropdown by remember { mutableStateOf(false) } Scaffold( @@ -95,8 +101,9 @@ fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { { Text(stringResource(R.string.export_logs)) }, { dropdown = false - val time = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()) - .format(Date(System.currentTimeMillis())) + val time = + SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()) + .format(Date(System.currentTimeMillis())) exportLogsLauncher.launch("owndroid_log_$time") }, leadingIcon = { @@ -116,193 +123,186 @@ fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( - modifier = Modifier + Modifier .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(bottom = 80.dp) ) { - FunctionItem(title = R.string.options, icon = R.drawable.tune_fill0) { onNavigate(SettingsOptions) } - FunctionItem(title = R.string.appearance, icon = R.drawable.format_paint_fill0) { onNavigate(Appearance) } - FunctionItem(R.string.app_lock, icon = R.drawable.lock_fill0) { onNavigate(AppLockSettings) } - if (privilege.device || privilege.profile) - FunctionItem(title = R.string.api, icon = R.drawable.code_fill0) { onNavigate(ApiSettings) } - if (privilege.device && !privilege.dhizuku) - FunctionItem(R.string.notifications, icon = R.drawable.notifications_fill0) { onNavigate(Notifications) } - FunctionItem(title = R.string.about, icon = R.drawable.info_fill0) { onNavigate(About) } + FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { + onNavigate(Destination.SettingsOptions) + } + FunctionItem(R.string.appearance, icon = R.drawable.format_paint_fill0) { + onNavigate(Destination.AppearanceSettings) + } + FunctionItem(R.string.app_lock, icon = R.drawable.lock_fill0) { + onNavigate(Destination.AppLockSettings) + } + if (privilege.device || privilege.profile) { + FunctionItem(R.string.api, icon = R.drawable.code_fill0) { + onNavigate(Destination.ApiSettings) + } + } + if (privilege.device && !privilege.dhizuku) { + FunctionItem(R.string.notifications, icon = R.drawable.notifications_fill0) { + onNavigate(Destination.NotificationSettings) + } + } + FunctionItem(R.string.about, icon = R.drawable.info_fill0) { + onNavigate(Destination.About) + } + Spacer(Modifier.height(BottomPadding)) } } } -@Serializable object SettingsOptions - @Composable fun SettingsOptionsScreen( - getDisplayDangerousFeatures: () -> Boolean, getShortcutsEnabled: () -> Boolean, - setDisplayDangerousFeatures: (Boolean) -> Unit, setShortcutsEnabled: (Boolean) -> Unit, - onNavigateUp: () -> Unit + vm: SettingsViewModel, onNavigateUp: () -> Unit ) { - var dangerousFeatures by remember { mutableStateOf(getDisplayDangerousFeatures()) } - var shortcuts by remember { mutableStateOf(getShortcutsEnabled()) } + val dangerousFeatures by vm.dangerousFeaturesState.collectAsState() + val shortcuts by vm.shortcutsState.collectAsState() MyScaffold(R.string.options, onNavigateUp, 0.dp) { SwitchItem( - R.string.show_dangerous_features, dangerousFeatures, { - setDisplayDangerousFeatures(it) - dangerousFeatures = it - }, R.drawable.warning_fill0 + R.string.show_dangerous_features, dangerousFeatures, vm::setDisplayDangerousFeatures, + R.drawable.warning_fill0 ) SwitchItem( - R.string.shortcuts, shortcuts, { - setShortcutsEnabled(it) - shortcuts = it - }, R.drawable.open_in_new + R.string.shortcuts, shortcuts, vm::setShortcutsEnabled, R.drawable.open_in_new ) } } -@Serializable object Appearance - @Composable fun AppearanceScreen( - onNavigateUp: () -> Unit, currentTheme: StateFlow, - setTheme: (ThemeSettings) -> Unit + vm: SettingsViewModel, onNavigateUp: () -> Unit ) { + val uiState by vm.themeState.collectAsState() var darkThemeMenu by remember { mutableStateOf(false) } - val theme by currentTheme.collectAsStateWithLifecycle() - val darkThemeTextID = when(theme.darkTheme) { - 1 -> R.string.on - 0 -> R.string.off - else -> R.string.follow_system - } MyScaffold(R.string.appearance, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 31) { - SwitchItem( - R.string.material_you_color, - state = theme.materialYou, - onCheckedChange = { setTheme(theme.copy(materialYou = it)) } - ) + if (VERSION.SDK_INT >= 31) { + SwitchItem(R.string.material_you_color, uiState.materialYou, vm::setMaterialYou) } Box { - FunctionItem(R.string.dark_theme, stringResource(darkThemeTextID)) { darkThemeMenu = true } + FunctionItem(R.string.dark_theme, stringResource(uiState.dark.text)) { + darkThemeMenu = true + } DropdownMenu( - expanded = darkThemeMenu, onDismissRequest = { darkThemeMenu = false }, + darkThemeMenu, { darkThemeMenu = false }, offset = DpOffset(x = 25.dp, y = 0.dp) ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.follow_system)) }, - onClick = { - setTheme(theme.copy(darkTheme = -1)) - darkThemeMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.on)) }, - onClick = { - setTheme(theme.copy(darkTheme = 1)) - darkThemeMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.off)) }, - onClick = { - setTheme(theme.copy(darkTheme = 0)) - darkThemeMenu = false - } - ) + MySettings.DarkMode.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.text)) }, + { + vm.setDarkMode(it) + darkThemeMenu = false + } + ) + } } } - AnimatedVisibility(theme.darkTheme == 1 || (theme.darkTheme == -1 && isSystemInDarkTheme())) { - SwitchItem( - R.string.black_theme, state = theme.blackTheme, - onCheckedChange = { setTheme(theme.copy(blackTheme = it)) } - ) + AnimatedVisibility( + uiState.dark == MySettings.DarkMode.On || + (uiState.dark == MySettings.DarkMode.FollowSystem && isSystemInDarkTheme()) + ) { + SwitchItem(R.string.black_theme, uiState.black, vm::setBlackTheme) } } } -data class AppLockConfig( - /** null means no password, empty means password already set */ - val password: String?, val biometrics: Boolean, val whenLeaving: Boolean -) - -@Serializable object AppLockSettings - @Composable fun AppLockSettingsScreen( - config: AppLockConfig, setConfig: (AppLockConfig) -> Unit, - onNavigateUp: () -> Unit + vm: SettingsViewModel, onNavigateUp: () -> Unit ) = MyScaffold(R.string.app_lock, onNavigateUp) { + val config = vm.getAppLockConfig() var password by rememberSaveable { mutableStateOf("") } var confirmPassword by rememberSaveable { mutableStateOf("") } var allowBiometrics by rememberSaveable { mutableStateOf(config.biometrics) } - var lockWhenLeaving by rememberSaveable { mutableStateOf(config.whenLeaving) } - var alreadySet by rememberSaveable { mutableStateOf(config.password != null) } + var lockWhenLeaving by rememberSaveable { mutableStateOf(config.lockWhenLeaving) } + var alreadySet by rememberSaveable { mutableStateOf(config.passwordHash.isNotEmpty()) } val isInputLegal = password.length !in 1..3 && (alreadySet || password.isNotBlank()) OutlinedTextField( - password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + password, { password = it }, Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), label = { Text(stringResource(R.string.password)) }, - supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, + supportingText = { + Text( + stringResource( + if (alreadySet) R.string.leave_empty_to_remain_unchanged + else R.string.minimum_length_4 + ) + ) + }, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Next + ) ) OutlinedTextField( confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.confirm_password)) }, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Done + ) ) if (VERSION.SDK_INT >= 28) Row( - Modifier.fillMaxWidth().padding(vertical = 6.dp), + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Text(stringResource(R.string.allow_biometrics)) Switch(allowBiometrics, { allowBiometrics = it }) } Row( - Modifier.fillMaxWidth().padding(bottom = 6.dp), + Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Text(stringResource(R.string.lock_when_leaving)) Switch(lockWhenLeaving, { lockWhenLeaving = it }) } Button( - onClick = { - setConfig(AppLockConfig(password, allowBiometrics, lockWhenLeaving)) + { + vm.setAppLockConfig(password, allowBiometrics, lockWhenLeaving) onNavigateUp() }, - modifier = Modifier.fillMaxWidth(), - enabled = isInputLegal && confirmPassword == password + Modifier.fillMaxWidth(), + isInputLegal && confirmPassword == password ) { - Text(stringResource(if(alreadySet) R.string.update else R.string.set)) + Text(stringResource(if (alreadySet) R.string.update else R.string.set)) } if (alreadySet) FilledTonalButton( - onClick = { - setConfig(AppLockConfig(null, false, false)) + { + vm.disableAppLock() onNavigateUp() }, - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth() ) { Text(stringResource(R.string.disable)) } } -@Serializable object ApiSettings - @Composable fun ApiSettings( - getEnabled: () -> Boolean, setKey: (String) -> Unit, onNavigateUp: () -> Unit + vm: SettingsViewModel, onNavigateUp: () -> Unit ) { - val context = LocalContext.current - var alreadyEnabled by remember { mutableStateOf(getEnabled()) } + var alreadyEnabled by rememberSaveable { mutableStateOf(vm.getApiEnabled()) } MyScaffold(R.string.api, onNavigateUp) { - var enabled by remember { mutableStateOf(alreadyEnabled) } + var enabled by rememberSaveable { mutableStateOf(alreadyEnabled) } var key by rememberSaveable { mutableStateOf("") } SwitchItem(R.string.enable, state = enabled, onCheckedChange = { enabled = it }, padding = false) if (enabled) { OutlinedTextField( - key, { key = it }, Modifier.fillMaxWidth().padding(bottom = 4.dp), + key, { key = it }, + Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), label = { Text(stringResource(R.string.api_key)) }, trailingIcon = { IconButton({ key = generateBase64Key(10) }) { @@ -312,12 +312,13 @@ fun ApiSettings( ) } Button( - onClick = { - setKey(if (enabled) key else "") + { + vm.setApiKey(if (enabled) key else "") alreadyEnabled = enabled - context.showOperationResultToast(true) }, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), enabled = !enabled || key.length !in 0..7 ) { Text(stringResource(R.string.apply)) @@ -326,36 +327,35 @@ fun ApiSettings( } } -@Serializable object Notifications - @Composable fun NotificationsScreen( - enabledNotifications: StateFlow>, getState: () -> Unit, - setNotification: (NotificationType, Boolean) -> Unit, onNavigateUp: () -> Unit + vm: SettingsViewModel, onNavigateUp: () -> Unit ) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { - val notifications by enabledNotifications.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - getState() - } + val notifications by vm.enabledNotifications.collectAsState() NotificationType.entries.filter { it.channel == MyNotificationChannel.Events }.forEach { type -> - SwitchItem(type.text, type.id in notifications, { setNotification(type, it) }) + SwitchItem(type.text, type.id in notifications, { vm.setNotificationEnabled(type, it) }) } } -@Serializable object About - @Composable fun AboutScreen(onNavigateUp: () -> Unit) { val context = LocalContext.current - val pkgInfo = context.packageManager.getPackageInfo(context.packageName,0) + val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) val verCode = pkgInfo.versionCode val verName = pkgInfo.versionName MyScaffold(R.string.about, onNavigateUp, 0.dp) { - Text(text = stringResource(R.string.app_name)+" v$verName ($verCode)", modifier = Modifier.padding(start = 16.dp)) + Text( + stringResource(R.string.app_name) + " v$verName ($verCode)", + Modifier.padding(start = 16.dp) + ) Spacer(Modifier.padding(vertical = 5.dp)) - FunctionItem(R.string.project_homepage, "GitHub", R.drawable.open_in_new) { shareLink(context, "https://github.com/BinTianqi/OwnDroid") } + FunctionItem(R.string.project_homepage, "GitHub", R.drawable.open_in_new) { + shareLink( + context, "https://github.com/BinTianqi/OwnDroid" + ) + } } } diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000..5ff6d3a --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/settings/SettingsViewModel.kt @@ -0,0 +1,97 @@ +package com.bintianqi.owndroid.feature.settings + +import android.net.Uri +import android.os.Build +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.NotificationType +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ShortcutUtils +import com.bintianqi.owndroid.utils.ToastChannel +import com.bintianqi.owndroid.utils.hash +import com.bintianqi.owndroid.utils.plusOrMinus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.TimeUnit + +class SettingsViewModel( + val application: MyApplication, val settingsRepo: SettingsRepository, + val ph: PrivilegeHelper, val privilegeState: StateFlow, + val toastChannel: ToastChannel, val themeState: MutableStateFlow +) : ViewModel() { + fun exportLogs(uri: Uri) { + application.contentResolver.openOutputStream(uri)?.use { output -> + val proc = Runtime.getRuntime().exec("logcat -d") + proc.inputStream.copyTo(output) + if (Build.VERSION.SDK_INT >= 26) proc.waitFor(2L, TimeUnit.SECONDS) + else proc.waitFor() + toastChannel.sendStatus(proc.exitValue() == 0) + } + } + + fun setMaterialYou(enabled: Boolean) { + themeState.update { it.copy(materialYou = enabled) } + settingsRepo.update { it.theme = it.theme.copy(materialYou = enabled) } + } + + fun setDarkMode(mode: MySettings.DarkMode) { + themeState.update { it.copy(dark = mode) } + settingsRepo.update { it.theme = it.theme.copy(dark = mode) } + } + + fun setBlackTheme(enabled: Boolean) { + themeState.update { it.copy(black = enabled) } + settingsRepo.update { it.theme = it.theme.copy(black = enabled) } + } + + val dangerousFeaturesState = MutableStateFlow(settingsRepo.data.displayDangerousFeatures) + val shortcutsState = MutableStateFlow(settingsRepo.data.shortcut.enabled) + + fun setDisplayDangerousFeatures(state: Boolean) { + settingsRepo.update { it.displayDangerousFeatures = state } + dangerousFeaturesState.value = state + } + + fun setShortcutsEnabled(enabled: Boolean) { + settingsRepo.update { it.shortcut.enabled = enabled } + ShortcutUtils.setAllShortcuts(application, settingsRepo, ph, enabled) + shortcutsState.value = enabled + } + + fun getAppLockConfig() = settingsRepo.data.appLock + + fun setAppLockConfig(password: String, biometrics: Boolean, lockWhenLeaving: Boolean) { + settingsRepo.update { + if (password.isNotEmpty()) it.appLock.passwordHash = password.hash() + it.appLock.biometrics = biometrics + it.appLock.lockWhenLeaving = lockWhenLeaving + } + } + + fun disableAppLock() { + settingsRepo.update { + it.appLock.passwordHash = "" + } + } + + fun getApiEnabled() = settingsRepo.data.apiKeyHash.isNotEmpty() + fun setApiKey(key: String) { + settingsRepo.update { + it.apiKeyHash = if (key.isEmpty()) "" else key.hash() + } + toastChannel.sendStatus(true) + } + + val enabledNotifications = MutableStateFlow(emptyList()) + fun setNotificationEnabled(type: NotificationType, enabled: Boolean) { + settingsRepo.update { + it.notifications.clear() + it.notifications.addAll(enabledNotifications.value) + } + enabledNotifications.update { + it.plusOrMinus(enabled, type.id) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertModel.kt new file mode 100644 index 0000000..d937aca --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertModel.kt @@ -0,0 +1,11 @@ +package com.bintianqi.owndroid.feature.system + +class CaCertInfo( + val hash: String, + val serialNumber: String, + val issuer: String, + val subject: String, + val issuedTime: Long, + val expiresTime: Long, + val bytes: ByteArray +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertScreen.kt new file mode 100644 index 0000000..db5d723 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertScreen.kt @@ -0,0 +1,220 @@ +package com.bintianqi.owndroid.feature.system + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.formatDate +import com.bintianqi.owndroid.utils.popToast + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class) +@Composable +fun CaCertScreen( + vm: CaCertViewModel, onNavigateUp: () -> Unit +) { + val context = LocalContext.current + // 0:none, 1:install, 2:info, 3:uninstall all + var dialog by rememberSaveable { mutableIntStateOf(0) } + val caCerts by vm.installedCertsState.collectAsState() + val selectedCert by vm.selectedCert.collectAsState() + val getCertLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + vm.parseCert(uri) + dialog = 1 + } + } + val exportCertLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument() + ) { uri -> + if (uri != null) vm.exportCert(uri) + } + LaunchedEffect(Unit) { + vm.getCaCerts() + } + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.ca_cert)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + actions = { + IconButton({ dialog = 3 }) { + Icon(Icons.Outlined.Delete, stringResource(R.string.delete)) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton({ + context.popToast(R.string.select_ca_cert) + getCertLauncher.launch(arrayOf("*/*")) + }) { + Icon(Icons.Default.Add, stringResource(R.string.install)) + } + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + LazyColumn( + Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally + ) { + items(caCerts, { it.hash }) { cert -> + Column( + Modifier + .fillMaxWidth() + .clickable { + vm.selectCert(cert) + dialog = 2 + } + .animateItem() + .padding(vertical = 10.dp, horizontal = 8.dp) + ) { + Text(cert.hash.substring(0..7)) + } + HorizontalDivider() + } + item { + Spacer(Modifier.height(BottomPadding)) + } + } + } + if (selectedCert != null && (dialog == 1 || dialog == 2)) { + val cert = selectedCert!! + AlertDialog( + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text("Serial number", style = typography.labelLarge) + SelectionContainer { Text(cert.serialNumber) } + Text("Subject", style = typography.labelLarge) + SelectionContainer { Text(cert.subject) } + Text("Issuer", style = typography.labelLarge) + SelectionContainer { Text(cert.issuer) } + Text("Issued on", style = typography.labelLarge) + SelectionContainer { Text(formatDate(cert.issuedTime)) } + Text("Expires on", style = typography.labelLarge) + SelectionContainer { Text(formatDate(cert.expiresTime)) } + Text("SHA-256 fingerprint", style = typography.labelLarge) + SelectionContainer { Text(cert.hash) } + if (dialog == 2) Row( + Modifier + .fillMaxWidth() + .padding(top = 4.dp), Arrangement.SpaceBetween + ) { + TextButton( + { + vm.uninstallCert() + dialog = 0 + }, + Modifier.fillMaxWidth(0.49F) + ) { + Text(stringResource(R.string.uninstall)) + } + FilledTonalButton( + { + exportCertLauncher.launch(cert.hash.substring(0..7) + ".0") + }, + Modifier.fillMaxWidth(0.96F) + ) { + Text(stringResource(R.string.export)) + } + } + } + }, + confirmButton = { + if (dialog == 1) { + TextButton({ + vm.installCert() + dialog = 0 + }) { + Text(stringResource(R.string.install)) + } + } else { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.confirm)) + } + } + }, + dismissButton = { + if (dialog == 1) { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.cancel)) + } + } + }, + onDismissRequest = { dialog = 0 } + ) + } + if (dialog == 3) { + AlertDialog( + text = { + Text(stringResource(R.string.uninstall_all_user_ca_cert)) + }, + confirmButton = { + TextButton({ + vm.uninstallAll() + dialog = 0 + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ + dialog = 0 + }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = 0 } + ) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertViewModel.kt new file mode 100644 index 0000000..82a7752 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/CaCertViewModel.kt @@ -0,0 +1,72 @@ +package com.bintianqi.owndroid.feature.system + +import android.net.Uri +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import java.security.MessageDigest +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +class CaCertViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val toastChannel: ToastChannel +) : ViewModel() { + val installedCertsState = MutableStateFlow(emptyList()) + + val selectedCert = MutableStateFlow(null) + + fun getCaCerts() = ph.safeDpmCall { + installedCertsState.value = dpm.getInstalledCaCerts(dar).mapNotNull { parseCert(it) } + } + + fun selectCert(cert: CaCertInfo) { + selectedCert.value = cert + } + + fun parseCert(uri: Uri) { + try { + application.contentResolver.openInputStream(uri)?.use { + selectedCert.value = parseCert(it.readBytes()) + } + } catch (e: Exception) { + e.printStackTrace() + toastChannel.sendStatus(false) + } + } + + private fun parseCert(bytes: ByteArray): CaCertInfo { + val hash = MessageDigest.getInstance("SHA-256").digest(bytes).toHexString() + val factory = CertificateFactory.getInstance("X.509") + val cert = factory.generateCertificate(bytes.inputStream()) as X509Certificate + return CaCertInfo( + hash, cert.serialNumber.toString(16), + cert.issuerX500Principal.name, cert.subjectX500Principal.name, + cert.notBefore.time, cert.notAfter.time, bytes + ) + } + + fun installCert() = ph.safeDpmCall { + val result = dpm.installCaCert(dar, selectedCert.value!!.bytes) + if (result) getCaCerts() + toastChannel.sendStatus(result) + } + + fun uninstallCert() = ph.safeDpmCall { + dpm.uninstallCaCert(dar, selectedCert.value!!.bytes) + getCaCerts() + } + + fun uninstallAll() = ph.safeDpmCall { + dpm.uninstallAllUserCaCerts(dar) + installedCertsState.value = emptyList() + } + + fun exportCert(uri: Uri) { + application.contentResolver.openOutputStream(uri)?.use { + it.write(selectedCert.value!!.bytes) + } + toastChannel.sendStatus(true) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorModel.kt new file mode 100644 index 0000000..f8ea961 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorModel.kt @@ -0,0 +1,19 @@ +package com.bintianqi.owndroid.feature.system + +import android.os.HardwarePropertiesManager +import androidx.annotation.RequiresApi +import com.bintianqi.owndroid.R + +class HardwareProperties( + val temperatures: Map> = emptyMap(), + val cpuUsages: List> = emptyList(), + val fanSpeeds: List = emptyList() +) + +@RequiresApi(24) +val temperatureTypes = mapOf( + HardwarePropertiesManager.DEVICE_TEMPERATURE_CPU to R.string.cpu_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_GPU to R.string.gpu_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_BATTERY to R.string.battery_temp, + HardwarePropertiesManager.DEVICE_TEMPERATURE_SKIN to R.string.skin_temp +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorScreen.kt new file mode 100644 index 0000000..936df92 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorScreen.kt @@ -0,0 +1,111 @@ +package com.bintianqi.owndroid.feature.system + +import android.os.HardwarePropertiesManager +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MyScaffold +import kotlin.math.roundToLong + +@RequiresApi(24) +@Composable +fun HardwareMonitorScreen( + vm: HardwareMonitorViewModel, onNavigateUp: () -> Unit +) { + val properties by vm.propertiesState.collectAsState() + var refreshInterval by rememberSaveable { mutableFloatStateOf(1F) } + val refreshIntervalMs = (refreshInterval * 1000).roundToLong() + LaunchedEffect(Unit) { + vm.startHardwareMonitor() + } + MyScaffold(R.string.hardware_monitor, onNavigateUp) { + Text( + stringResource(R.string.refresh_interval), Modifier.padding(top = 8.dp, bottom = 4.dp), + style = typography.titleLarge + ) + Slider(refreshInterval, { + refreshInterval = it + vm.setRefreshInterval(it) + }, valueRange = 0.5F..2F, steps = 14) + Text("${refreshIntervalMs}ms") + Spacer(Modifier.padding(vertical = 10.dp)) + properties.temperatures.forEach { tempMapItem -> + Text( + stringResource(temperatureTypes[tempMapItem.key]!!), style = typography.titleLarge, + modifier = Modifier.padding(vertical = 4.dp) + ) + if (tempMapItem.value.isEmpty()) { + Text(stringResource(R.string.unsupported)) + } else { + tempMapItem.value.forEachIndexed { index, temp -> + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + index.toString(), style = typography.titleMedium, + modifier = Modifier.padding(start = 8.dp, end = 12.dp) + ) + Text( + if (temp == HardwarePropertiesManager.UNDEFINED_TEMPERATURE) stringResource( + R.string.undefined + ) else temp.toString() + ) + } + } + } + Spacer(Modifier.padding(vertical = 10.dp)) + } + Text( + stringResource(R.string.cpu_usages), style = typography.titleLarge, + modifier = Modifier.padding(vertical = 4.dp) + ) + if (properties.cpuUsages.isEmpty()) { + Text(stringResource(R.string.unsupported)) + } else { + properties.cpuUsages.forEachIndexed { index, usage -> + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + index.toString(), style = typography.titleMedium, + modifier = Modifier.padding(start = 8.dp, end = 12.dp) + ) + Column { + Text(stringResource(R.string.active) + ": " + usage.first + "ms") + Text(stringResource(R.string.total) + ": " + usage.second + "ms") + } + } + } + } + Spacer(Modifier.padding(vertical = 10.dp)) + Text( + stringResource(R.string.fan_speeds), style = typography.titleLarge, + modifier = Modifier.padding(vertical = 4.dp) + ) + if (properties.fanSpeeds.isEmpty()) { + Text(stringResource(R.string.unsupported)) + } else { + properties.fanSpeeds.forEachIndexed { index, speed -> + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + index.toString(), style = typography.titleMedium, + modifier = Modifier.padding(start = 8.dp, end = 12.dp) + ) + Text("$speed RPM") + } + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorViewModel.kt new file mode 100644 index 0000000..18ae3a0 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/HardwareMonitorViewModel.kt @@ -0,0 +1,53 @@ +package com.bintianqi.owndroid.feature.system + +import android.os.HardwarePropertiesManager +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class HardwareMonitorViewModel(val application: MyApplication) : ViewModel() { + val propertiesState = MutableStateFlow(HardwareProperties()) + + var refreshInterval = 1000L + + fun setRefreshInterval(interval: Float) { + refreshInterval = (interval * 1000).toLong() + } + + lateinit var job: Job + + @RequiresApi(24) + fun startHardwareMonitor() { + job = viewModelScope.launch { + val hpm = application.getSystemService(HardwarePropertiesManager::class.java) + while (true) { + val properties = HardwareProperties( + temperatureTypes.map { (type, _) -> + type to hpm.getDeviceTemperatures( + type, HardwarePropertiesManager.TEMPERATURE_CURRENT + ).toList() + }.toMap(), + hpm.cpuUsages.map { it.active to it.total }, + hpm.fanSpeeds.toList() + ) + if (properties.cpuUsages.isEmpty() && properties.fanSpeeds.isEmpty() && + properties.temperatures.isEmpty() + ) { + break + } + propertiesState.value = properties + delay(refreshInterval) + } + } + } + + override fun onCleared() { + job.cancel() + super.onCleared() + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeScreen.kt new file mode 100644 index 0000000..48508b5 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeScreen.kt @@ -0,0 +1,261 @@ +package com.bintianqi.owndroid.feature.system + +import android.app.admin.DevicePolicyManager +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.applications.ApplicationItem +import com.bintianqi.owndroid.ui.ErrorDialog +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.PackageNameTextField +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.isValidPackageName +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalMaterial3Api::class) +@RequiresApi(28) +@Composable +fun LockTaskModeScreen( + vm: LockTaskModeViewModel, chosenPackage: Channel, chooseSinglePackage: () -> Unit, + choosePackage: () -> Unit, onNavigateUp: () -> Unit +) { + val coroutine = rememberCoroutineScope() + val pagerState = rememberPagerState { 3 } + val tabIndex = pagerState.targetPage + LaunchedEffect(Unit) { + vm.getPackages() + vm.getFeatures() + } + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.lock_task_mode)) }, + navigationIcon = { NavIcon(onNavigateUp) } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + PrimaryTabRow(tabIndex) { + Tab( + tabIndex == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.start)) } + ) + Tab( + tabIndex == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.applications)) } + ) + Tab( + tabIndex == 2, { coroutine.launch { pagerState.animateScrollToPage(2) } }, + text = { Text(stringResource(R.string.features)) } + ) + } + HorizontalPager(pagerState, verticalAlignment = Alignment.Top) { page -> + if(page == 0) { + StartLockTaskMode(vm::startLockTaskMode, chosenPackage, chooseSinglePackage) + } else if (page == 1) { + LockTaskPackages(chosenPackage, choosePackage, vm.packagesState, vm::setPackage) + } else { + LockTaskFeatures(vm.featuresState, vm::setFeatures, vm::applyFeatures) + } + } + } + } +} + +@RequiresApi(28) +@Composable +private fun StartLockTaskMode( + startLockTaskMode: (String, String, Boolean, Boolean) -> Unit, + chosenPackage: Channel, onChoosePackage: () -> Unit +) { + val focusMgr = LocalFocusManager.current + var packageName by rememberSaveable { mutableStateOf("") } + var activity by rememberSaveable { mutableStateOf("") } + var specifyActivity by rememberSaveable { mutableStateOf(false) } + var clearTask by rememberSaveable { mutableStateOf(true) } + var showNotification by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + PackageNameTextField( + packageName, onChoosePackage, Modifier.padding(HorizontalPadding, 8.dp) + ) { packageName = it } + FullWidthCheckBoxItem( + R.string.lock_task_mode_start_clear_task, clearTask + ) { clearTask = it } + FullWidthCheckBoxItem( + R.string.lock_task_mode_show_notification, showNotification + ) { showNotification = it } + Row( + Modifier + .fillMaxWidth() + .padding(4.dp, 4.dp, HorizontalPadding, 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(specifyActivity, { + specifyActivity = it + activity = "" + }) + OutlinedTextField( + activity, { activity = it }, + Modifier.fillMaxWidth(), + label = { Text("Activity") }, + enabled = specifyActivity, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) + ) + } + Button( + { + startLockTaskMode(packageName, activity, clearTask, showNotification) + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + packageName.isNotBlank() && (!specifyActivity || activity.isNotBlank()) + ) { + Text(stringResource(R.string.start)) + } + Spacer(Modifier.height(5.dp)) + Notes(R.string.info_start_lock_task_mode, HorizontalPadding) + } +} + +@RequiresApi(26) +@Composable +private fun LockTaskPackages( + chosenPackage: Channel, onChoosePackage: () -> Unit, + packagesState: StateFlow>, setPackage: (String, Boolean) -> Unit +) { + val packages by packagesState.collectAsStateWithLifecycle() + var packageName by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + packageName = chosenPackage.receive() + } + LazyColumn { + items(packages, { it.name }) { + ApplicationItem(it) { setPackage(it.name, false) } + } + item { + Column( + Modifier.padding(horizontal = HorizontalPadding) + ) { + PackageNameTextField( + packageName, onChoosePackage, Modifier.padding(vertical = 3.dp) + ) { packageName = it } + Button( + { + setPackage(packageName, true) + packageName = "" + }, + Modifier.fillMaxWidth(), + packageName.isValidPackageName + ) { + Text(stringResource(R.string.add)) + } + Notes(R.string.info_lock_task_packages) + Spacer(Modifier.height(BottomPadding)) + } + } + } +} + +@RequiresApi(28) +@Composable +private fun LockTaskFeatures( + featuresState: StateFlow, setFlag: (Int) -> Unit, apply: ((String?) -> Unit) -> Unit +) { + val flags by featuresState.collectAsState() + var errorMessage by rememberSaveable { mutableStateOf(null) } + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Spacer(Modifier.padding(vertical = 5.dp)) + listOf( + DevicePolicyManager.LOCK_TASK_FEATURE_SYSTEM_INFO to R.string.ltf_sys_info, + DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS to R.string.ltf_notifications, + DevicePolicyManager.LOCK_TASK_FEATURE_HOME to R.string.ltf_home, + DevicePolicyManager.LOCK_TASK_FEATURE_OVERVIEW to R.string.ltf_overview, + DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS to R.string.ltf_global_actions, + DevicePolicyManager.LOCK_TASK_FEATURE_KEYGUARD to R.string.ltf_keyguard + ).let { + if(VERSION.SDK_INT >= 30) it.plus( + DevicePolicyManager.LOCK_TASK_FEATURE_BLOCK_ACTIVITY_START_IN_TASK to + R.string.ltf_block_activity_start_in_task) + else it + }.forEach { (id, title) -> + FullWidthCheckBoxItem(title, flags and id != 0) { setFlag(flags xor id) } + } + Button( + { + apply { errorMessage = it } + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp) + ) { + Text(stringResource(R.string.apply)) + } + Spacer(Modifier.height(BottomPadding)) + } + ErrorDialog(errorMessage) { errorMessage = null } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeViewModel.kt new file mode 100644 index 0000000..e6ed48d --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/LockTaskModeViewModel.kt @@ -0,0 +1,95 @@ +package com.bintianqi.owndroid.feature.system + +import android.app.ActivityOptions +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Intent +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.LockTaskService +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.ToastChannel +import com.bintianqi.owndroid.utils.getAppInfo +import kotlinx.coroutines.flow.MutableStateFlow + +class LockTaskModeViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val toastChannel: ToastChannel +) : ViewModel() { + val packagesState = MutableStateFlow(emptyList()) + + @RequiresApi(26) + fun getPackages() = ph.safeDpmCall { + val pm = application.packageManager + packagesState.value = dpm.getLockTaskPackages(dar).map { getAppInfo(pm, it) } + } + + @RequiresApi(26) + fun setPackage(name: String, status: Boolean) = ph.safeDpmCall { + dpm.setLockTaskPackages( + dar, + packagesState.value.map { it.name } + .run { if (status) plus(name) else minus(name) } + .toTypedArray() + ) + getPackages() + } + + @RequiresApi(28) + fun startLockTaskMode( + packageName: String, activity: String, clearTask: Boolean, showNotification: Boolean + ) = ph.safeDpmCall { + if (!dpm.isLockTaskPermitted(packageName)) { + val list = packagesState.value.map { it.name } + packageName + dpm.setLockTaskPackages(dar, list.toTypedArray()) + getPackages() + } + if (showNotification) { + dpm.setLockTaskFeatures( + dar, + dpm.getLockTaskFeatures(dar) or + DevicePolicyManager.LOCK_TASK_FEATURE_NOTIFICATIONS or + DevicePolicyManager.LOCK_TASK_FEATURE_HOME + ) + } + val options = ActivityOptions.makeBasic().setLockTaskEnabled(true) + val pm = application.packageManager + val intent = if (activity.isNotEmpty()) { + Intent().setComponent(ComponentName(packageName, activity)) + } else pm.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or (if (clearTask) Intent.FLAG_ACTIVITY_CLEAR_TASK else 0) + ) + application.startActivity(intent, options.toBundle()) + if (showNotification) { + application.startForegroundService(Intent(application, LockTaskService::class.java)) + } + } else { + toastChannel.sendStatus(false) + } + } + + val featuresState = MutableStateFlow(0) + + @RequiresApi(28) + fun getFeatures() = ph.safeDpmCall { + featuresState.value = dpm.getLockTaskFeatures(dar) + } + + fun setFeatures(flags: Int) { + featuresState.value = flags + } + + @RequiresApi(28) + fun applyFeatures(errorCallback: (String?) -> Unit) = ph.safeDpmCall { + try { + dpm.setLockTaskFeatures(dar, featuresState.value) + } catch (e: IllegalArgumentException) { + errorCallback(e.message) + } + toastChannel.sendStatus(true) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingModel.kt new file mode 100644 index 0000000..941cba1 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingModel.kt @@ -0,0 +1,182 @@ +package com.bintianqi.owndroid.feature.system + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +class SecurityEvent( + val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: JsonObject? +) + +@Serializable +class SecurityEventWithData( + val id: Long?, val tag: Int, val level: Int?, val time: Long, val data: SecurityEventData? +) + +@Serializable +sealed class SecurityEventData { + @Serializable + class AdbShellCmd(val command: String): SecurityEventData() + @Serializable + class AppProcessStart( + val name: String, + val time: Long, + val uid: Int, + val pid: Int, + val seinfo: String, + val hash: String + ): SecurityEventData() + @Serializable + class BackupServiceToggled( + val admin: String, + val user: Int, + val state: Int + ): SecurityEventData() + @Serializable + class BluetoothConnection( + val mac: String, + val successful: Int, + @SerialName("failure_reason") val failureReason: String + ): SecurityEventData() + @Serializable + class BluetoothDisconnection( + val mac: String, + val reason: String + ): SecurityEventData() + @Serializable + class CameraPolicySet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val disabled: Int + ): SecurityEventData() + @Serializable + class CaInstalledRemoved( + val result: Int, + val subject: String, + val user: Int + ): SecurityEventData() + @Serializable + class CertValidationFailure(val reason: String): SecurityEventData() + @Serializable + class CryptoSelfTestCompleted(val result: Int): SecurityEventData() + @Serializable + class KeyguardDisabledFeaturesSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val mask: Int + ): SecurityEventData() + @Serializable + class KeyguardDismissAuthAttempt( + val result: Int, + val strength: Int + ): SecurityEventData() + @Serializable + class KeyGeneratedImportDestruction( + val result: Int, + val alias: String, + val uid: Int + ): SecurityEventData() + @Serializable + class KeyIntegrityViolation( + val alias: String, + val uid: Int + ): SecurityEventData() + @Serializable + class MaxPasswordAttemptsSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val value: Int + ): SecurityEventData() + @Serializable + class MaxScreenLockTimeoutSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val timeout: Long + ): SecurityEventData() + @Serializable + class MediaMountUnmount( + @SerialName("mount_point") val mountPoint: String, + val label: String + ): SecurityEventData() + @Serializable + class OsStartup( + @SerialName("verified_boot_state") val verifiedBootState: String, + @SerialName("dm_verity_mode") val dmVerityMode: String + ): SecurityEventData() + @Serializable + class PackageInstalledUninstalledUpdated( + val name: String, + val version: Long, + val user: Int + ): SecurityEventData() + @Serializable + class PasswordChanged( + val complexity: Int, + val user: Int + ): SecurityEventData() + @Serializable + class PasswordComplexityRequired( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val complexity: Int + ): SecurityEventData() + @Serializable + class PasswordComplexitySet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val length: Int, + val quality: Int, + val letters: Int, + @SerialName("non_letters") val nonLetters: Int, + val digits: Int, + val uppercase: Int, + val lowercase: Int, + val symbols: Int + ): SecurityEventData() + @Serializable + class PasswordExpirationSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val expiration: Long + ): SecurityEventData() + @Serializable + class PasswordHistoryLengthSet( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + val length: Int + ): SecurityEventData() + @Serializable + class RemoteLock( + val admin: String, + @SerialName("admin_user") val adminUser: Int, + @SerialName("target_user") val targetUser: Int, + ): SecurityEventData() + @Serializable + class SyncRecvSendFile(val path: String): SecurityEventData() + @Serializable + class UserRestrictionAddedRemoved( + val admin: String, + val user: Int, + val restriction: String + ): SecurityEventData() + @Serializable + class WifiConnection( + val bssid: String, + val type: String, + @SerialName("failure_reason") val failureReason: String + ): SecurityEventData() + @Serializable + class WifiDisconnection( + val bssid: String, + val reason: String + ): SecurityEventData() +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingRepository.kt new file mode 100644 index 0000000..ba62c9b --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingRepository.kt @@ -0,0 +1,274 @@ +package com.bintianqi.owndroid.feature.system + +import android.app.admin.SecurityLog +import android.database.DatabaseUtils +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.database.getStringOrNull +import com.bintianqi.owndroid.MyDbHelper +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.ClassDiscriminatorMode +import kotlinx.serialization.json.Json +import java.io.OutputStream + +class SecurityLoggingRepository(val dbHelper: MyDbHelper) { + fun getSecurityLogsCount(): Long { + return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "security_logs") + } + + @OptIn(ExperimentalSerializationApi::class) + @RequiresApi(24) + fun writeSecurityLogs(events: List) { + val db = dbHelper.writableDatabase + val json = Json { + classDiscriminatorMode = ClassDiscriminatorMode.NONE + } + val statement = db.compileStatement("INSERT INTO security_logs VALUES (?, ?, ?, ?, ?)") + db.beginTransaction() + events.forEach { event -> + try { + if (Build.VERSION.SDK_INT >= 28) { + statement.bindLong(1, event.id) + statement.bindLong(3, event.logLevel.toLong()) + } else { + statement.bindNull(1) + statement.bindNull(3) + } + statement.bindLong(2, event.tag.toLong()) + statement.bindLong(4, event.timeNanos / 1000000) + val dataObject = transformSecurityEventData(event.tag, event.data) + if (dataObject == null) { + statement.bindNull(5) + } else { + statement.bindString(5, json.encodeToString(dataObject)) + } + statement.executeInsert() + } catch (e: Exception) { + e.printStackTrace() + } finally { + statement.clearBindings() + } + } + db.setTransactionSuccessful() + db.endTransaction() + statement.close() + } + + fun exportSecurityLogs(stream: OutputStream) { + var offset = 0 + val json = Json { + explicitNulls = false + } + var addComma = false + val bw = stream.bufferedWriter() + bw.write("[") + while (true) { + dbHelper.readableDatabase.rawQuery( + "SELECT * FROM security_logs LIMIT ? OFFSET ?", + arrayOf(100.toString(), offset.toString()) + ).use { cursor -> + if (cursor.count == 0) { + break + } + while (cursor.moveToNext()) { + if (addComma) bw.write(",") + addComma = true + val event = SecurityEvent( + cursor.getLong(0), cursor.getInt(1), cursor.getInt(2), cursor.getLong(3), + cursor.getStringOrNull(4)?.let { json.decodeFromString(it) } + ) + bw.write(json.encodeToString(event)) + } + offset += 100 + } + } + bw.write("]") + bw.close() + } + + @OptIn(ExperimentalSerializationApi::class) + @RequiresApi(24) + fun exportPRSecurityLogs(logs: List, stream: OutputStream) { + val bw = stream.bufferedWriter() + bw.write("[") + val json = Json { + explicitNulls = false + classDiscriminatorMode = ClassDiscriminatorMode.NONE + } + var addComma = false + logs.forEach { log -> + try { + if (addComma) bw.write(",") + addComma = true + val event = SecurityEventWithData( + if (Build.VERSION.SDK_INT >= 28) log.id else null, log.tag, + if (Build.VERSION.SDK_INT >= 28) log.logLevel else null, + log.timeNanos / 1000000, + transformSecurityEventData(log.tag, log.data) + ) + bw.write(json.encodeToString(event)) + } catch (e: Exception) { + e.printStackTrace() + } + } + bw.write("]") + bw.close() + } + + fun deleteSecurityLogs() { + dbHelper.writableDatabase.execSQL("DELETE FROM security_logs") + } + + companion object { + fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? { + return when(tag) { + SecurityLog.TAG_ADB_SHELL_CMD -> SecurityEventData.AdbShellCmd(payload as String) + SecurityLog.TAG_ADB_SHELL_INTERACTIVE -> null + SecurityLog.TAG_APP_PROCESS_START -> { + val data = payload as Array<*> + SecurityEventData.AppProcessStart( + data[0] as String, data[1] as Long, data[2] as Int, data[3] as Int, + data[4] as String, data[5] as String + ) + } + SecurityLog.TAG_BACKUP_SERVICE_TOGGLED -> { + val data = payload as Array<*> + SecurityEventData.BackupServiceToggled( + data[0] as String, data[1] as Int, data[2] as Int + ) + } + SecurityLog.TAG_BLUETOOTH_CONNECTION -> { + val data = payload as Array<*> + SecurityEventData.BluetoothConnection( + data[0] as String, data[1] as Int, data[2] as String + ) + } + SecurityLog.TAG_BLUETOOTH_DISCONNECTION -> { + val data = payload as Array<*> + SecurityEventData.BluetoothDisconnection(data[0] as String, data[1] as String) + } + SecurityLog.TAG_CAMERA_POLICY_SET -> { + val data = payload as Array<*> + SecurityEventData.CameraPolicySet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) + } + SecurityLog.TAG_CERT_AUTHORITY_INSTALLED, SecurityLog.TAG_CERT_AUTHORITY_REMOVED -> { + val data = payload as Array<*> + SecurityEventData.CaInstalledRemoved( + data[0] as Int, data[1] as String, data[2] as Int + ) + } + SecurityLog.TAG_CERT_VALIDATION_FAILURE -> + SecurityEventData.CertValidationFailure(payload as String) + SecurityLog.TAG_CRYPTO_SELF_TEST_COMPLETED -> + SecurityEventData.CryptoSelfTestCompleted(payload as Int) + SecurityLog.TAG_KEYGUARD_DISABLED_FEATURES_SET -> { + val data = payload as Array<*> + SecurityEventData.KeyguardDisabledFeaturesSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) + } + SecurityLog.TAG_KEYGUARD_DISMISSED -> null + SecurityLog.TAG_KEYGUARD_DISMISS_AUTH_ATTEMPT -> { + val data = payload as Array<*> + SecurityEventData.KeyguardDismissAuthAttempt(data[0] as Int, data[1] as Int) + } + SecurityLog.TAG_KEYGUARD_SECURED -> null + SecurityLog.TAG_KEY_GENERATED, SecurityLog.TAG_KEY_IMPORT, + SecurityLog.TAG_KEY_DESTRUCTION -> { + val data = payload as Array<*> + SecurityEventData.KeyGeneratedImportDestruction( + data[0] as Int, data[1] as String, data[2] as Int + ) + } + SecurityLog.TAG_LOGGING_STARTED, SecurityLog.TAG_LOGGING_STOPPED -> null + SecurityLog.TAG_LOG_BUFFER_SIZE_CRITICAL -> null + SecurityLog.TAG_MAX_PASSWORD_ATTEMPTS_SET -> { + val data = payload as Array<*> + SecurityEventData.MaxPasswordAttemptsSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) + } + SecurityLog.TAG_MAX_SCREEN_LOCK_TIMEOUT_SET -> { + val data = payload as Array<*> + SecurityEventData.MaxScreenLockTimeoutSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long + ) + } + SecurityLog.TAG_MEDIA_MOUNT, SecurityLog.TAG_MEDIA_UNMOUNT -> { + val data = payload as Array<*> + SecurityEventData.MediaMountUnmount(data[0] as String, data[1] as String) + } + SecurityLog.TAG_NFC_ENABLED, SecurityLog.TAG_NFC_DISABLED -> null + SecurityLog.TAG_OS_SHUTDOWN -> null + SecurityLog.TAG_OS_STARTUP -> { + val data = payload as Array<*> + SecurityEventData.OsStartup(data[0] as String, data[1] as String) + } + SecurityLog.TAG_PACKAGE_INSTALLED, SecurityLog.TAG_PACKAGE_UPDATED, + SecurityLog.TAG_PACKAGE_UNINSTALLED -> { + val data = payload as Array<*> + SecurityEventData.PackageInstalledUninstalledUpdated( + data[0] as String, data[1] as Long, data[2] as Int + ) + } + SecurityLog.TAG_PASSWORD_CHANGED -> { + val data = payload as Array<*> + SecurityEventData.PasswordChanged(data[0] as Int, data[1] as Int) + } + SecurityLog.TAG_PASSWORD_COMPLEXITY_REQUIRED -> { + val data = payload as Array<*> + SecurityEventData.PasswordComplexityRequired( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) + } + SecurityLog.TAG_PASSWORD_COMPLEXITY_SET -> { + val data = payload as Array<*> + SecurityEventData.PasswordComplexitySet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int, + data[4] as Int, data[5] as Int, data[6] as Int, data[7] as Int, + data[8] as Int, data[9] as Int, data[10] as Int + ) + } + SecurityLog.TAG_PASSWORD_EXPIRATION_SET -> { + val data = payload as Array<*> + SecurityEventData.PasswordExpirationSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Long + ) + } + SecurityLog.TAG_PASSWORD_HISTORY_LENGTH_SET -> { + val data = payload as Array<*> + SecurityEventData.PasswordHistoryLengthSet( + data[0] as String, data[1] as Int, data[2] as Int, data[3] as Int + ) + } + SecurityLog.TAG_REMOTE_LOCK -> { + val data = payload as Array<*> + SecurityEventData.RemoteLock(data[0] as String, data[1] as Int, data[2] as Int) + } + SecurityLog.TAG_SYNC_RECV_FILE, SecurityLog.TAG_SYNC_SEND_FILE -> + SecurityEventData.SyncRecvSendFile(payload as String) + SecurityLog.TAG_USER_RESTRICTION_ADDED, + SecurityLog.TAG_USER_RESTRICTION_REMOVED -> { + val data = payload as Array<*> + SecurityEventData.UserRestrictionAddedRemoved( + data[0] as String, data[1] as Int, data[2] as String + ) + } + SecurityLog.TAG_WIFI_CONNECTION -> { + val data = payload as Array<*> + SecurityEventData.WifiConnection( + data[0] as String, data[1] as String, data[2] as String + ) + } + SecurityLog.TAG_WIFI_DISCONNECTION -> { + val data = payload as Array<*> + SecurityEventData.WifiDisconnection(data[0] as String, data[1] as String) + } + SecurityLog.TAG_WIPE_FAILURE -> null + else -> null + } + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingScreen.kt new file mode 100644 index 0000000..c8e6e69 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingScreen.kt @@ -0,0 +1,116 @@ +package com.bintianqi.owndroid.feature.system + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CircularProgressDialog +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.utils.HorizontalPadding +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@RequiresApi(24) +@Composable +fun SecurityLoggingScreen( + vm: SecurityLoggingViewModel, onNavigateUp: () -> Unit +) { + val enabled by vm.enabledState.collectAsState() + val logsCount by vm.countState.collectAsState() + val exporting by vm.exportingState.collectAsState() + var dialog by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + vm.getEnabled() + vm.getCount() + } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) vm.exportLogs(it) + } + val exportPRLogsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) vm.exportPreRebootSecurityLogs(it) + } + MyScaffold(R.string.security_logging, onNavigateUp, 0.dp) { + SwitchItem(R.string.enable, enabled, vm::setEnabled) + Text( + stringResource(R.string.n_logs_in_total, logsCount), + Modifier.padding(HorizontalPadding) + ) + Button( + { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportLauncher.launch("security_logs_$date") + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + logsCount > 0 + ) { + Text(stringResource(R.string.export_logs)) + } + if (logsCount > 0) FilledTonalButton( + { dialog = true }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 4.dp) + ) { + Text(stringResource(R.string.delete_logs)) + } + Notes(R.string.info_security_log, HorizontalPadding) + Button( + { + vm.getPreRebootSecurityLogs { + val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) + exportPRLogsLauncher.launch("pre_reboot_security_logs_$date") + } + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 15.dp) + ) { + Text(stringResource(R.string.pre_reboot_security_logs)) + } + Notes(R.string.info_pre_reboot_security_log, HorizontalPadding) + } + if (exporting) CircularProgressDialog {} + if (dialog) AlertDialog( + text = { Text(stringResource(R.string.delete_logs)) }, + confirmButton = { + TextButton({ + vm.deleteLogs() + dialog = false + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingViewModel.kt new file mode 100644 index 0000000..5d8e508 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SecurityLoggingViewModel.kt @@ -0,0 +1,85 @@ +package com.bintianqi.owndroid.feature.system + +import android.app.admin.SecurityLog +import android.net.Uri +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlin.collections.isNotEmpty + +class SecurityLoggingViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val repo: SecurityLoggingRepository, + val toastChannel: ToastChannel +) : ViewModel() { + val enabledState = MutableStateFlow(false) + + @RequiresApi(24) + fun getEnabled() = ph.safeDpmCall { + enabledState.value = dpm.isSecurityLoggingEnabled(dar) + } + + @RequiresApi(24) + fun setEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setSecurityLoggingEnabled(dar, enabled) + enabledState.value = enabled + } + + val countState = MutableStateFlow(0L) + + fun getCount() { + countState.value = repo.getSecurityLogsCount() + } + + val exportingState = MutableStateFlow(false) + + fun exportLogs(uri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + exportingState.value = true + application.contentResolver.openOutputStream(uri)?.use { + repo.exportSecurityLogs(it) + } + exportingState.value = false + toastChannel.sendStatus(true) + } + } + + fun deleteLogs() { + repo.deleteSecurityLogs() + countState.value = 0 + } + + var preRebootSecurityLogs = emptyList() + + @RequiresApi(24) + fun getPreRebootSecurityLogs(callback: () -> Unit) { + if (preRebootSecurityLogs.isNotEmpty()) callback() + val result = try { + val logs = ph.myDpm.retrievePreRebootSecurityLogs(ph.myDar) + if (!logs.isNullOrEmpty()) { + preRebootSecurityLogs = logs + true + } else false + } catch (_: SecurityException) { + false + } + if (!result) toastChannel.sendStatus(false) + } + + @RequiresApi(24) + fun exportPreRebootSecurityLogs(uri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + exportingState.value = true + application.contentResolver.openOutputStream(uri)!!.use { + repo.exportPRSecurityLogs(preRebootSecurityLogs, it) + } + exportingState.value = false + toastChannel.sendStatus(true) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemModel.kt new file mode 100644 index 0000000..9594413 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemModel.kt @@ -0,0 +1,17 @@ +package com.bintianqi.owndroid.feature.system + +data class FrpPolicyInfo( + val supported: Boolean = false, + val usePolicy: Boolean = false, + val enabled: Boolean = false, + val accounts: List = emptyList() +) + +class DeviceInfo( + val financed: Boolean = false, + val dpmrh: String? = null, + val storageEncryptionStatus: Int = 0, + val deviceIdAttestationSupported: Boolean = false, + val uniqueDeviceAttestationSupported: Boolean = false, + val activeAdmins: List = emptyList() +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsModel.kt new file mode 100644 index 0000000..99120cd --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsModel.kt @@ -0,0 +1,36 @@ +package com.bintianqi.owndroid.feature.system + +import android.provider.Settings +import com.bintianqi.owndroid.R + +data class SystemOptionsStatus( + val cameraDisabled: Boolean = false, + val screenCaptureDisabled: Boolean = false, + val statusBarDisabled: Boolean = false, + val autoTimeEnabled: Boolean = true, + val autoTimeZoneEnabled: Boolean = true, + val autoTimeRequired: Boolean = true, + val masterVolumeMuted: Boolean = false, + val backupServiceEnabled: Boolean = false, + val btContactSharingDisabled: Boolean = false, + val commonCriteriaMode: Boolean = false, + val usbSignalEnabled: Boolean = true, + val canDisableUsbSignal: Boolean = true, + val stayOnWhilePluggedIn: Boolean = false +) + +class GlobalSetting(val icon: Int, val name: Int, val setting: String) // also for secure settings + +val globalSettings = listOf( + //GlobalSetting(R.drawable.cell_tower_fill0, R.string.data_roaming, Settings.Global.DATA_ROAMING), + GlobalSetting(R.drawable.adb_fill0, R.string.enable_adb, Settings.Global.ADB_ENABLED), + GlobalSetting(R.drawable.usb_fill0, R.string.enable_usb_mass_storage, + Settings.Global.USB_MASS_STORAGE_ENABLED), + GlobalSetting(R.drawable.wifi_password_fill0, R.string.lockdown_admin_configured_network, + Settings.Global.WIFI_DEVICE_OWNER_CONFIGS_LOCKDOWN) +) + +val secureSettings = listOf( + GlobalSetting(R.drawable.light_off_fill0, R.string.skip_first_use_hints, + Settings.Secure.SKIP_FIRST_USE_HINTS) +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsScreen.kt new file mode 100644 index 0000000..ab588db --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsScreen.kt @@ -0,0 +1,161 @@ +package com.bintianqi.owndroid.feature.system + +import android.os.Build.VERSION +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.utils.HorizontalPadding + +@Composable +fun SystemOptionsScreen(vm: SystemOptionsViewModel, onNavigateUp: () -> Unit) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + var dialog by rememberSaveable { mutableIntStateOf(0) } + val status by vm.optionsState.collectAsStateWithLifecycle() + val globalSettingsStatus = remember { mutableStateMapOf() } + val secureSettingsStatus = remember { mutableStateMapOf() } + LaunchedEffect(Unit) { + vm.getSystemOptionsStatus() + if (privilege.device) { + globalSettingsStatus.putAll(vm.getGlobalSettings()) + secureSettingsStatus.putAll(vm.getSecureSettings()) + } + } + MyScaffold(R.string.options, onNavigateUp, 0.dp) { + SwitchItem( + R.string.disable_cam, status.cameraDisabled, vm::setCameraDisabled, + R.drawable.no_photography_fill0 + ) + SwitchItem( + R.string.disable_screen_capture, status.screenCaptureDisabled, + vm::setScreenCaptureDisabled, R.drawable.screenshot_fill0 + ) + if (VERSION.SDK_INT >= 34 && privilege.run { device || (profile && affiliated) }) { + SwitchItem( + R.string.disable_status_bar, status.statusBarDisabled, + vm::setStatusBarDisabled, R.drawable.notifications_fill0 + ) + } + if (privilege.device || privilege.org) { + if (VERSION.SDK_INT >= 30) { + SwitchItem( + R.string.auto_time, status.autoTimeEnabled, vm::setAutoTimeEnabled, + R.drawable.schedule_fill0 + ) + SwitchItem( + R.string.auto_timezone, status.autoTimeZoneEnabled, + vm::setAutoTimeZoneEnabled, R.drawable.globe_fill0 + ) + } else { + SwitchItem( + R.string.require_auto_time, status.autoTimeRequired, + vm::setAutoTimeRequired, R.drawable.schedule_fill0 + ) + } + } + if (!privilege.work) SwitchItem( + R.string.master_mute, + status.masterVolumeMuted, vm::setMasterVolumeMuted, R.drawable.volume_off_fill0 + ) + if (VERSION.SDK_INT >= 26) { + SwitchItem( + R.string.backup_service, icon = R.drawable.backup_fill0, + state = status.backupServiceEnabled, onCheckedChange = vm::setBackupServiceEnabled, + onClickBlank = { dialog = 1 }) + } + if (VERSION.SDK_INT >= 24 && privilege.work) { + SwitchItem( + R.string.disable_bt_contact_share, status.btContactSharingDisabled, + vm::setBtContactSharingDisabled, R.drawable.account_circle_fill0 + ) + } + if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { + SwitchItem( + R.string.common_criteria_mode, icon = R.drawable.security_fill0, + state = status.commonCriteriaMode, + onCheckedChange = vm::setCommonCriteriaModeEnabled, + onClickBlank = { dialog = 2 }) + } + if (VERSION.SDK_INT >= 31 && (privilege.device || privilege.org) && status.canDisableUsbSignal) { + SwitchItem( + R.string.enable_usb_signal, status.usbSignalEnabled, + vm::setUsbSignalEnabled, R.drawable.usb_fill0 + ) + } + SwitchItem( + R.string.stay_on_while_plugged_in, status.stayOnWhilePluggedIn, + vm::setStayOnWhilePluggedIn, R.drawable.mobile_phone_fill0 + ) + if (privilege.device && !privilege.dhizuku) { + globalSettings.forEach { + SwitchItem(it.name, globalSettingsStatus[it.setting] ?: false, { state -> + vm.setGlobalSetting(it.setting, state) + globalSettingsStatus[it.setting] = state + }, it.icon) + } + secureSettings.forEach { + SwitchItem(it.name, secureSettingsStatus[it.setting] ?: false, { state -> + vm.setSecureSetting(it.setting, state) + secureSettingsStatus[it.setting] = state + }, it.icon) + } + } + if (VERSION.SDK_INT < 34) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.status_bar), style = typography.titleMedium) + Button({ + vm.setStatusBarDisabled(true) + }, Modifier.padding(horizontal = 4.dp)) { + Text(stringResource(R.string.disable)) + } + Button({ + vm.setStatusBarDisabled(false) + }) { + Text(stringResource(R.string.enable)) + } + } + } + } + if (dialog != 0) AlertDialog( + text = { + Text( + stringResource( + when (dialog) { + 1 -> R.string.info_backup_service + 2 -> R.string.info_common_criteria_mode + else -> R.string.options + } + ) + ) + }, + confirmButton = { + TextButton(onClick = { dialog = 0 }) { Text(stringResource(R.string.confirm)) } + }, + onDismissRequest = { dialog = 0 } + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsViewModel.kt new file mode 100644 index 0000000..47e22eb --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemOptionsViewModel.kt @@ -0,0 +1,155 @@ +package com.bintianqi.owndroid.feature.system + +import android.os.Build.VERSION +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.utils.MyShortcut +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ShortcutUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class SystemOptionsViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val settingsRepo: SettingsRepository, + val privilegeState: StateFlow +) : ViewModel() { + val optionsState = MutableStateFlow(SystemOptionsStatus()) + + fun getSystemOptionsStatus() = ph.safeDpmCall { + val privilege = privilegeState.value + optionsState.value = SystemOptionsStatus( + cameraDisabled = dpm.getCameraDisabled(null), + screenCaptureDisabled = dpm.getScreenCaptureDisabled(null), + statusBarDisabled = if (VERSION.SDK_INT >= 34 && + privilege.run { device || (profile && affiliated) } + ) + dpm.isStatusBarDisabled else false, + autoTimeEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) + dpm.getAutoTimeEnabled(dar) else false, + autoTimeZoneEnabled = if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) + dpm.getAutoTimeZoneEnabled(dar) else false, + autoTimeRequired = if (VERSION.SDK_INT < 30) dpm.autoTimeRequired else false, + masterVolumeMuted = dpm.isMasterVolumeMuted(dar), + backupServiceEnabled = + if (VERSION.SDK_INT >= 26) dpm.isBackupServiceEnabled(dar) else false, + btContactSharingDisabled = if (privilege.work) + dpm.getBluetoothContactSharingDisabled(dar) else false, + commonCriteriaMode = + if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) + dpm.isCommonCriteriaModeEnabled(dar) + else false, + usbSignalEnabled = if (VERSION.SDK_INT >= 31) dpm.isUsbDataSignalingEnabled else false, + canDisableUsbSignal = + if (VERSION.SDK_INT >= 31) dpm.canUsbDataSignalingBeDisabled() else false, + stayOnWhilePluggedIn = + Settings.Global.getInt( + application.contentResolver, Settings.Global.STAY_ON_WHILE_PLUGGED_IN + ) != 0 + ) + } + + fun setCameraDisabled(disabled: Boolean) = ph.safeDpmCall { + dpm.setCameraDisabled(dar, disabled) + ShortcutUtils.setShortcut(application, settingsRepo, MyShortcut.DisableCamera, !disabled) + optionsState.update { it.copy(cameraDisabled = dpm.getCameraDisabled(null)) } + } + + fun setScreenCaptureDisabled(disabled: Boolean) = ph.safeDpmCall { + dpm.setScreenCaptureDisabled(dar, disabled) + optionsState.update { + it.copy(screenCaptureDisabled = dpm.getScreenCaptureDisabled(null)) + } + } + + fun setStatusBarDisabled(disabled: Boolean) = ph.safeDpmCall { + val result = dpm.setStatusBarDisabled(dar, disabled) + if (result) optionsState.update { it.copy(statusBarDisabled = disabled) } + } + + @RequiresApi(30) + fun setAutoTimeEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setAutoTimeEnabled(dar, enabled) + optionsState.update { it.copy(autoTimeEnabled = dpm.getAutoTimeEnabled(dar)) } + } + + @RequiresApi(30) + fun setAutoTimeZoneEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setAutoTimeZoneEnabled(dar, enabled) + optionsState.update { + it.copy(autoTimeZoneEnabled = dpm.getAutoTimeZoneEnabled(dar)) + } + } + + @Suppress("DEPRECATION") + fun setAutoTimeRequired(required: Boolean) = ph.safeDpmCall { + dpm.setAutoTimeRequired(dar, required) + optionsState.update { it.copy(autoTimeRequired = dpm.autoTimeRequired) } + } + + fun setMasterVolumeMuted(muted: Boolean) = ph.safeDpmCall { + dpm.setMasterVolumeMuted(dar, muted) + ShortcutUtils.setShortcut(application, settingsRepo, MyShortcut.Mute, !muted) + optionsState.update { it.copy(masterVolumeMuted = dpm.isMasterVolumeMuted(dar)) } + } + + @RequiresApi(26) + fun setBackupServiceEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setBackupServiceEnabled(dar, enabled) + optionsState.update { + it.copy(backupServiceEnabled = dpm.isBackupServiceEnabled(dar)) + } + } + + fun setBtContactSharingDisabled(disabled: Boolean) = ph.safeDpmCall { + dpm.setBluetoothContactSharingDisabled(dar, disabled) + optionsState.update { + it.copy(btContactSharingDisabled = dpm.getBluetoothContactSharingDisabled(dar)) + } + } + + @RequiresApi(30) + fun setCommonCriteriaModeEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setCommonCriteriaModeEnabled(dar, enabled) + optionsState.update { + it.copy(commonCriteriaMode = dpm.isCommonCriteriaModeEnabled(dar)) + } + } + + @RequiresApi(31) + fun setUsbSignalEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.isUsbDataSignalingEnabled = enabled + optionsState.update { it.copy(usbSignalEnabled = dpm.isUsbDataSignalingEnabled) } + } + + fun setStayOnWhilePluggedIn(status: Boolean) = ph.safeDpmCall { + dpm.setGlobalSetting( + dar, Settings.Global.STAY_ON_WHILE_PLUGGED_IN, if (status) "15" else "0" + ) + optionsState.update { it.copy(stayOnWhilePluggedIn = status) } + } + + fun getGlobalSettings(): Map { + return globalSettings.associate { + it.setting to (Settings.Global.getInt(application.contentResolver, it.setting, 0) == 1) + } + } + + fun setGlobalSetting(name: String, status: Boolean) = ph.safeDpmCall { + dpm.setGlobalSetting(dar, name, if (status) "1" else "0") + } + + fun getSecureSettings(): Map { + return secureSettings.associate { + it.setting to (Settings.Secure.getInt(application.contentResolver, it.setting, 0) == 1) + } + } + + fun setSecureSetting(name: String, status: Boolean) = ph.safeDpmCall { + dpm.setSecureSetting(dar, name, if (status) "1" else "0") + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemScreen.kt new file mode 100644 index 0000000..2d9d34c --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemScreen.kt @@ -0,0 +1,830 @@ +package com.bintianqi.owndroid.feature.system + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyManager.WIPE_EUICC +import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE +import android.app.admin.DevicePolicyManager.WIPE_RESET_PROTECTION_DATA +import android.app.admin.DevicePolicyManager.WIPE_SILENTLY +import android.content.Context +import android.os.Build.VERSION +import android.os.UserManager +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.FunctionItem +import com.bintianqi.owndroid.ui.InfoItem +import com.bintianqi.owndroid.ui.ListItem +import com.bintianqi.owndroid.ui.MyLazyScaffold +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.ui.MySmallTitleScaffold +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.ui.SwitchItem +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.yesOrNo +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.delay + +@Composable +fun SystemScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + // 1: reboot, 2: bug report, 3: org name, 4: org id, 5: enrollment specific id + var dialog by rememberSaveable { mutableIntStateOf(0) } + MyScaffold(R.string.system, onNavigateUp, 0.dp) { + FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { + onNavigate(Destination.SystemOptions) + } + FunctionItem(R.string.keyguard, icon = R.drawable.screen_lock_portrait_fill0) { + onNavigate(Destination.Keyguard) + } + if (VERSION.SDK_INT >= 24 && privilege.device && !privilege.dhizuku) + FunctionItem(R.string.hardware_monitor, icon = R.drawable.memory_fill0) { + onNavigate(Destination.HardwareMonitor) + } + FunctionItem(R.string.default_input_method, icon = R.drawable.keyboard_fill0) { + onNavigate(Destination.DefaultInputMethod) + } + if (VERSION.SDK_INT >= 24 && privilege.device) { + FunctionItem(R.string.reboot, icon = R.drawable.restart_alt_fill0) { dialog = 1 } + } + if (VERSION.SDK_INT >= 24 && privilege.device && (VERSION.SDK_INT < 28 || privilege.affiliated)) { + FunctionItem(R.string.bug_report, icon = R.drawable.bug_report_fill0) { dialog = 2 } + } + if (VERSION.SDK_INT >= 28 && (privilege.device || privilege.org)) { + FunctionItem(R.string.time, icon = R.drawable.schedule_fill0) { + onNavigate(Destination.Time) + } + } + if (VERSION.SDK_INT >= 35 && (privilege.device || (privilege.profile && privilege.affiliated))) + FunctionItem(R.string.content_protection_policy, icon = R.drawable.search_fill0) { + onNavigate(Destination.ContentProtectionPolicy) + } + FunctionItem(R.string.permission_policy, icon = R.drawable.key_fill0) { + onNavigate(Destination.PermissionPolicy) + } + if (VERSION.SDK_INT >= 34 && privilege.device) { + FunctionItem(R.string.mte_policy, icon = R.drawable.memory_fill0) { + onNavigate(Destination.MtePolicy) + } + } + if (VERSION.SDK_INT >= 31) { + FunctionItem(R.string.nearby_streaming_policy, icon = R.drawable.share_fill0) { + onNavigate(Destination.NearbyStreamingPolicy) + } + } + if (VERSION.SDK_INT >= 28 && privilege.device) { + FunctionItem(R.string.lock_task_mode, icon = R.drawable.lock_fill0) { + onNavigate(Destination.LockTaskMode) + } + } + FunctionItem(R.string.ca_cert, icon = R.drawable.license_fill0) { + onNavigate(Destination.CaCert) + } + if (VERSION.SDK_INT >= 26 && !privilege.dhizuku && (privilege.device || privilege.org)) { + FunctionItem(R.string.security_logging, icon = R.drawable.description_fill0) { + onNavigate(Destination.SecurityLogging) + } + } + FunctionItem(R.string.device_info, icon = R.drawable.perm_device_information_fill0) { + onNavigate(Destination.DeviceInfo) + } + if (VERSION.SDK_INT >= 24 && (privilege.profile || (VERSION.SDK_INT >= 26 && privilege.device))) { + FunctionItem(R.string.org_name, icon = R.drawable.corporate_fare_fill0) { dialog = 3 } + } + if (VERSION.SDK_INT >= 31) { + FunctionItem(R.string.org_id, icon = R.drawable.corporate_fare_fill0) { dialog = 4 } + } + if (VERSION.SDK_INT >= 31) { + FunctionItem( + R.string.enrollment_specific_id, icon = R.drawable.id_card_fill0 + ) { dialog = 5 } + } + if (VERSION.SDK_INT >= 24 && (privilege.device || privilege.org)) { + FunctionItem(R.string.lock_screen_info, icon = R.drawable.screen_lock_portrait_fill0) { + onNavigate(Destination.LockScreenInfo) + } + } + if (VERSION.SDK_INT >= 24) { + FunctionItem(R.string.support_messages, icon = R.drawable.chat_fill0) { + onNavigate(Destination.SupportMessage) + } + } + FunctionItem(R.string.disable_account_management, icon = R.drawable.account_circle_fill0) { + onNavigate(Destination.DisableAccountManagement) + } + if (privilege.device || privilege.org) { + FunctionItem(R.string.system_update_policy, icon = R.drawable.system_update_fill0) { + onNavigate(Destination.SystemUpdatePolicy) + } + } + if (VERSION.SDK_INT >= 29 && (privilege.device || privilege.org)) { + FunctionItem(R.string.install_system_update, icon = R.drawable.system_update_fill0) { + onNavigate(Destination.InstallSystemUpdate) + } + } + if (VERSION.SDK_INT >= 30 && (privilege.device || privilege.org)) { + FunctionItem(R.string.frp_policy, icon = R.drawable.device_reset_fill0) { + onNavigate(Destination.FrpPolicy) + } + } + if (vm.getDisplayDangerousFeatures() && !privilege.work) { + FunctionItem(R.string.wipe_data, icon = R.drawable.device_reset_fill0) { + onNavigate(Destination.WipeData) + } + } + } + if ((dialog == 1 || dialog == 2) && VERSION.SDK_INT >= 24) AlertDialog( + onDismissRequest = { dialog = 0 }, + title = { + Text(stringResource(if (dialog == 1) R.string.reboot else R.string.bug_report)) + }, + text = { + Text( + stringResource( + if (dialog == 1) R.string.info_reboot else R.string.confirm_bug_report + ) + ) + }, + dismissButton = { + TextButton(onClick = { dialog = 0 }) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + if (dialog == 1) { + vm.reboot() + } else { + vm.requestBugReport() + } + dialog = 0 + } + ) { + Text(stringResource(R.string.confirm)) + } + } + ) + if (dialog in 3..5) { + val input by when (dialog) { + 3 -> vm.orgNameState.collectAsState() + 4 -> vm.orgIdState.collectAsState() + else -> vm.enrollmentSpecificIdState.collectAsState() + } + AlertDialog( + text = { + LaunchedEffect(Unit) { + if (dialog == 5 && VERSION.SDK_INT >= 31) vm.getEnrollmentSpecificId() + if (dialog == 3 && VERSION.SDK_INT >= 24) vm.getOrgName() + } + Column { + OutlinedTextField( + input, { + when (dialog) { + 3 -> vm.setOrgName(it) + 4 -> vm.setOrgId(it) + } + }, + Modifier + .fillMaxWidth() + .padding(bottom = if (dialog != 3) 8.dp else 0.dp), + readOnly = dialog == 5, + label = { + Text( + stringResource( + when (dialog) { + 3 -> R.string.org_name + 4 -> R.string.org_id + else -> R.string.enrollment_specific_id + } + ) + ) + }, + supportingText = { + if (dialog == 4) Text(stringResource(R.string.length_6_to_64)) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + textStyle = typography.bodyLarge + ) + if (dialog == 5) Text(stringResource(R.string.info_enrollment_specific_id)) + if (dialog == 4) Text(stringResource(R.string.info_org_id)) + } + }, + onDismissRequest = { dialog = 0 }, + dismissButton = { + if (dialog != 5) TextButton({ dialog = 0 }) { + Text( + stringResource(R.string.cancel) + ) + } + }, + confirmButton = { + TextButton( + { + if (dialog == 3 && VERSION.SDK_INT >= 24) vm.applyOrgName() + if (dialog == 4 && VERSION.SDK_INT >= 31) vm.applyOrgId() + dialog = 0 + }, + enabled = dialog != 4 || input.length in 6..64 + ) { + Text(stringResource(R.string.confirm)) + } + } + ) + } +} + +@Composable +fun KeyguardScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + MyScaffold(R.string.keyguard, onNavigateUp) { + if (privilege.device || + (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated) + ) { + Row( + Modifier.fillMaxWidth(), Arrangement.SpaceBetween, + ) { + Button( + onClick = { vm.setKeyguardDisabled(true) }, + modifier = Modifier.fillMaxWidth(0.49F) + ) { + Text(stringResource(R.string.disable)) + } + Button( + { vm.setKeyguardDisabled(false) }, + Modifier.fillMaxWidth(0.96F) + ) { + Text(stringResource(R.string.enable)) + } + } + Notes(R.string.info_disable_keyguard) + Spacer(Modifier.padding(vertical = 12.dp)) + } + Text(text = stringResource(R.string.lock_now), style = typography.titleLarge) + Spacer(Modifier.padding(vertical = 2.dp)) + var evictKey by rememberSaveable { mutableStateOf(false) } + if (VERSION.SDK_INT >= 26 && privilege.work) { + CheckBoxItem(R.string.evict_credential_encryption_key, evictKey) { evictKey = true } + Spacer(Modifier.height(5.dp)) + Notes(R.string.info_evict_credential_encryption_key) + } + Button( + { vm.lockScreen(evictKey) }, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.lock_now)) + } + } +} + +@Composable +fun DefaultInputMethodScreen( + vm: SystemViewModel, navigateUp: () -> Unit +) { + val imList by vm.inputMethodList.collectAsStateWithLifecycle() + val selectedIm by vm.defaultInputMethodState.collectAsState() + LaunchedEffect(Unit) { + vm.getInputMethods() + vm.getDefaultInputMethod() + } + MyLazyScaffold(R.string.default_input_method, navigateUp) { + items(imList) { (id, info) -> + Row( + Modifier + .fillMaxWidth() + .clickable { vm.setDefaultInputMethod(id) } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selectedIm == id, { vm.setDefaultInputMethod(id) }) + Image(rememberDrawablePainter(info.icon), null, Modifier.size(40.dp)) + Column(Modifier.padding(start = 8.dp)) { + Text(info.label) + Text(id, Modifier.alpha(0.7F), style = typography.bodyMedium) + } + } + } + item { + Spacer(Modifier.height(BottomPadding)) + } + } +} + +@RequiresApi(35) +@Composable +fun ContentProtectionPolicyScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val policy by vm.contentProtectionPolicyState.collectAsState() + MyScaffold(R.string.content_protection_policy, onNavigateUp, 0.dp) { + mapOf( + 0 to R.string.not_controlled_by_policy, + 1 to R.string.disabled, + 2 to R.string.enabled + ).forEach { (id, string) -> + FullWidthRadioButtonItem(string, policy == id) { vm.setContentProtectionPolicy(id) } + } + Notes(R.string.info_content_protection_policy, HorizontalPadding) + } +} + +@Composable +fun PermissionPolicyScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val policy by vm.permissionPolicyState.collectAsState() + MyScaffold(R.string.permission_policy, onNavigateUp, 0.dp) { + listOf( + 0 to R.string.default_stringres, + 1 to R.string.auto_grant, + 2 to R.string.auto_deny + ).forEach { + FullWidthRadioButtonItem(it.second, policy == it.first) { + vm.setPermissionPolicy(it.first) + } + } + Notes(R.string.info_permission_policy, HorizontalPadding) + } +} + +@RequiresApi(34) +@Composable +fun MtePolicyScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val policy by vm.mtePolicyState.collectAsState() + LaunchedEffect(Unit) { + vm.getMtePolicy() + } + MyScaffold(R.string.mte_policy, onNavigateUp, 0.dp) { + listOf( + 0 to R.string.default_stringres, + 1 to R.string.enabled, + 2 to R.string.disabled + ).forEach { + FullWidthRadioButtonItem(it.second, policy == it.first) { vm.setMtePolicy(it.first) } + } + Notes(R.string.info_mte_policy, HorizontalPadding) + } +} + +@RequiresApi(31) +@Composable +fun NearbyStreamingPolicyScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val appPolicy by vm.nsAppPolicyState.collectAsState() + val notificationPolicy by vm.nsNotificationPolicyState.collectAsState() + MySmallTitleScaffold(R.string.nearby_streaming_policy, onNavigateUp, 0.dp) { + Text( + stringResource(R.string.nearby_app_streaming), + Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), + style = typography.titleLarge + ) + NearbyStreamingPolicyScreenContent(appPolicy, vm::setNsAppPolicy) + Notes(R.string.info_nearby_app_streaming_policy, HorizontalPadding) + Spacer(Modifier.height(20.dp)) + Text( + stringResource(R.string.nearby_notification_streaming), + Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), + style = typography.titleLarge + ) + NearbyStreamingPolicyScreenContent(notificationPolicy, vm::setNsNotificationPolicy) + Notes(R.string.info_nearby_notification_streaming_policy, HorizontalPadding) + Spacer(Modifier.height(BottomPadding)) + } +} + +@Composable +private fun NearbyStreamingPolicyScreenContent(policy: Int, setPolicy: (Int) -> Unit) { + listOf( + 0 to R.string.default_str, + 1 to R.string.disabled, + 2 to R.string.enabled, + 3 to R.string.enable_if_same_account + ).forEach { + FullWidthRadioButtonItem(it.second, policy == it.first) { setPolicy(it.first) } + } +} + +@Composable +fun DeviceInfoScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + val info by vm.deviceInfoState.collectAsState() + var dialog by rememberSaveable { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + vm.getDeviceInfo() + } + MyScaffold(R.string.device_info, onNavigateUp, 0.dp) { + if (VERSION.SDK_INT >= 34 && (privilege.device || privilege.org)) { + InfoItem(R.string.financed_device, info.financed.yesOrNo) + } + if (VERSION.SDK_INT >= 33) { + InfoItem(R.string.dpmrh, info.dpmrh ?: stringResource(R.string.none)) + } + val encryptionStatus = when (info.storageEncryptionStatus) { + DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE -> R.string.es_inactive + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE -> R.string.es_active + DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED -> R.string.es_unsupported + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY -> R.string.es_active_default_key + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER -> R.string.es_active_per_user + else -> R.string.unknown + } + InfoItem(R.string.encryption_status, encryptionStatus) + if (VERSION.SDK_INT >= 28) { + InfoItem( + R.string.support_device_id_attestation, info.deviceIdAttestationSupported.yesOrNo, + true + ) { dialog = 1 } + } + if (VERSION.SDK_INT >= 30) { + InfoItem( + R.string.support_unique_device_attestation, + info.uniqueDeviceAttestationSupported.yesOrNo, true + ) { dialog = 2 } + } + InfoItem(R.string.activated_device_admin, info.activeAdmins.joinToString("\n")) + } + if (dialog != 0) AlertDialog( + text = { + Text( + stringResource( + if (dialog == 1) R.string.info_device_id_attestation + else R.string.info_unique_device_attestation + ) + ) + }, + confirmButton = { + TextButton({ dialog = 0 }) { Text(stringResource(R.string.confirm)) } + }, + onDismissRequest = { dialog = 0 } + ) +} + +@RequiresApi(24) +@Composable +fun SupportMessageScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val shortMsg by vm.shortSupportMessageState.collectAsState() + val longMsg by vm.longSupportMessageState.collectAsState() + LaunchedEffect(Unit) { + vm.getSupportMessages() + } + MyScaffold(R.string.support_messages, onNavigateUp) { + OutlinedTextField( + shortMsg, vm::setShortSupportMessage, + Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + label = { Text(stringResource(R.string.short_support_msg)) }, + minLines = 2 + ) + Notes(R.string.info_short_support_message) + Spacer(Modifier.padding(vertical = 8.dp)) + OutlinedTextField( + longMsg, vm::setLongSupportMessage, + Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + label = { Text(stringResource(R.string.long_support_msg)) }, + minLines = 3 + ) + Notes(R.string.info_long_support_message) + Spacer(Modifier.padding(vertical = 8.dp)) + Button( + vm::applySupportMessages, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.apply)) + } + } +} + +@Composable +fun DisableAccountManagementScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val list by vm.mdAccountTypes.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.getMdAccountTypes() } + MyScaffold(R.string.disable_account_management, onNavigateUp) { + Column(Modifier.animateContentSize()) { + for (i in list) { + ListItem(i) { + vm.setMdAccountType(i, false) + } + } + } + var inputText by remember { mutableStateOf("") } + OutlinedTextField( + inputText, { inputText = it }, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + label = { Text(stringResource(R.string.account_type)) }, + trailingIcon = { + IconButton( + { + vm.setMdAccountType(inputText, true) + inputText = "" + }, + enabled = inputText != "" + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add) + ) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + Spacer(Modifier.padding(vertical = 10.dp)) + Notes(R.string.info_disable_account_management) + } +} + +@RequiresApi(30) +@Composable +fun FrpPolicyScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val policy by vm.frpPolicyState.collectAsState() + var inputAccount by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + vm.getFrpPolicy() + } + MyScaffold(R.string.frp_policy, onNavigateUp, 0.dp) { + if (!policy.supported) { + Column( + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(colorScheme.primaryContainer) + ) { + Text( + stringResource(R.string.frp_not_supported), Modifier.padding(8.dp), + color = colorScheme.onPrimaryContainer + ) + } + } else { + SwitchItem( + R.string.use_policy, policy.usePolicy, + { vm.setFrpPolicy(policy.copy(usePolicy = it)) } + ) + } + if (policy.usePolicy) { + FullWidthCheckBoxItem(R.string.enable_frp, policy.enabled) { + vm.setFrpPolicy(policy.copy(enabled = it)) + } + Column(Modifier.padding(horizontal = HorizontalPadding)) { + Text(stringResource(R.string.account_list_is)) + Column(Modifier.animateContentSize()) { + if (policy.accounts.isEmpty()) Text(stringResource(R.string.none)) + for (i in policy.accounts) { + ListItem(i) { + } + } + } + OutlinedTextField( + inputAccount, { inputAccount = it }, + Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.account)) }, + trailingIcon = { + IconButton( + { + vm.setFrpPolicy( + policy.copy(accounts = policy.accounts + inputAccount) + ) + inputAccount = "" + }, + enabled = inputAccount.isNotBlank() + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add) + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done + ) + ) + Button( + vm::applyFrpPolicy, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.apply)) + } + } + } + Notes(R.string.info_frp_policy, HorizontalPadding) + } +} + +@Composable +fun WipeDataScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val context = LocalContext.current + val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + val focusMgr = LocalFocusManager.current + var flag by rememberSaveable { mutableIntStateOf(0) } + var dialog by rememberSaveable { mutableIntStateOf(0) } // 0: none, 1: wipe data, 2: wipe device + var reason by rememberSaveable { mutableStateOf("") } + MyScaffold(R.string.wipe_data, onNavigateUp, 0.dp) { + FullWidthCheckBoxItem(R.string.wipe_external_storage, flag and WIPE_EXTERNAL_STORAGE != 0) { + flag = flag xor WIPE_EXTERNAL_STORAGE + } + if (privilege.device) FullWidthCheckBoxItem( + R.string.wipe_reset_protection_data, flag and WIPE_RESET_PROTECTION_DATA != 0 + ) { + flag = flag xor WIPE_RESET_PROTECTION_DATA + } + if (VERSION.SDK_INT >= 28) FullWidthCheckBoxItem( + R.string.wipe_euicc, + flag and WIPE_EUICC != 0 + ) { + flag = flag xor WIPE_EUICC + } + if (VERSION.SDK_INT < 34 || !userManager.isSystemUser) { + if (VERSION.SDK_INT >= 29) CheckBoxItem( + R.string.wipe_silently, flag and WIPE_SILENTLY != 0 + ) { + flag = flag xor WIPE_SILENTLY + reason = "" + } + AnimatedVisibility(flag and WIPE_SILENTLY != 0 && VERSION.SDK_INT >= 28) { + OutlinedTextField( + reason, { reason = it }, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + label = { Text(stringResource(R.string.reason)) } + ) + } + Button( + { + focusMgr.clearFocus() + dialog = 1 + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 5.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.error, contentColor = colorScheme.onError + ) + ) { + Text("WipeData") + } + } + if (VERSION.SDK_INT >= 34 && privilege.device) { + Button( + { + focusMgr.clearFocus() + dialog = 2 + }, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 5.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.error, contentColor = colorScheme.onError + ) + ) { + Text("WipeDevice") + } + } + } + if (dialog != 0) { + AlertDialog( + title = { + Text(stringResource(R.string.warning), color = colorScheme.error) + }, + text = { + Text( + stringResource( + if (userManager.isSystemUser) R.string.wipe_data_warning + else R.string.info_wipe_data_in_managed_user + ), + color = colorScheme.error + ) + }, + onDismissRequest = { dialog = 0 }, + confirmButton = { + var timer by remember { mutableIntStateOf(5) } + LaunchedEffect(Unit) { + while (timer > 0) { + delay(1000) + timer -= 1 + } + } + val timerText = if (timer > 0) "(${timer}s)" else "" + TextButton( + { + vm.wipeData(dialog == 2, flag, reason) + }, + Modifier.animateContentSize(), + timer == 0, + colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) + ) { + Text(stringResource(R.string.confirm) + timerText) + } + }, + dismissButton = { + TextButton(onClick = { dialog = 0 }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +@RequiresApi(24) +@Composable +fun LockScreenInfoScreen( + vm: SystemViewModel, onNavigateUp: () -> Unit +) { + val text by vm.lockScreenInfoState.collectAsState() + LaunchedEffect(Unit) { + vm.getLockScreenInfo() + } + MyScaffold(R.string.lock_screen_info, onNavigateUp) { + OutlinedTextField( + text, vm::setLockScreenInfo, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + label = { Text(stringResource(R.string.lock_screen_info)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + Button( + vm::applyLockScreenInfo, + Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.apply)) + } + Spacer(Modifier.padding(vertical = 10.dp)) + Notes(R.string.info_lock_screen_info) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateModel.kt new file mode 100644 index 0000000..7a2a573 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateModel.kt @@ -0,0 +1,23 @@ +package com.bintianqi.owndroid.feature.system + +import android.app.admin.SystemUpdatePolicy +import com.bintianqi.owndroid.R + +data class SystemUpdatePolicyUiState( + val type: SystemUpdatePolicyType = SystemUpdatePolicyType.None, + val start: String = "", + val end: String = "" +) + +enum class SystemUpdatePolicyType(val id: Int, val text: Int) { + None(-1, R.string.none), + Automatic(SystemUpdatePolicy.TYPE_INSTALL_AUTOMATIC, R.string.automatic), + Windowed(SystemUpdatePolicy.TYPE_INSTALL_WINDOWED, R.string.system_update_policy_windowed), + Postpone(SystemUpdatePolicy.TYPE_POSTPONE, R.string.system_update_policy_postpone) +} + +class PendingSystemUpdateInfo( + val exists: Boolean = false, + val time: Long = 0, + val securityPatch: Boolean = false +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateScreen.kt new file mode 100644 index 0000000..5b248c7 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateScreen.kt @@ -0,0 +1,166 @@ +package com.bintianqi.owndroid.feature.system + +import android.net.Uri +import android.os.Build.VERSION +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.ErrorDialog +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.MySmallTitleScaffold +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.formatDate +import com.bintianqi.owndroid.utils.yesOrNo + +@Composable +fun SystemUpdateScreen( + vm: SystemUpdateViewModel, onNavigateUp: () -> Unit +) { + val policy by vm.policyState.collectAsState() + val pendingUpdate by vm.pendingUpdateState.collectAsState() + var uri by remember { mutableStateOf(null) } + var installing by rememberSaveable { mutableStateOf(false) } + var errorMessage by rememberSaveable { mutableStateOf(null) } + val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + uri = it + } + LaunchedEffect(Unit) { + vm.getPolicy() + if (VERSION.SDK_INT >= 26) vm.getPendingUpdate() + } + MySmallTitleScaffold(R.string.system_update_policy, onNavigateUp, 0.dp) { + Text( + stringResource(R.string.system_update_policy), + Modifier.padding(start = HorizontalPadding, top = 8.dp), + style = MaterialTheme.typography.titleLarge + ) + SystemUpdatePolicyType.entries.forEach { + FullWidthRadioButtonItem(it.text, policy.type == it) { + vm.setPolicy(policy.copy(type = it)) + } + } + AnimatedVisibility(policy.type == SystemUpdatePolicyType.Windowed) { + Column(Modifier.padding(horizontal = HorizontalPadding)) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + Arrangement.SpaceBetween + ) { + OutlinedTextField( + policy.start, { vm.setPolicy(policy.copy(start = it)) }, + Modifier.fillMaxWidth(0.49F), + label = { Text(stringResource(R.string.start_time)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Next + ) + ) + OutlinedTextField( + policy.end, { vm.setPolicy(policy.copy(end = it)) }, + Modifier.fillMaxWidth(0.96F), + label = { Text(stringResource(R.string.end_time)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) + ) + } + Text( + stringResource(R.string.minutes_in_one_day), + color = colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } + } + Button( + vm::applyPolicy, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = HorizontalPadding), + policy.type != SystemUpdatePolicyType.Windowed || + listOf(policy.start, policy.end).map { it.toIntOrNull() } + .all { it != null && it <= 1440 } + ) { + Text(stringResource(R.string.apply)) + } + if (VERSION.SDK_INT >= 26) { + Column(Modifier.padding(HorizontalPadding)) { + if (pendingUpdate.exists) { + Text( + stringResource( + R.string.update_received_time, formatDate(pendingUpdate.time) + ) + ) + Text( + stringResource( + R.string.is_security_patch, + stringResource(pendingUpdate.securityPatch.yesOrNo) + ) + ) + } else { + Text(text = stringResource(R.string.no_system_update)) + } + } + } + if (VERSION.SDK_INT >= 29) { + Text( + stringResource(R.string.install_system_update), + Modifier.padding(start = HorizontalPadding), + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(10.dp)) + Button( + { + getFileLauncher.launch("application/zip") + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) + ) { + Text(stringResource(R.string.select_ota_package)) + } + Button( + { + installing = true + vm.installUpdate(uri!!) { message -> + errorMessage = message + } + }, + Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + uri != null && !installing + ) { + Text(stringResource(R.string.install_system_update)) + } + Spacer(Modifier.padding(vertical = 10.dp)) + Notes(R.string.auto_reboot_after_install_succeed, HorizontalPadding) + } + Spacer(Modifier.height(BottomPadding)) + } + ErrorDialog(errorMessage) { errorMessage = null } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateViewModel.kt new file mode 100644 index 0000000..11cd3f2 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemUpdateViewModel.kt @@ -0,0 +1,75 @@ +package com.bintianqi.owndroid.feature.system + +import android.app.admin.DevicePolicyManager.InstallSystemUpdateCallback +import android.app.admin.SystemUpdateInfo +import android.app.admin.SystemUpdatePolicy +import android.net.Uri +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow + +class SystemUpdateViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val toastChannel: ToastChannel +) : ViewModel() { + val policyState = MutableStateFlow(SystemUpdatePolicyUiState()) + + fun getPolicy() = ph.safeDpmCall { + val policy = dpm.systemUpdatePolicy + if (policy != null) { + policyState.value = SystemUpdatePolicyUiState( + SystemUpdatePolicyType.entries.find { it.id == policy.policyType }!!, + policy.installWindowStart.toString(), policy.installWindowEnd.toString() + ) + } + } + + fun setPolicy(info: SystemUpdatePolicyUiState) { + policyState.value = info + } + + fun applyPolicy() = ph.safeDpmCall { + val info = policyState.value + val policy = when (info.type) { + SystemUpdatePolicyType.Automatic -> SystemUpdatePolicy.createAutomaticInstallPolicy() + SystemUpdatePolicyType.Windowed -> + SystemUpdatePolicy.createWindowedInstallPolicy(info.start.toInt(), info.end.toInt()) + SystemUpdatePolicyType.Postpone -> SystemUpdatePolicy.createPostponeInstallPolicy() + else -> null + } + dpm.setSystemUpdatePolicy(dar, policy) + toastChannel.sendStatus(true) + } + + val pendingUpdateState = MutableStateFlow(PendingSystemUpdateInfo()) + + @RequiresApi(26) + fun getPendingUpdate() = ph.safeDpmCall { + val update = dpm.getPendingSystemUpdate(dar) + pendingUpdateState.value = PendingSystemUpdateInfo( + update != null, update?.receivedTime ?: 0, + update?.securityPatchState == SystemUpdateInfo.SECURITY_PATCH_STATE_TRUE + ) + } + + @RequiresApi(29) + fun installUpdate(uri: Uri, callback: (String) -> Unit) = ph.safeDpmCall { + val callback = object : InstallSystemUpdateCallback() { + override fun onInstallUpdateError(errorCode: Int, errorMessage: String) { + super.onInstallUpdateError(errorCode, errorMessage) + val errDetail = when (errorCode) { + UPDATE_ERROR_BATTERY_LOW -> R.string.battery_low + UPDATE_ERROR_UPDATE_FILE_INVALID -> R.string.update_file_invalid + UPDATE_ERROR_INCORRECT_OS_VERSION -> R.string.incorrect_os_ver + UPDATE_ERROR_FILE_NOT_FOUND -> R.string.file_not_exist + else -> R.string.unknown_error + } + callback(application.getString(errDetail) + "\n$errorMessage") + } + } + dpm.installSystemUpdate(dar, uri, application.mainExecutor, callback) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemViewModel.kt new file mode 100644 index 0000000..ebc8d8f --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/SystemViewModel.kt @@ -0,0 +1,315 @@ +package com.bintianqi.owndroid.feature.system + +import android.annotation.SuppressLint +import android.app.admin.DevicePolicyManager +import android.app.admin.FactoryResetProtectionPolicy +import android.os.Build.VERSION +import android.provider.Settings +import android.view.inputmethod.InputMethodManager +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.utils.AppInfo +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import com.bintianqi.owndroid.utils.getAppInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class SystemViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val settingsRepo: SettingsRepository, + val privilegeState: StateFlow, val toastChannel: ToastChannel +) : ViewModel() { + fun getDisplayDangerousFeatures() = settingsRepo.data.displayDangerousFeatures + + @RequiresApi(24) + fun reboot() = ph.safeDpmCall { + dpm.reboot(dar) + } + + @RequiresApi(24) + fun requestBugReport() = ph.safeDpmCall { + val result = try { + dpm.requestBugreport(dar) + } catch (e: Exception) { + e.printStackTrace() + false + } + toastChannel.sendStatus(result) + } + + val orgNameState = MutableStateFlow("") + + @SuppressLint("PrivateApi") + @RequiresApi(24) + fun getOrgName() = ph.safeDpmCall { + orgNameState.value = try { + dpm.getOrganizationName(dar)?.toString() ?: "" + } catch (_: Exception) { + try { + val method = DevicePolicyManager::class.java.getDeclaredMethod( + "getDeviceOwnerOrganizationName" + ) + method.isAccessible = true + (method.invoke(dpm) as CharSequence).toString() + } catch (_: Exception) { + "" + } + } + } + + fun setOrgName(name: String) { + orgNameState.value = name + } + + @RequiresApi(24) + fun applyOrgName() = ph.safeDpmCall { + dpm.setOrganizationName(dar, orgNameState.value) + } + + val orgIdState = MutableStateFlow("") + + fun setOrgId(id: String) { + orgIdState.value = id + } + + @RequiresApi(31) + fun applyOrgId() = ph.safeDpmCall { + val result = try { + dpm.setOrganizationId(orgIdState.value) + true + } catch (_: IllegalStateException) { + false + } + toastChannel.sendStatus(result) + } + + val enrollmentSpecificIdState = MutableStateFlow("") + + @RequiresApi(31) + fun getEnrollmentSpecificId() = ph.safeDpmCall { + enrollmentSpecificIdState.value = + dpm.enrollmentSpecificId.ifEmpty { application.getString(R.string.none) } + } + + val lockScreenInfoState = MutableStateFlow("") + + @RequiresApi(24) + fun getLockScreenInfo() = ph.safeDpmCall { + lockScreenInfoState.value = dpm.deviceOwnerLockScreenInfo?.toString() ?: "" + } + + fun setLockScreenInfo(text: String) { + lockScreenInfoState.value = text + } + + @RequiresApi(24) + fun applyLockScreenInfo() = ph.safeDpmCall { + dpm.setDeviceOwnerLockScreenInfo(dar, lockScreenInfoState.value) + toastChannel.sendStatus(true) + } + + fun setKeyguardDisabled(disabled: Boolean) = ph.safeDpmCall { + val result = dpm.setKeyguardDisabled(dar, disabled) + toastChannel.sendStatus(result) + } + + fun lockScreen(evictKey: Boolean) = ph.safeDpmCall { + if (VERSION.SDK_INT >= 26 && privilegeState.value.work) { + dpm.lockNow( + if (evictKey) DevicePolicyManager.FLAG_EVICT_CREDENTIAL_ENCRYPTION_KEY else 0 + ) + } else { + dpm.lockNow() + } + } + + val defaultInputMethodState = MutableStateFlow("") + + val inputMethodList = MutableStateFlow(listOf>()) + + fun getDefaultInputMethod() = ph.safeDpmCall { + defaultInputMethodState.value = Settings.Secure.getString( + application.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD + ) + } + + fun getInputMethods() = ph.safeDpmCall { + val imm = application.getSystemService(InputMethodManager::class.java) + val pm = application.packageManager + inputMethodList.value = imm.inputMethodList.map { + it.id to getAppInfo(pm, it.packageName) + } + } + + fun setDefaultInputMethod(id: String) = ph.safeDpmCall { + dpm.setSecureSetting( + dar, Settings.Secure.DEFAULT_INPUT_METHOD, id + ) + getDefaultInputMethod() + } + + val contentProtectionPolicyState = MutableStateFlow(0) + + @RequiresApi(35) + fun getContentProtectionPolicy() = ph.safeDpmCall { + contentProtectionPolicyState.value = dpm.getContentProtectionPolicy(dar) + } + + @RequiresApi(35) + fun setContentProtectionPolicy(policy: Int) = ph.safeDpmCall { + dpm.setContentProtectionPolicy(dar, policy) + getContentProtectionPolicy() + } + + val permissionPolicyState = MutableStateFlow(0) + + fun getPermissionPolicy() = ph.safeDpmCall { + permissionPolicyState.value = dpm.getPermissionPolicy(dar) + } + + fun setPermissionPolicy(policy: Int) = ph.safeDpmCall { + dpm.setPermissionPolicy(dar, policy) + getPermissionPolicy() + } + + val mtePolicyState = MutableStateFlow(0) + + @RequiresApi(34) + fun getMtePolicy() = ph.safeDpmCall { + mtePolicyState.value = dpm.mtePolicy + } + + @RequiresApi(34) + fun setMtePolicy(policy: Int) = ph.safeDpmCall { + try { + dpm.mtePolicy = policy + mtePolicyState.value = policy + } catch (_: UnsupportedOperationException) { + toastChannel.sendText(R.string.unsupported) + } + } + + // Nearby streaming + val nsAppPolicyState = MutableStateFlow(0) + + @RequiresApi(31) + fun getNsAppPolicy() = ph.safeDpmCall { + nsAppPolicyState.value = dpm.nearbyAppStreamingPolicy + } + + @RequiresApi(31) + fun setNsAppPolicy(policy: Int) = ph.safeDpmCall { + dpm.nearbyAppStreamingPolicy = policy + getNsAppPolicy() + } + + val nsNotificationPolicyState = MutableStateFlow(0) + + @RequiresApi(31) + fun getNsNotificationPolicy() = ph.safeDpmCall { + nsNotificationPolicyState.value = dpm.nearbyNotificationStreamingPolicy + } + + @RequiresApi(31) + fun setNsNotificationPolicy(policy: Int) = ph.safeDpmCall { + dpm.nearbyNotificationStreamingPolicy = policy + getNsNotificationPolicy() + } + + // Management disabled account + val mdAccountTypes = MutableStateFlow(emptyList()) + + fun getMdAccountTypes() = ph.safeDpmCall { + mdAccountTypes.value = dpm.accountTypesWithManagementDisabled?.toList() ?: emptyList() + } + + fun setMdAccountType(type: String, disabled: Boolean) = ph.safeDpmCall { + dpm.setAccountManagementDisabled(dar, type, disabled) + getMdAccountTypes() + } + + val frpPolicyState = MutableStateFlow(FrpPolicyInfo()) + + @RequiresApi(30) + fun getFrpPolicy() = ph.safeDpmCall { + try { + val policy = dpm.getFactoryResetProtectionPolicy(dar) + frpPolicyState.value = FrpPolicyInfo( + true, policy != null, policy?.isFactoryResetProtectionEnabled ?: false, + policy?.factoryResetProtectionAccounts ?: emptyList() + ) + } catch (_: UnsupportedOperationException) { + } + } + + fun setFrpPolicy(info: FrpPolicyInfo) { + frpPolicyState.value = info + } + + @RequiresApi(30) + fun applyFrpPolicy() = ph.safeDpmCall { + val info = frpPolicyState.value + val policy = if (info.usePolicy) { + FactoryResetProtectionPolicy.Builder() + .setFactoryResetProtectionEnabled(info.enabled) + .setFactoryResetProtectionAccounts(info.accounts) + .build() + } else null + dpm.setFactoryResetProtectionPolicy(dar, policy) + toastChannel.sendStatus(true) + } + + fun wipeData(wipeDevice: Boolean, flags: Int, reason: String) = ph.safeDpmCall { + if (wipeDevice && VERSION.SDK_INT >= 34) { + dpm.wipeDevice(flags) + } else { + if (VERSION.SDK_INT >= 28 && reason.isNotEmpty()) { + dpm.wipeData(flags, reason) + } else { + dpm.wipeData(flags) + } + } + } + + val deviceInfoState = MutableStateFlow(DeviceInfo()) + + fun getDeviceInfo() = ph.safeDpmCall { + val ps = privilegeState.value + deviceInfoState.value = DeviceInfo( + if (VERSION.SDK_INT >= 34 && (ps.device || ps.org)) dpm.isDeviceFinanced else false, + if (VERSION.SDK_INT >= 33) dpm.devicePolicyManagementRoleHolderPackage else "", + dpm.storageEncryptionStatus, + if (VERSION.SDK_INT >= 28) dpm.isDeviceIdAttestationSupported else false, + if (VERSION.SDK_INT >= 30) dpm.isUniqueDeviceAttestationSupported else false, + dpm.activeAdmins?.map { it.flattenToShortString() } ?: emptyList() + ) + } + + val shortSupportMessageState = MutableStateFlow("") + val longSupportMessageState = MutableStateFlow("") + + @RequiresApi(24) + fun getSupportMessages() = ph.safeDpmCall { + shortSupportMessageState.value = dpm.getShortSupportMessage(dar)?.toString() ?: "" + longSupportMessageState.value = dpm.getLongSupportMessage(dar)?.toString() ?: "" + } + + fun setShortSupportMessage(text: String) { + shortSupportMessageState.value = text + } + + fun setLongSupportMessage(text: String) { + longSupportMessageState.value = text + } + + @RequiresApi(24) + fun applySupportMessages() = ph.safeDpmCall { + dpm.setShortSupportMessage(dar, shortSupportMessageState.value.ifEmpty { null }) + dpm.setLongSupportMessage(dar, longSupportMessageState.value.ifEmpty { null }) + toastChannel.sendStatus(true) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeScreen.kt new file mode 100644 index 0000000..5941733 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeScreen.kt @@ -0,0 +1,333 @@ +package com.bintianqi.owndroid.feature.system + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CheckBoxItem +import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.clickableTextField +import com.bintianqi.owndroid.utils.formatDate +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.TimeZone + +@OptIn(ExperimentalMaterial3Api::class) +@RequiresApi(28) +@Composable +fun TimeScreen(vm: TimeViewModel, onNavigateUp: () -> Unit) { + val pagerState = rememberPagerState { if (Build.VERSION.SDK_INT >= 36) 2 else 1 } + val tab = pagerState.currentPage + val coroutine = rememberCoroutineScope() + Scaffold( + topBar = { + TopAppBar( + { Text(stringResource(R.string.time)) }, + navigationIcon = { NavIcon(onNavigateUp) } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (Build.VERSION.SDK_INT >= 36) PrimaryTabRow(tab) { + Tab( + tab == 0, { coroutine.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.change_time)) } + ) + Tab( + tab == 1, { coroutine.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.auto_time_policy)) } + ) + } + HorizontalPager( + pagerState, Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { page -> + if (page == 0) { + ChangeTimeScreen(vm) + } else if (Build.VERSION.SDK_INT >= 36) { + AutoTimePolicyScreen(vm) + } + } + } + } +} + +@RequiresApi(28) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChangeTimeScreen(vm: TimeViewModel) { + val pagerState = rememberPagerState { 2 } + val coroutine = rememberCoroutineScope() + val tab = pagerState.targetPage + Column(Modifier.fillMaxSize()) { + SecondaryTabRow(tab) { + Tab( + tab == 0, + { + coroutine.launch { + pagerState.animateScrollToPage(0) + } + }, + text = { Text(stringResource(R.string.time)) } + ) + Tab( + tab == 1, + { + coroutine.launch { + pagerState.animateScrollToPage(1) + } + }, + text = { Text(stringResource(R.string.timezone)) } + ) + } + HorizontalPager(pagerState) { page -> + if (page == 0) { + ChangeTimeScreenContent(vm::setTime) + } else { + ChangeTimeZoneScreenContent(vm::setTimeZone) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChangeTimeScreenContent(setTime: (Long, Boolean) -> Unit) { + var picker by rememberSaveable { mutableIntStateOf(0) } //0:None, 1:DatePicker, 2:TimePicker + var useCurrentTz by rememberSaveable { mutableStateOf(true) } + val datePickerState = rememberDatePickerState() + val timePickerState = rememberTimePickerState(is24Hour = true) + Column( + Modifier + .fillMaxSize() + .padding(top = 8.dp) + .padding(horizontal = HorizontalPadding) + .verticalScroll(rememberScrollState()) + ) { + OutlinedTextField( + datePickerState.selectedDateMillis?.let { formatDate(it) } ?: "", {}, + Modifier + .fillMaxWidth() + .clickableTextField { picker = 1 }, + readOnly = true, + label = { Text(stringResource(R.string.date)) } + ) + OutlinedTextField( + timePickerState.hour.toString().padStart(2, '0') + ":" + + timePickerState.minute.toString().padStart(2, '0'), + {}, + Modifier + .fillMaxWidth() + .clickableTextField { picker = 2 } + .padding(vertical = 4.dp), + readOnly = true, + label = { Text(stringResource(R.string.time)) } + ) + CheckBoxItem(R.string.use_current_timezone, useCurrentTz) { + useCurrentTz = it + } + Button( + { + val timeMillis = datePickerState.selectedDateMillis!! + + timePickerState.hour * 3600000 + timePickerState.minute * 60000 + setTime(timeMillis, useCurrentTz) + }, + Modifier.fillMaxWidth(), + datePickerState.selectedDateMillis != null + ) { + Text(stringResource(R.string.apply)) + } + Spacer(Modifier.height(BottomPadding)) + } + if (picker == 1) DatePickerDialog( + confirmButton = { + TextButton({ picker = 0 }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { picker = 0 } + ) { + Column(Modifier.verticalScroll(rememberScrollState())) { + DatePicker(datePickerState) + } + } + if (picker == 2) TimePickerDialog( + title = {}, + confirmButton = { + TextButton({ picker = 0 }) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = { picker = 0 } + ) { + TimePicker(timePickerState) + } +} + +@Composable +private fun ChangeTimeZoneScreenContent(setTimeZone: (String) -> Unit) { + var inputTimezone by rememberSaveable { mutableStateOf(TimeZone.getDefault().id) } + var dialog by rememberSaveable { mutableStateOf(false) } + val availableIds = TimeZone.getAvailableIDs() + val validInput = inputTimezone in availableIds + Column( + Modifier + .fillMaxSize() + .padding(top = 8.dp) + .padding(horizontal = HorizontalPadding) + .verticalScroll(rememberScrollState()) + ) { + OutlinedTextField( + inputTimezone, { inputTimezone = it }, + Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.timezone_id)) }, + isError = inputTimezone.isNotEmpty() && !validInput, + trailingIcon = { + IconButton({ dialog = true }) { + Icon(Icons.AutoMirrored.Default.List, null) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii) + ) + Spacer(Modifier.padding(vertical = 5.dp)) + Button( + { + setTimeZone(inputTimezone) + }, + Modifier.fillMaxWidth(), + inputTimezone.isNotEmpty() && validInput + ) { + Text(stringResource(R.string.apply)) + } + Spacer(Modifier.padding(vertical = 10.dp)) + Notes(R.string.disable_auto_time_zone_before_set) + } + if (dialog) AlertDialog( + text = { + LazyColumn { + items(availableIds) { + Text( + it, + Modifier + .fillMaxWidth() + .padding(vertical = 1.dp) + .clickable { + inputTimezone = it + dialog = false + } + .padding(start = 6.dp, top = 10.dp, bottom = 10.dp) + ) + } + } + }, + confirmButton = { + TextButton(onClick = { dialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = false } + ) +} + +@RequiresApi(36) +@Composable +private fun AutoTimePolicyScreen(vm: TimeViewModel) { + LaunchedEffect(Unit) { + vm.getAutoTimePolicy() + vm.getAutoTimeZonePolicy() + } + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Text( + stringResource(R.string.auto_time_policy), + Modifier.padding(start = 8.dp, top = 8.dp), + style = MaterialTheme.typography.titleLarge + ) + AutoTimePolicyScreenContent(vm.autoTimePolicyState, vm::setAutoTimePolicy) + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.auto_timezone_policy), + Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.titleLarge + ) + AutoTimePolicyScreenContent(vm.autoTimeZonePolicyState, vm::setAutoTimeZonePolicy) + } +} + +@Composable +private fun AutoTimePolicyScreenContent( + policyState: StateFlow, setPolicy: (Int) -> Unit +) { + val policy by policyState.collectAsState() + listOf( + 0 to R.string.not_controlled_by_policy, + 1 to R.string.disabled, + 2 to R.string.enable + ).forEach { + FullWidthRadioButtonItem(it.second, it.first == policy) { + setPolicy(it.first) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeViewModel.kt new file mode 100644 index 0000000..b5176df --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/system/TimeViewModel.kt @@ -0,0 +1,54 @@ +package com.bintianqi.owndroid.feature.system + +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import java.time.ZoneId +import java.time.ZonedDateTime + +class TimeViewModel( + val ph: PrivilegeHelper, val toastChannel: ToastChannel +) : ViewModel() { + @RequiresApi(28) + fun setTime(time: Long, useCurrentTz: Boolean) = ph.safeDpmCall { + val offset = if (useCurrentTz) { + ZonedDateTime.now(ZoneId.systemDefault()).offset.totalSeconds * 1000L + } else 0L + val result = dpm.setTime(dar, time - offset) + toastChannel.sendStatus(result) + } + + @RequiresApi(28) + fun setTimeZone(tz: String) = ph.safeDpmCall { + val result = dpm.setTimeZone(dar, tz) + toastChannel.sendStatus(result) + } + + val autoTimePolicyState = MutableStateFlow(0) + + @RequiresApi(36) + fun getAutoTimePolicy() = ph.safeDpmCall { + autoTimePolicyState.value = dpm.autoTimePolicy + } + + @RequiresApi(36) + fun setAutoTimePolicy(policy: Int) = ph.safeDpmCall { + dpm.autoTimePolicy = policy + getAutoTimePolicy() + } + + val autoTimeZonePolicyState = MutableStateFlow(0) + + @RequiresApi(36) + fun getAutoTimeZonePolicy() = ph.safeDpmCall { + autoTimeZonePolicyState.value = dpm.autoTimeZonePolicy + } + + @RequiresApi(36) + fun setAutoTimeZonePolicy(policy: Int) = ph.safeDpmCall { + dpm.autoTimeZonePolicy = policy + getAutoTimeZonePolicy() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionModel.kt new file mode 100644 index 0000000..6316f1a --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionModel.kt @@ -0,0 +1,8 @@ +package com.bintianqi.owndroid.feature.user_restriction + +class Restriction( + val id: String, + val name: Int, + val icon: Int, + val requiresApi: Int = 0 +) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionScreen.kt similarity index 67% rename from app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt rename to app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionScreen.kt index 8fe03b4..97075cf 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionScreen.kt @@ -1,4 +1,4 @@ -package com.bintianqi.owndroid.dpm +package com.bintianqi.owndroid.feature.user_restriction import androidx.annotation.RequiresApi import androidx.compose.foundation.background @@ -44,47 +44,32 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bintianqi.owndroid.BottomPadding -import com.bintianqi.owndroid.HorizontalPadding -import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.UserRestrictionCategory -import com.bintianqi.owndroid.UserRestrictionsRepository -import com.bintianqi.owndroid.adaptiveInsets -import com.bintianqi.owndroid.popToast -import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyLazyScaffold import com.bintianqi.owndroid.ui.NavIcon -import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.Serializable - -@Serializable -data class Restriction( - val id: String, - val name: Int, - val icon: Int, - val requiresApi: Int = 0 -) - -@Serializable object UserRestriction +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(24) @Composable fun UserRestrictionScreen( - getRestrictions: () -> Unit,onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit + vm: UserRestrictionViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit ) { - val privilege by Privilege.status.collectAsStateWithLifecycle() + val privilege by vm.privilegeState.collectAsStateWithLifecycle() val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - LaunchedEffect(Unit) { getRestrictions() } + LaunchedEffect(Unit) { + vm.getRestrictions() + } Scaffold( Modifier.nestedScroll(sb.nestedScrollConnection), topBar = { @@ -92,7 +77,7 @@ fun UserRestrictionScreen( { Text(stringResource(R.string.user_restriction)) }, navigationIcon = { NavIcon(onNavigateUp) }, actions = { - IconButton({ onNavigate(UserRestrictionEditor) }) { + IconButton({ onNavigate(Destination.UserRestrictionEditor) }) { Icon(Icons.Default.Edit, null) } }, @@ -102,53 +87,46 @@ fun UserRestrictionScreen( contentWindowInsets = adaptiveInsets() ) { paddingValues -> Column( - modifier = Modifier + Modifier .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) .padding(bottom = 80.dp) ) { Spacer(Modifier.padding(vertical = 2.dp)) - Text(text = stringResource(R.string.switch_to_disable_feature), modifier = Modifier.padding(start = 16.dp)) + Text( + stringResource(R.string.switch_to_disable_feature), + Modifier.padding(start = 16.dp) + ) if (privilege.profile && !privilege.work) { - Text(text = stringResource(R.string.profile_owner_is_restricted), modifier = Modifier.padding(start = 16.dp)) + Text( + stringResource(R.string.profile_owner_is_restricted), + Modifier.padding(start = 16.dp) + ) } - if(privilege.work) { - Text(text = stringResource(R.string.some_features_invalid_in_work_profile), modifier = Modifier.padding(start = 16.dp)) + if (privilege.work) { + Text( + stringResource(R.string.some_features_invalid_in_work_profile), + Modifier.padding(start = 16.dp) + ) } Spacer(Modifier.padding(vertical = 2.dp)) UserRestrictionCategory.entries.forEach { FunctionItem(it.title, icon = it.icon) { - onNavigate(UserRestrictionOptions(it.name)) + onNavigate(Destination.UserRestrictionOptions(it.name)) } } - Row( - Modifier - .padding(HorizontalPadding, 10.dp) - .fillMaxWidth() - .background(colorScheme.primaryContainer, RoundedCornerShape(8.dp)) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Outlined.Info, null, Modifier.padding(end = 8.dp), colorScheme.onPrimaryContainer) - Text(stringResource(R.string.user_restriction_tip), color = colorScheme.onPrimaryContainer) - } } } } -@Serializable -data class UserRestrictionOptions(val id: String) - @RequiresApi(24) @Composable fun UserRestrictionOptionsScreen( - args: UserRestrictionOptions, userRestrictions: StateFlow>, - setRestriction: (String, Boolean) -> Boolean, setShortcut: (String) -> Boolean, + args: Destination.UserRestrictionOptions, vm: UserRestrictionViewModel, onNavigateUp: () -> Unit ) { - val context = LocalContext.current - val status by userRestrictions.collectAsStateWithLifecycle() + val status by vm.restrictionsState.collectAsStateWithLifecycle() val (title, items) = UserRestrictionsRepository.getData(args.id) MyLazyScaffold(title, onNavigateUp) { items(items) { restriction -> @@ -156,13 +134,16 @@ fun UserRestrictionOptionsScreen( Modifier .fillMaxWidth() .combinedClickable(onClick = {}, onLongClick = { - if (!setShortcut(restriction.id)) context.popToast(R.string.unsupported) + vm.createShortcut(restriction.id) }) .padding(15.dp, 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { - Icon(painterResource(restriction.icon), null, Modifier.padding(start = 6.dp, end = 16.dp)) + Icon( + painterResource(restriction.icon), null, + Modifier.padding(start = 6.dp, end = 16.dp) + ) Column { Text(stringResource(restriction.name), style = typography.titleMedium) Text( @@ -174,31 +155,42 @@ fun UserRestrictionOptionsScreen( Switch( status[restriction.id] == true, { - if (!setRestriction(restriction.id, it)) { - context.showOperationResultToast(false) - } + vm.setRestriction(restriction.id, it) }, Modifier.padding(start = 8.dp) ) } } item { + 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 + ) + } Spacer(Modifier.height(BottomPadding)) } } } -@Serializable object UserRestrictionEditor - @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(24) @Composable fun UserRestrictionEditorScreen( - restrictions: StateFlow>, setRestriction: (String, Boolean) -> Boolean, - onNavigateUp: () -> Unit + vm: UserRestrictionViewModel, onNavigateUp: () -> Unit ) { - val context = LocalContext.current - val map by restrictions.collectAsStateWithLifecycle() + val map by vm.restrictionsState.collectAsStateWithLifecycle() val list = map.filter { it.value }.map { it.key } Scaffold( topBar = { @@ -209,15 +201,20 @@ fun UserRestrictionEditorScreen( }, contentWindowInsets = adaptiveInsets() ) { paddingValues -> - LazyColumn(Modifier.fillMaxSize().padding(paddingValues)) { + LazyColumn(Modifier + .fillMaxSize() + .padding(paddingValues)) { items(list, { it }) { Row( - Modifier.fillMaxWidth().padding(HorizontalPadding, 2.dp).animateItem(), + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 2.dp) + .animateItem(), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Text(it) IconButton({ - if (!setRestriction(it, false)) context.showOperationResultToast(false) + vm.setRestriction(it, false) }) { Icon(Icons.Outlined.Delete, null) } @@ -226,17 +223,21 @@ fun UserRestrictionEditorScreen( item { var input by rememberSaveable { mutableStateOf("") } fun add() { - if (!setRestriction(input, false)) context.showOperationResultToast(false) + vm.setRestriction(input, true) } OutlinedTextField( - input, { input = it }, Modifier.fillMaxWidth().padding(HorizontalPadding, 8.dp), + input, { input = it }, Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 8.dp), label = { Text("id") }, trailingIcon = { IconButton(::add, enabled = input.isNotBlank()) { Icon(Icons.Default.Add, null) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), + 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/feature/user_restriction/UserRestrictionViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionViewModel.kt new file mode 100644 index 0000000..95e1e4b --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionViewModel.kt @@ -0,0 +1,54 @@ +package com.bintianqi.owndroid.feature.user_restriction + +import android.content.Context +import android.os.UserManager +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ShortcutUtils +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class UserRestrictionViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val settingsRepo: SettingsRepository, + val privilegeState: StateFlow, val toastChannel: ToastChannel +) : ViewModel() { + val restrictionsState = MutableStateFlow(emptyMap()) + + fun getRestrictions() { + val um = application.getSystemService(Context.USER_SERVICE) as UserManager + val bundle = um.userRestrictions + restrictionsState.value = bundle.keySet().associateWith { bundle.getBoolean(it) } + } + + fun setRestriction(name: String, state: Boolean) = ph.safeDpmCall { + val result = try { + if (state) { + dpm.addUserRestriction(dar, name) + } else { + dpm.clearUserRestriction(dar, name) + } + restrictionsState.update { it.plus(name to state) } + getRestrictions() + ShortcutUtils.updateUserRestrictionShortcut( + application, settingsRepo, name, !state, true + ) + true + } catch (_: SecurityException) { + false + } + if (!result) toastChannel.sendStatus(false) + } + + fun createShortcut(id: String) { + val result = ShortcutUtils.setUserRestrictionShortcut( + application, settingsRepo, id, restrictionsState.value[id] ?: true + ) + if (!result) toastChannel.sendText(R.string.unsupported) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionsRepository.kt similarity index 99% rename from app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt rename to app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionsRepository.kt index e00916b..771948e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/feature/user_restriction/UserRestrictionsRepository.kt @@ -1,8 +1,8 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.feature.user_restriction import android.os.Build import android.os.UserManager -import com.bintianqi.owndroid.dpm.Restriction +import com.bintianqi.owndroid.R @Suppress("InlinedApi") object UserRestrictionsRepository { @@ -114,4 +114,4 @@ enum class UserRestrictionCategory(val title: Int, val icon: Int) { 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/feature/users/UsersModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/users/UsersModel.kt new file mode 100644 index 0000000..3414f6e --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/users/UsersModel.kt @@ -0,0 +1,22 @@ +package com.bintianqi.owndroid.feature.users + +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 +) + +class UserIdentifier(val id: Int, val serial: Long) + +enum class UserOperationType { + Start, Switch, Stop, Delete +} + +class CreateUserResult(val message: Int, val serial: Long = -1) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt b/app/src/main/java/com/bintianqi/owndroid/feature/users/UsersScreen.kt similarity index 64% rename from app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt rename to app/src/main/java/com/bintianqi/owndroid/feature/users/UsersScreen.kt index 18776a3..dc26f85 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/feature/users/UsersScreen.kt @@ -1,4 +1,4 @@ -package com.bintianqi.owndroid.dpm +package com.bintianqi.owndroid.feature.users import android.app.admin.DevicePolicyManager import android.graphics.Bitmap @@ -44,9 +44,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -62,13 +62,7 @@ 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.HorizontalPadding -import com.bintianqi.owndroid.MyViewModel -import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.formatDate -import com.bintianqi.owndroid.popToast -import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FunctionItem @@ -77,40 +71,50 @@ import com.bintianqi.owndroid.ui.ListItem import com.bintianqi.owndroid.ui.MyScaffold 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.flow.StateFlow -import kotlinx.serialization.Serializable - -@Serializable object Users +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.formatDate +import com.bintianqi.owndroid.utils.popToast +import com.bintianqi.owndroid.utils.uriToStream +import com.bintianqi.owndroid.utils.yesOrNo @Composable -fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { +fun UsersScreen(vm: UsersViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit) { val context = LocalContext.current - val privilege by Privilege.status.collectAsStateWithLifecycle() - /** 1: logout */ + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + // 1: logout var dialog by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.users, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated) { + if (VERSION.SDK_INT >= 28 && privilege.profile && privilege.affiliated) { 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.options, icon = R.drawable.tune_fill0) { onNavigate(UsersOptions) } + FunctionItem(R.string.user_info, icon = R.drawable.person_fill0) { + onNavigate(Destination.UserInfo) } - if(privilege.device) { - FunctionItem(R.string.user_operation, icon = R.drawable.sync_alt_fill0) { onNavigate(UserOperation) } + if (VERSION.SDK_INT >= 28 && privilege.device) { + FunctionItem(R.string.options, icon = R.drawable.tune_fill0) { + onNavigate(Destination.UsersOptions) + } } - if(VERSION.SDK_INT >= 24 && privilege.device) { - FunctionItem(R.string.create_user, icon = R.drawable.person_add_fill0) { onNavigate(CreateUser) } + if (privilege.device) { + FunctionItem(R.string.user_operation, icon = R.drawable.sync_alt_fill0) { + onNavigate(Destination.UserOperation) + } + } + if (VERSION.SDK_INT >= 24 && privilege.device) { + FunctionItem(R.string.create_user, icon = R.drawable.person_add_fill0) { + onNavigate(Destination.CreateUser) + } + } + FunctionItem(R.string.change_username, icon = R.drawable.edit_fill0) { + onNavigate(Destination.ChangeUsername) } - FunctionItem(R.string.change_username, icon = R.drawable.edit_fill0) { onNavigate(ChangeUsername) } var changeUserIconDialog by remember { mutableStateOf(false) } var bitmap: Bitmap? by remember { mutableStateOf(null) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { - if(it != null) uriToStream(context, it) { stream -> + if (it != null) uriToStream(context, it) { stream -> bitmap = BitmapFactory.decodeStream(stream) - if(bitmap != null) changeUserIconDialog = true + if (bitmap != null) changeUserIconDialog = true } } FunctionItem(R.string.change_user_icon, icon = R.drawable.account_circle_fill0) { @@ -122,11 +126,15 @@ fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> 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) } + if (VERSION.SDK_INT >= 28 && privilege.device) { + FunctionItem(R.string.user_session_msg, icon = R.drawable.notifications_fill0) { + onNavigate(Destination.UserSessionMessage) + } } - if(VERSION.SDK_INT >= 26) { - FunctionItem(R.string.affiliation_id, icon = R.drawable.id_card_fill0) { onNavigate(AffiliationId) } + if (VERSION.SDK_INT >= 26) { + FunctionItem(R.string.affiliation_id, icon = R.drawable.id_card_fill0) { + onNavigate(Destination.AffiliationId) + } } } if (VERSION.SDK_INT >= 28 && dialog == 1) AlertDialog( @@ -136,7 +144,7 @@ fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> }, confirmButton = { TextButton({ - context.popToast(vm.logoutUser()) + vm.logout() dialog = 0 }) { Text(stringResource(R.string.confirm)) @@ -151,43 +159,33 @@ fun UsersScreen(vm: MyViewModel, onNavigateUp: () -> Unit, onNavigate: (Any) -> ) } -@Serializable object UsersOptions - @Composable fun UsersOptionsScreen( - getLogoutEnabled: () -> Boolean, setLogoutEnabled: (Boolean) -> Unit, onNavigateUp: () -> Unit + vm: UsersViewModel, onNavigateUp: () -> Unit ) { - var logoutEnabled by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { logoutEnabled = getLogoutEnabled() } + val logoutEnabled by vm.logoutEnabledState.collectAsState() + LaunchedEffect(Unit) { + if (VERSION.SDK_INT >= 28) vm.getLogoutEnabled() + } MyScaffold(R.string.options, onNavigateUp, 0.dp) { - if(VERSION.SDK_INT >= 28) { - SwitchItem(R.string.enable_logout, logoutEnabled, { - setLogoutEnabled(it) - logoutEnabled = it - }) + if (VERSION.SDK_INT >= 28) { + SwitchItem(R.string.enable_logout, logoutEnabled, vm::setLogoutEnabled) } } } -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(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { - var info by remember { mutableStateOf(UserInformation()) } +fun UserInfoScreen(vm: UsersViewModel, onNavigateUp: () -> Unit) { + val info by vm.userInformationState.collectAsState() var infoDialog by rememberSaveable { mutableIntStateOf(0) } LaunchedEffect(Unit) { - info = getInfo() + vm.getUserInformation() } MyScaffold(R.string.user_info, onNavigateUp, 0.dp) { 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 } + if (VERSION.SDK_INT >= 31) InfoItem( + R.string.headless_system_user_mode, info.headless.yesOrNo, true + ) { infoDialog = 1 } Spacer(Modifier.height(8.dp)) InfoItem(R.string.system_user, info.system.yesOrNo) if (VERSION.SDK_INT >= 34) InfoItem(R.string.admin_user, info.admin.yesOrNo) @@ -202,7 +200,7 @@ fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { InfoItem(R.string.user_id, (Binder.getCallingUid() / 100000).toString()) InfoItem(R.string.user_serial_number, info.serial.toString()) } - if(infoDialog != 0) AlertDialog( + if (infoDialog != 0) AlertDialog( text = { Text(stringResource(R.string.info_headless_system_user_mode)) }, confirmButton = { TextButton(onClick = { infoDialog = 0 }) { @@ -213,47 +211,49 @@ fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { ) } -class UserIdentifier(val id: Int, val serial: Long) - -enum class UserOperationType { - Start, Switch, Stop, Delete -} - -@Serializable object UserOperation - @OptIn(ExperimentalMaterial3Api::class) @Composable fun UserOperationScreen( - getUsers: () -> List, doOperation: (UserOperationType, Int, Boolean) -> Boolean, - createShortcut: (UserOperationType, Int, Boolean) -> Boolean, onNavigateUp: () -> Unit + vm: UsersViewModel, onNavigateUp: () -> Unit ) { - - val context = LocalContext.current var input by rememberSaveable { mutableStateOf("") } val focusMgr = LocalFocusManager.current var useUserId by rememberSaveable { mutableStateOf(false) } var dialog by rememberSaveable { mutableStateOf(false) } var menu by remember { mutableStateOf(false) } val legalInput = input.toIntOrNull() != null - val identifiers = remember { mutableStateListOf() } + val identifiers by vm.secondaryUsersState.collectAsState() @Composable fun CreateShortcutIcon(type: UserOperationType) { FilledTonalIconButton({ - if (!createShortcut(type, input.toInt(), useUserId)) - context.showOperationResultToast(false) + vm.createUserOperationShortcut(type, input.toInt(), useUserId) }, enabled = legalInput) { Icon(painterResource(R.drawable.open_in_new), null) } } LaunchedEffect(Unit) { - identifiers.addAll(getUsers()) + if (VERSION.SDK_INT >= 28) vm.getUserIdentifiers() } MyScaffold(R.string.user_operation, onNavigateUp) { if (VERSION.SDK_INT >= 24) SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - SegmentedButton(!useUserId, { useUserId = false }, SegmentedButtonDefaults.itemShape(0, 2)) { + SegmentedButton( + !useUserId, + { + useUserId = false + input = "" + }, + SegmentedButtonDefaults.itemShape(0, 2) + ) { Text(stringResource(R.string.serial_number)) } - SegmentedButton(useUserId, { useUserId = true }, SegmentedButtonDefaults.itemShape(1, 2)) { + SegmentedButton( + useUserId, + { + useUserId = true + input = "" + }, + SegmentedButtonDefaults.itemShape(1, 2) + ) { Text(stringResource(R.string.user_id)) } } @@ -265,12 +265,16 @@ fun UserOperationScreen( .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable) .padding(top = 4.dp, bottom = 8.dp), label = { - Text(stringResource(if(useUserId) R.string.user_id else R.string.serial_number)) + Text( + stringResource(if (useUserId) R.string.user_id else R.string.serial_number) + ) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(menu) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ) ) ExposedDropdownMenu(menu, { menu = false }) { if (identifiers.isEmpty()) { @@ -295,8 +299,7 @@ fun UserOperationScreen( Button( { focusMgr.clearFocus() - val result = doOperation(UserOperationType.Start, input.toInt(), useUserId) - context.showOperationResultToast(result) + vm.doUserOperation(UserOperationType.Start, input.toInt(), useUserId) }, Modifier.weight(1F), legalInput @@ -310,8 +313,7 @@ fun UserOperationScreen( Button( { focusMgr.clearFocus() - val result = doOperation(UserOperationType.Switch, input.toInt(), useUserId) - context.showOperationResultToast(result) + vm.doUserOperation(UserOperationType.Switch, input.toInt(), useUserId) }, Modifier.weight(1F), legalInput @@ -325,8 +327,7 @@ fun UserOperationScreen( Button( { focusMgr.clearFocus() - val result = doOperation(UserOperationType.Stop, input.toInt(), useUserId) - context.showOperationResultToast(result) + vm.doUserOperation(UserOperationType.Stop, input.toInt(), useUserId) }, Modifier.weight(1F), legalInput @@ -337,12 +338,12 @@ fun UserOperationScreen( CreateShortcutIcon(UserOperationType.Stop) } Button( - onClick = { + { focusMgr.clearFocus() dialog = true }, - enabled = legalInput, - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + legalInput ) { Icon(Icons.Default.Delete, null, Modifier.padding(end = 4.dp)) Text(stringResource(R.string.delete)) @@ -354,8 +355,7 @@ fun UserOperationScreen( }, confirmButton = { TextButton({ - val result = doOperation(UserOperationType.Delete, input.toInt(), useUserId) - context.showOperationResultToast(result) + vm.doUserOperation(UserOperationType.Delete, input.toInt(), useUserId) dialog = false }) { Text(stringResource(R.string.confirm)) @@ -368,14 +368,10 @@ fun UserOperationScreen( ) } -data class CreateUserResult(val message: Int, val serial: Long = -1) - -@Serializable object CreateUser - @RequiresApi(24) @Composable fun CreateUserScreen( - createUser: (String, Int, (CreateUserResult) -> Unit) -> Unit, onNavigateUp: () -> Unit + vm: UsersViewModel, onNavigateUp: () -> Unit ) { var result by remember { mutableStateOf(null) } val focusMgr = LocalFocusManager.current @@ -384,7 +380,10 @@ fun CreateUserScreen( var flags by rememberSaveable { mutableIntStateOf(0) } MyScaffold(R.string.create_user, onNavigateUp, 0.dp) { OutlinedTextField( - userName, { userName= it }, Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + userName, { userName = it }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), label = { Text(stringResource(R.string.username)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) @@ -394,7 +393,7 @@ fun CreateUserScreen( R.string.create_user_skip_wizard, flags and DevicePolicyManager.SKIP_SETUP_WIZARD != 0 ) { flags = flags xor DevicePolicyManager.SKIP_SETUP_WIZARD } - if(VERSION.SDK_INT >= 28) { + if (VERSION.SDK_INT >= 28) { FullWidthCheckBoxItem( R.string.create_user_ephemeral_user, flags and DevicePolicyManager.MAKE_USER_EPHEMERAL != 0 @@ -406,15 +405,17 @@ fun CreateUserScreen( } Spacer(Modifier.padding(vertical = 5.dp)) Button( - onClick = { + { focusMgr.clearFocus() creating = true - createUser(userName, flags) { + vm.createUser(userName, flags) { creating = false result = it } }, - modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) ) { Text(stringResource(R.string.create)) } @@ -432,154 +433,105 @@ fun CreateUserScreen( }, onDismissRequest = { result = null } ) - if (creating) CircularProgressDialog { } + if (creating) CircularProgressDialog { } } } -@Serializable object AffiliationId - @RequiresApi(26) @Composable fun AffiliationIdScreen( - affiliationIds: StateFlow>, getIds: () -> Unit, setId: (String, Boolean) -> Unit, - onNavigateUp: () -> Unit + vm: UsersViewModel, onNavigateUp: () -> Unit ) { - val focusMgr = LocalFocusManager.current var input by rememberSaveable { mutableStateOf("") } - val list by affiliationIds.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { getIds() } + val list by vm.affiliationIdsState.collectAsState() + LaunchedEffect(Unit) { + vm.getAffiliationIds() + } MyScaffold(R.string.affiliation_id, onNavigateUp) { - Column(modifier = Modifier.animateContentSize()) { + Column(Modifier.animateContentSize()) { if (list.isEmpty()) Text(stringResource(R.string.none)) for (i in list) { - ListItem(i) { setId(i, false) } + ListItem(i) { vm.setAffiliationId(i, false) } } } OutlinedTextField( - value = input, - onValueChange = { input = it }, + input, { input = it }, + Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), label = { Text("ID") }, trailingIcon = { IconButton( - onClick = { - setId(input, true) + { + vm.setAffiliationId(input, true) input = "" }, enabled = input.isNotEmpty() ) { - Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add)) + Icon(Icons.Default.Add, stringResource(R.string.add)) } }, - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }) + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) + Button(vm::applyAffiliationIds) { + Text(stringResource(R.string.apply)) + } Notes(R.string.info_affiliation_id) } } -@Serializable object ChangeUsername - @Composable -fun ChangeUsernameScreen(setName: (String) -> Unit, onNavigateUp: () -> Unit) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current +fun ChangeUsernameScreen(vm: UsersViewModel, onNavigateUp: () -> Unit) { var inputUsername by rememberSaveable { mutableStateOf("") } MyScaffold(R.string.change_username, onNavigateUp) { OutlinedTextField( - value = inputUsername, - onValueChange = { inputUsername= it }, + inputUsername, { inputUsername = it }, + Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.username)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) Spacer(Modifier.padding(vertical = 5.dp)) Button( - onClick = { - setName(inputUsername) - context.showOperationResultToast(true) + { + vm.setProfileName(inputUsername) }, - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth() ) { Text(stringResource(R.string.apply)) } } } -@Serializable object UserSessionMessage - @RequiresApi(28) @Composable fun UserSessionMessageScreen( - getMessages: () -> Pair, setStartMessage: (String?) -> Unit, - setEndMessage: (String?) -> Unit, onNavigateUp: () -> Unit + vm: UsersViewModel, onNavigateUp: () -> Unit ) { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - var start by rememberSaveable { mutableStateOf("") } - var end by rememberSaveable { mutableStateOf("") } + val startMessage by vm.startSessionMessageState.collectAsState() + val endMessage by vm.endSessionMessageState.collectAsState() LaunchedEffect(Unit) { - val messages = getMessages() - start = messages.first - end = messages.second + vm.getSessionMessages() } MyScaffold(R.string.user_session_msg, onNavigateUp) { OutlinedTextField( - value = start, - onValueChange = { start= it }, + startMessage, vm::setStartSessionMessage, + Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.start_user_session_msg)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp) + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { - setStartMessage(start) - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.apply)) - } - Button( - onClick = { - setStartMessage(null) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.reset)) - } - } - Spacer(Modifier.padding(vertical = 8.dp)) OutlinedTextField( - value = end, - onValueChange = { end= it }, + endMessage, vm::setEndSessionMessage, + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), label = { Text(stringResource(R.string.end_user_session_msg)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp) + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Button( - onClick = { - setStartMessage(end) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(0.49F) - ) { - Text(stringResource(R.string.apply)) - } - Button( - onClick = { - setEndMessage(null) - context.showOperationResultToast(true) - }, - modifier = Modifier.fillMaxWidth(0.96F) - ) { - Text(stringResource(R.string.reset)) - } + Button( + vm::applySessionMessages, + Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.apply)) } } } @@ -589,10 +541,12 @@ private fun ChangeUserIconDialog(bitmap: Bitmap, onSet: () -> Unit, onClose: () AlertDialog( title = { Text(stringResource(R.string.change_user_icon)) }, text = { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Image( - bitmap = bitmap.asImageBitmap(), contentDescription = null, - modifier = Modifier.size(80.dp).clip(RoundedCornerShape(50)) + bitmap.asImageBitmap(), null, + Modifier + .size(80.dp) + .clip(RoundedCornerShape(50)) ) } }, diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/users/UsersViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/users/UsersViewModel.kt new file mode 100644 index 0000000..0c9865e --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/users/UsersViewModel.kt @@ -0,0 +1,188 @@ +package com.bintianqi.owndroid.feature.users + +import android.content.Context +import android.graphics.Bitmap +import android.os.Binder +import android.os.Build.VERSION +import android.os.UserHandle +import android.os.UserManager +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ShortcutUtils +import com.bintianqi.owndroid.utils.ToastChannel +import com.bintianqi.owndroid.utils.doUserOperationWithContext +import com.bintianqi.owndroid.utils.plusOrMinus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class UsersViewModel( + val application: MyApplication, val ph: PrivilegeHelper, val toastChannel: ToastChannel, + val settingsRepo: SettingsRepository, val privilegeState: StateFlow +) : ViewModel() { + val um = application.getSystemService(Context.USER_SERVICE) as UserManager + val logoutEnabledState = MutableStateFlow(false) + + @RequiresApi(28) + fun getLogoutEnabled() = ph.safeDpmCall { + logoutEnabledState.value = dpm.isLogoutEnabled + } + + @RequiresApi(28) + fun setLogoutEnabled(enabled: Boolean) = ph.safeDpmCall { + dpm.setLogoutEnabled(dar, enabled) + getLogoutEnabled() + } + + val userInformationState = MutableStateFlow(UserInformation()) + fun getUserInformation() = ph.safeDpmCall { + val uh = Binder.getCallingUserHandle() + userInformationState.value = UserInformation( + if (VERSION.SDK_INT >= 24) UserManager.supportsMultipleUsers() else false, + if (VERSION.SDK_INT >= 31) UserManager.isHeadlessSystemUserMode() else false, + um.isSystemUser, + if (VERSION.SDK_INT >= 34) um.isAdminUser else false, + if (VERSION.SDK_INT >= 25) um.isDemoUser else false, + um.getUserCreationTime(uh), + if (VERSION.SDK_INT >= 28) dpm.isLogoutEnabled else false, + if (VERSION.SDK_INT >= 28) dpm.isEphemeralUser(dar) else false, + if (VERSION.SDK_INT >= 28) dpm.isAffiliatedUser else false, + um.getSerialNumberForUser(uh) + ) + } + + val secondaryUsersState = MutableStateFlow(emptyList()) + + @Suppress("PrivateApi") + @RequiresApi(28) + fun getUserIdentifiers() = ph.safeDpmCall { + secondaryUsersState.value = 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) = ph.safeDpmCall { + val result = doUserOperationWithContext(application, dpm, dar, type, id, isUserId) + toastChannel.sendStatus(result) + } + + 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, settingsRepo, type, serial.toInt() + ) + } + + fun getUserOperationResultText(code: Int): Int { + return when (code) { + UserManager.USER_OPERATION_SUCCESS -> R.string.success + UserManager.USER_OPERATION_ERROR_UNKNOWN -> R.string.unknown_error + UserManager.USER_OPERATION_ERROR_MANAGED_PROFILE -> R.string.fail_managed_profile + UserManager.USER_OPERATION_ERROR_MAX_RUNNING_USERS -> R.string.limit_reached + UserManager.USER_OPERATION_ERROR_MAX_USERS -> R.string.limit_reached + UserManager.USER_OPERATION_ERROR_CURRENT_USER -> R.string.fail_current_user + else -> R.string.unknown + } + } + + @RequiresApi(24) + fun createUser( + name: String, flags: Int, callback: (CreateUserResult) -> Unit + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + ph.safeDpmCall { + 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 affiliationIdsState = MutableStateFlow(emptyList()) + + @RequiresApi(26) + fun getAffiliationIds() = ph.safeDpmCall { + affiliationIdsState.value = dpm.getAffiliationIds(dar).toList() + } + + fun setAffiliationId(id: String, state: Boolean) { + affiliationIdsState.update { it.plusOrMinus(state, id) } + } + + @RequiresApi(26) + fun applyAffiliationIds() = ph.safeDpmCall { + dpm.setAffiliationIds(dar, affiliationIdsState.value.toSet()) + toastChannel.sendStatus(true) + } + + fun setProfileName(name: String) = ph.safeDpmCall { + dpm.setProfileName(dar, name) + toastChannel.sendStatus(true) + } + + fun setUserIcon(bitmap: Bitmap) = ph.safeDpmCall { + dpm.setUserIcon(dar, bitmap) + toastChannel.sendStatus(true) + } + + val startSessionMessageState = MutableStateFlow("") + val endSessionMessageState = MutableStateFlow("") + + @RequiresApi(28) + fun getSessionMessages() = ph.safeDpmCall { + startSessionMessageState.value = dpm.getStartUserSessionMessage(dar)?.toString() ?: "" + endSessionMessageState.value = dpm.getEndUserSessionMessage(dar)?.toString() ?: "" + } + + fun setStartSessionMessage(message: String) { + startSessionMessageState.value = message + } + + fun setEndSessionMessage(message: String) { + endSessionMessageState.value = message + } + + @RequiresApi(28) + fun applySessionMessages() = ph.safeDpmCall { + dpm.setStartUserSessionMessage(dar, startSessionMessageState.value.ifEmpty { null }) + dpm.setEndUserSessionMessage(dar, endSessionMessageState.value.ifEmpty { null }) + toastChannel.sendStatus(true) + } + + @RequiresApi(28) + fun logout() = ph.safeDpmCall { + val result = dpm.logoutUser(dar) + if (result != 0) toastChannel.sendStatus(false) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileModel.kt new file mode 100644 index 0000000..39f7f66 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileModel.kt @@ -0,0 +1,10 @@ +package com.bintianqi.owndroid.feature.work_profile + +data class CreateWorkProfileOptions( + val skipEncrypt: Boolean, + val offline: Boolean, + val migrateAccount: Boolean, + val accountName: String, + val accountType: String, + val keepAccount: Boolean +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileScreen.kt new file mode 100644 index 0000000..ef21bc7 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileScreen.kt @@ -0,0 +1,90 @@ +package com.bintianqi.owndroid.feature.work_profile + +import android.os.Build.VERSION +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem +import com.bintianqi.owndroid.ui.MyScaffold +import com.bintianqi.owndroid.utils.HorizontalPadding + +@Composable +fun CreateWorkProfileScreen( + vm: CreateWorkProfileViewModel, onNavigateUp: () -> Unit +) { + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } + 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) } + FullWidthCheckBoxItem(R.string.migrate_account, migrateAccount) { migrateAccount = it } + AnimatedVisibility(migrateAccount) { + Column(Modifier.padding(start = 10.dp)) { + OutlinedTextField( + migrateAccountName, { migrateAccountName = it }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + label = { Text(stringResource(R.string.account_name)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + OutlinedTextField( + migrateAccountType, { migrateAccountType = it }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + label = { Text(stringResource(R.string.account_type)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 26) { + FullWidthCheckBoxItem(R.string.keep_account, keepAccount) { keepAccount = 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( + { + val intent = vm.createIntent( + CreateWorkProfileOptions( + skipEncrypt, offlineProvisioning, migrateAccount, migrateAccountName, + migrateAccountType, keepAccount + ) + ) + launcher.launch(intent) + }, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding) + ) { + Text(stringResource(R.string.create)) + } + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileViewModel.kt new file mode 100644 index 0000000..53b50eb --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CreateWorkProfileViewModel.kt @@ -0,0 +1,40 @@ +package com.bintianqi.owndroid.feature.work_profile + +import android.accounts.Account +import android.app.admin.DevicePolicyManager +import android.content.Intent +import android.os.Build.VERSION +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.utils.MyAdminComponent + +class CreateWorkProfileViewModel : ViewModel() { + fun createIntent(options: CreateWorkProfileOptions): Intent { + val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, + MyAdminComponent + ) + if (options.migrateAccount) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_ACCOUNT_TO_MIGRATE, + Account(options.accountName, options.accountType) + ) + if (VERSION.SDK_INT >= 26) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_KEEP_ACCOUNT_ON_MIGRATION, + options.keepAccount + ) + } + } + if (VERSION.SDK_INT >= 24) { + intent.putExtra( + DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, + options.skipEncrypt + ) + } + if (VERSION.SDK_INT >= 33) { + intent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_ALLOW_OFFLINE, options.offline) + } + return intent + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterModel.kt new file mode 100644 index 0000000..e50db92 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterModel.kt @@ -0,0 +1,33 @@ +package com.bintianqi.owndroid.feature.work_profile + +import android.content.Intent +import com.bintianqi.owndroid.R +import kotlinx.serialization.Serializable + +@Serializable +data class IntentFilterOptions( + val action: String, val category: String, val mimeType: String, + val direction: Int // 1: private to work, 2: work to private, 3: both +) + +val directionTextMap = mapOf( + 1 to R.string.personal_to_work, + 2 to R.string.work_to_personal, + 3 to R.string.both_direction +) + +class IntentFilterPreset( + val name: Int, val action: String, val category: String = "", val mimeType: String = "" +) + +val crossProfileIntentFilterPresets = listOf( + IntentFilterPreset(R.string.open_file, Intent.ACTION_VIEW, Intent.CATEGORY_DEFAULT, "*/*"), + IntentFilterPreset(R.string.share, Intent.ACTION_SEND, Intent.CATEGORY_DEFAULT, "*/*"), + IntentFilterPreset(R.string.share_multiple, Intent.ACTION_SEND_MULTIPLE), + IntentFilterPreset(R.string.edit, Intent.ACTION_EDIT, Intent.CATEGORY_DEFAULT, "*/*"), + IntentFilterPreset(R.string.get_content, Intent.ACTION_GET_CONTENT, Intent.CATEGORY_DEFAULT, "*/*"), + IntentFilterPreset(R.string.install_app, Intent.ACTION_INSTALL_PACKAGE), + IntentFilterPreset(R.string.uninstall_app, Intent.ACTION_UNINSTALL_PACKAGE), + IntentFilterPreset(R.string.choose_file, Intent.ACTION_OPEN_DOCUMENT, Intent.CATEGORY_DEFAULT, "*/*"), + IntentFilterPreset(R.string.choose_folder, Intent.ACTION_OPEN_DOCUMENT_TREE) +) diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterRepository.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterRepository.kt new file mode 100644 index 0000000..3c6ed87 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterRepository.kt @@ -0,0 +1,33 @@ +package com.bintianqi.owndroid.feature.work_profile + +import android.content.ContentValues +import com.bintianqi.owndroid.MyDbHelper + +class CrossProfileIntentFilterRepository(val dbHelper: MyDbHelper) { + fun setCrossProfileIntentFilter(data: IntentFilterOptions) { + val cv = ContentValues() + cv.put("action_str", data.action) + cv.put("category", data.category) + cv.put("mime_type", data.mimeType) + cv.put("direction", data.direction) + dbHelper.writableDatabase.insert("cross_profile_intent_filters", null, cv) + } + + fun getAllCrossProfileIntentFilters(): List { + val list = mutableListOf() + dbHelper.readableDatabase.rawQuery( + "SELECT * FROM cross_profile_intent_filters", null + ).use { + while (it.moveToNext()) { + list += IntentFilterOptions( + it.getString(0), it.getString(1), it.getString(2), it.getInt(3) + ) + } + } + return list + } + + fun deleteAllCrossProfileIntentFilters() { + dbHelper.writableDatabase.delete("cross_profile_intent_filters", null, null) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterScreen.kt new file mode 100644 index 0000000..5bc38e8 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterScreen.kt @@ -0,0 +1,285 @@ +package com.bintianqi.owndroid.feature.work_profile + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.draw.alpha +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 com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.MyLazyScaffold +import com.bintianqi.owndroid.ui.NavIcon +import com.bintianqi.owndroid.ui.Notes +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.adaptiveInsets + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrossProfileIntentFilterScreen( + vm: CrossProfileIntentFilterViewModel, onNavigateUp: () -> Unit, navigateToPresets: () -> Unit +) { + 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 { mutableIntStateOf(3) } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { + if (it != null) vm.importFilters(it) + } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) vm.exportFilters(it) + } + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.intent_filter)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + actions = { + var menu by remember { mutableStateOf(false) } + Box { + IconButton({ menu = !menu }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(menu, { menu = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.presets)) }, + { + navigateToPresets() + menu = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.list_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.import_str)) }, + { + importLauncher.launch(arrayOf("application/json")) + menu = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_open_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.export)) }, + { + exportLauncher.launch("owndroid_intent_filters") + menu = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_export_fill0), null) + } + ) + } + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .padding(horizontal = HorizontalPadding) + .verticalScroll(rememberScrollState()) + ) { + OutlinedTextField( + value = action, onValueChange = { action = it }, + label = { Text("Action") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusMgr.clearFocus() }), + modifier = Modifier.fillMaxWidth() + ) + 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(directionTextMap[direction]!!), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + label = { Text(stringResource(R.string.direction)) }, readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + directionTextMap.forEach { + DropdownMenuItem({ Text(stringResource(it.value)) }, { + direction = it.key + dropdown = false + }) + } + } + } + Button( + { + vm.addFilter(IntentFilterOptions(action, category, mimeType, direction)) + }, + Modifier.fillMaxWidth(), + enabled = action.isNotBlank() && (!customCategory || category.isNotBlank()) && + (!customMimeType || mimeType.isNotBlank()) + ) { + Text(stringResource(R.string.add)) + } + Button( + { + vm.clearFilters() + }, + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Text(stringResource(R.string.clear_cross_profile_filters)) + } + Notes(R.string.info_cross_profile_intent_filter) + Spacer(Modifier.height(BottomPadding)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrossProfileIntentFilterPresetsScreen( + vm: CrossProfileIntentFilterViewModel, navigateUp: () -> Unit +) { + var dialog by remember { mutableStateOf(null) } + MyLazyScaffold(R.string.presets, navigateUp) { + items(crossProfileIntentFilterPresets) { + Row( + Modifier.padding(start = HorizontalPadding, end = 8.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(Modifier.weight(1F)) { + Text(stringResource(it.name)) + Text( + it.action, + Modifier.alpha(0.7F), + style = MaterialTheme.typography.bodyMedium + ) + } + IconButton({ dialog = it }) { + Icon(Icons.Default.Add, null) + } + } + } + } + if (dialog != null) { + var direction by remember { mutableIntStateOf(3) } + AlertDialog( + title = { + Text(stringResource(dialog!!.name)) + }, + text = { + Column { + var dropdown by remember { mutableStateOf(false) } + Text(dialog!!.action) + ExposedDropdownMenuBox( + dropdown, { dropdown = it }, Modifier.padding(top = 5.dp) + ) { + OutlinedTextField( + stringResource(directionTextMap[direction]!!), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyLarge, + label = { Text(stringResource(R.string.direction)) }, readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + directionTextMap.forEach { + DropdownMenuItem({ Text(stringResource(it.value)) }, { + direction = it.key + dropdown = false + }) + } + } + } + } + }, + confirmButton = { + TextButton({ + vm.addPreset(dialog!!, direction) + dialog = null + }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton({ dialog = null }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { dialog = null } + ) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterViewModel.kt new file mode 100644 index 0000000..bf4e0ed --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/CrossProfileIntentFilterViewModel.kt @@ -0,0 +1,57 @@ +package com.bintianqi.owndroid.feature.work_profile + +import android.content.IntentFilter +import android.net.Uri +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.serialization.json.Json + +class CrossProfileIntentFilterViewModel( + val application: MyApplication, val ph: PrivilegeHelper, + val repo: CrossProfileIntentFilterRepository, val toastChannel: ToastChannel +) : ViewModel() { + private fun addFilterInternal(options: IntentFilterOptions) = ph.safeDpmCall { + val filter = IntentFilter(options.action) + if (options.category.isNotEmpty()) filter.addCategory(options.category) + if (options.mimeType.isNotEmpty()) filter.addDataType(options.mimeType) + dpm.addCrossProfileIntentFilter(dar, filter, options.direction) + repo.setCrossProfileIntentFilter(options) + } + + fun addFilter(options: IntentFilterOptions) { + addFilterInternal(options) + toastChannel.sendStatus(true) + } + + fun addPreset(preset: IntentFilterPreset, direction: Int) { + addFilter(IntentFilterOptions(preset.action, preset.category, preset.mimeType, direction)) + } + + fun clearFilters() = ph.safeDpmCall { + dpm.clearCrossProfileIntentFilters(dar) + repo.deleteAllCrossProfileIntentFilters() + toastChannel.sendStatus(true) + } + + fun importFilters(uri: Uri) = ph.safeDpmCall { + val bytes = application.contentResolver.openInputStream(uri)!!.use { + it.readBytes().decodeToString() + } + val data = Json.decodeFromString>(bytes) + data.forEach { + addFilterInternal(it) + } + toastChannel.sendStatus(true) + } + + fun exportFilters(uri: Uri) { + val data = repo.getAllCrossProfileIntentFilters() + val bytes = Json.encodeToString(data).encodeToByteArray() + application.contentResolver.openOutputStream(uri)!!.use { + it.write(bytes) + } + toastChannel.sendStatus(true) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/WorkProfileScreen.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/WorkProfileScreen.kt new file mode 100644 index 0000000..ad6ed2c --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/WorkProfileScreen.kt @@ -0,0 +1,194 @@ +package com.bintianqi.owndroid.feature.work_profile + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyManager.WIPE_EUICC +import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.CheckBoxItem +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.ui.navigation.Destination +import com.bintianqi.owndroid.utils.HorizontalPadding +import com.bintianqi.owndroid.utils.yesOrNo +import kotlinx.coroutines.delay + +@Composable +fun WorkProfileScreen( + vm: WorkProfileViewModel, onNavigateUp: () -> Unit, onNavigate: (Destination) -> Unit +) { + val privilege by vm.privilegeState.collectAsStateWithLifecycle() + MyScaffold(R.string.work_profile, onNavigateUp, 0.dp) { + if (privilege.org) { + FunctionItem(R.string.suspend_personal_app, icon = R.drawable.block_fill0) { + onNavigate(Destination.SuspendPersonalApp) + } + } + FunctionItem(R.string.intent_filter, icon = R.drawable.filter_alt_fill0) { + onNavigate(Destination.CrossProfileIntentFilter) + } + FunctionItem(R.string.delete_work_profile, icon = R.drawable.delete_forever_fill0) { + onNavigate(Destination.DeleteWorkProfile) + } + } +} + +@RequiresApi(30) +@Composable +fun SuspendPersonalAppScreen( + vm: WorkProfileViewModel, onNavigateUp: () -> Unit +) { + val reason by vm.personalAppSuspendedState.collectAsState() + val time by vm.profileMaxTimeOffState.collectAsState() + LaunchedEffect(Unit) { + vm.getPersonalAppsSuspendedReason() + vm.getProfileMaxTimeOff() + } + MyScaffold(R.string.suspend_personal_app, onNavigateUp, 0.dp) { + SwitchItem( + R.string.suspend_personal_app, reason != 0, vm::setPersonalAppsSuspended + ) + HorizontalDivider() + Spacer(Modifier.padding(HorizontalPadding, 10.dp)) + Text( + stringResource(R.string.profile_max_time_off), + Modifier.padding(horizontal = HorizontalPadding), style = typography.titleLarge + ) + Text( + stringResource(R.string.profile_max_time_out_desc), + Modifier.padding(horizontal = HorizontalPadding) + ) + Text( + stringResource( + R.string.personal_app_suspended_because_timeout, + stringResource( + (reason == DevicePolicyManager.PERSONAL_APPS_SUSPENDED_PROFILE_TIMEOUT).yesOrNo + ) + ), + Modifier.padding(horizontal = HorizontalPadding) + ) + OutlinedTextField( + time, vm::setProfileMaxTimeOff, + Modifier + .fillMaxWidth() + .padding(HorizontalPadding, 2.dp), + label = { Text(stringResource(R.string.time_unit_ms)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + Button( + vm::applyProfileMaxTimeOff, + Modifier + .fillMaxWidth() + .padding(horizontal = HorizontalPadding), + time.toLongOrNull() != null + ) { + Text(stringResource(R.string.apply)) + } + Notes(R.string.info_profile_maximum_time_off, HorizontalPadding) + } +} + +@Composable +fun DeleteWorkProfileScreen( + vm: WorkProfileViewModel, onNavigateUp: () -> Unit +) { + var flags by remember { mutableIntStateOf(0) } + var warning by remember { mutableStateOf(false) } + var reason by remember { mutableStateOf("") } + MyScaffold(R.string.delete_work_profile, onNavigateUp) { + 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( + reason, { reason = it }, + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + label = { Text(stringResource(R.string.reason)) }, + enabled = flags and DevicePolicyManager.WIPE_SILENTLY == 0 + ) + Spacer(Modifier.padding(vertical = 5.dp)) + Button( + { + warning = true + }, + Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.error, contentColor = colorScheme.onError + ) + ) { + Text(stringResource(R.string.delete)) + } + } + if (warning) { + AlertDialog( + title = { + Text(stringResource(R.string.warning)) + }, + text = { + Text(stringResource(R.string.wipe_work_profile_warning)) + }, + onDismissRequest = { warning = false }, + confirmButton = { + var timer by remember { mutableIntStateOf(3) } + LaunchedEffect(Unit) { + repeat(3) { + delay(1000) + timer -= 1 + } + } + val timerText = if (timer > 0) " (${timer}s)" else "" + TextButton( + { + vm.deleteProfile(flags, reason) + }, + enabled = timer == 0, + colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.error) + ) { + Text(stringResource(R.string.confirm) + timerText) + } + }, + dismissButton = { + TextButton({ warning = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/WorkProfileViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/WorkProfileViewModel.kt new file mode 100644 index 0000000..4609481 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/feature/work_profile/WorkProfileViewModel.kt @@ -0,0 +1,53 @@ +package com.bintianqi.owndroid.feature.work_profile + +import android.os.Build.VERSION +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.ToastChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class WorkProfileViewModel( + val ph: PrivilegeHelper, val privilegeState: StateFlow, + val toastChannel: ToastChannel +) : ViewModel() { + val personalAppSuspendedState = MutableStateFlow(0) + + @RequiresApi(30) + fun getPersonalAppsSuspendedReason() = ph.safeDpmCall { + personalAppSuspendedState.value = dpm.getPersonalAppsSuspendedReasons(dar) + } + + @RequiresApi(30) + fun setPersonalAppsSuspended(suspended: Boolean) = ph.safeDpmCall { + dpm.setPersonalAppsSuspended(dar, suspended) + getPersonalAppsSuspendedReason() + } + + val profileMaxTimeOffState = MutableStateFlow("") + + @RequiresApi(30) + fun getProfileMaxTimeOff() = ph.safeDpmCall { + profileMaxTimeOffState.value = dpm.getManagedProfileMaximumTimeOff(dar).toString() + } + + fun setProfileMaxTimeOff(input: String) { + profileMaxTimeOffState.value = input + } + + @RequiresApi(30) + fun applyProfileMaxTimeOff() = ph.safeDpmCall { + dpm.setManagedProfileMaximumTimeOff(dar, profileMaxTimeOffState.value.toLong()) + toastChannel.sendStatus(true) + } + + fun deleteProfile(flags: Int, reason: String) = ph.safeDpmCall { + if (VERSION.SDK_INT >= 28 && reason.isNotEmpty()) { + dpm.wipeData(flags, reason) + } else { + dpm.wipeData(flags) + } + } +} 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 d0cb905..4f59a57 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/Components.kt @@ -17,9 +17,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope 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 import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -31,6 +34,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar 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.Switch @@ -48,15 +52,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.bintianqi.owndroid.HorizontalPadding +import com.bintianqi.owndroid.utils.HorizontalPadding import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.adaptiveInsets -import com.bintianqi.owndroid.zhCN +import com.bintianqi.owndroid.utils.adaptiveInsets +import com.bintianqi.owndroid.utils.isValidPackageName +import com.bintianqi.owndroid.utils.parsePackageNames @Composable fun FunctionItem( @@ -70,20 +78,20 @@ fun FunctionItem( .fillMaxWidth() .clickable(onClick = operation) .padding(start = 25.dp, end = 15.dp) - .padding(vertical = 12.dp + (if(desc != "") 0 else 3).dp), + .padding(vertical = 12.dp + (if (desc != "") 0 else 3).dp), verticalAlignment = Alignment.CenterVertically ) { - if(icon != null) Icon( - painter = painterResource(icon), contentDescription = null, - modifier = Modifier.padding(top = 1.dp, end = 20.dp).offset(x = (-2).dp) + if (icon != null) Icon( + painterResource(icon), null, + Modifier + .padding(top = 1.dp, end = 20.dp) + .offset(x = (-2).dp) ) Column { - Text( - text = stringResource(title), - style = typography.titleLarge, - modifier = Modifier.padding(bottom = if(zhCN) 2.dp else 0.dp) - ) - if(desc != null) { Text(text = desc, color = colorScheme.onBackground.copy(alpha = 0.8F)) } + Text(stringResource(title), style = typography.titleLarge) + if (desc != null) { + Text(desc, color = colorScheme.onBackground.copy(alpha = 0.8F)) + } } } } @@ -110,13 +118,14 @@ fun RadioButtonItem( selected: Boolean, operation: () -> Unit ) { - Row(verticalAlignment = Alignment.CenterVertically,modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(25)) - .clickable(onClick = operation) + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(25)) + .clickable(onClick = operation) ) { RadioButton(selected = selected, onClick = operation) - Text(text = text, modifier = Modifier.padding(bottom = if(zhCN) { 2 } else { 0 }.dp)) + Text(text) } } @@ -134,11 +143,15 @@ fun FullWidthRadioButtonItem( operation: () -> Unit ) { Row( + Modifier + .fillMaxWidth() + .clickable(onClick = operation), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().clickable(onClick = operation) ) { - RadioButton(selected = selected, onClick = operation, modifier = Modifier.padding(horizontal = 4.dp)) - Text(text = text, modifier = Modifier.padding(bottom = if(zhCN) { 2 } else { 0 }.dp)) + RadioButton( + selected, operation, Modifier.padding(horizontal = 4.dp) + ) + Text(text) } } @@ -148,16 +161,15 @@ fun CheckBoxItem( checked: Boolean, operation: (Boolean) -> Unit ) { - Row(verticalAlignment = Alignment.CenterVertically,modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(25)) - .clickable { operation(!checked) } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(25)) + .clickable { operation(!checked) }, + verticalAlignment = Alignment.CenterVertically, ) { - Checkbox( - checked = checked, - onCheckedChange = operation - ) - Text(text = stringResource(text), modifier = Modifier.padding(bottom = if(zhCN) { 2 } else { 0 }.dp)) + Checkbox(checked, operation) + Text(stringResource(text)) } } @@ -168,11 +180,16 @@ fun FullWidthCheckBoxItem( operation: (Boolean) -> Unit ) { Row( + Modifier + .fillMaxWidth() + .clickable { operation(!checked) }, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().clickable { operation(!checked) } ) { - Checkbox(checked = checked, onCheckedChange = operation, modifier = Modifier.padding(horizontal = 4.dp)) - Text(text = stringResource(text), modifier = Modifier.padding(bottom = if(zhCN) { 2 } else { 0 }.dp)) + Checkbox( + checked, operation, + Modifier.padding(horizontal = 4.dp) + ) + Text(stringResource(text)) } } @@ -182,13 +199,16 @@ fun SwitchItem( desc: String? = null, @DrawableRes icon: Int? = null, getState: () -> Boolean, - onCheckedChange: (Boolean)->Unit, + onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true, onClickBlank: (() -> Unit)? = null, padding: Boolean = true ) { var state by remember { mutableStateOf(getState()) } - SwitchItem(title, desc, icon, state, { onCheckedChange(it); state = getState() }, enabled, onClickBlank, padding) + SwitchItem( + title, desc, icon, state, { onCheckedChange(it); state = getState() }, enabled, + onClickBlank, padding + ) } @Composable @@ -205,21 +225,28 @@ fun SwitchItem( Box( modifier = Modifier .fillMaxWidth() - .clickable(enabled = onClickBlank != null, onClick = onClickBlank?:{}) - .padding(start = if(padding) 25.dp else 0.dp, end = if(padding) 15.dp else 0.dp, top = 5.dp, bottom = 5.dp) + .clickable(enabled = onClickBlank != null, onClick = onClickBlank ?: {}) + .padding( + start = if (padding) 25.dp else 0.dp, end = if (padding) 15.dp else 0.dp, + top = 5.dp, bottom = 5.dp + ) ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.CenterStart) ) { - if(icon != null) Icon( + if (icon != null) Icon( painter = painterResource(icon), contentDescription = null, - modifier = Modifier.padding(end = 20.dp).offset(x = (-2).dp) + modifier = Modifier + .padding(end = 20.dp) + .offset(x = (-2).dp) ) - Column(modifier = Modifier.padding(end = 60.dp, bottom = if(zhCN) 2.dp else 0.dp)) { + Column(modifier = Modifier.padding(end = 60.dp)) { Text(text = stringResource(title), style = typography.titleLarge) - if(desc != null) Text(text = desc, color = colorScheme.onBackground.copy(alpha = 0.8F)) + if (desc != null) Text( + text = desc, color = colorScheme.onBackground.copy(alpha = 0.8F) + ) } } Switch( @@ -234,7 +261,9 @@ fun SwitchItem( title: Int, state: Boolean, onCheckedChange: (Boolean) -> Unit, icon: Int? = null ) { Row( - Modifier.fillMaxWidth().padding(25.dp, 5.dp, 15.dp, 5.dp), + Modifier + .fillMaxWidth() + .padding(25.dp, 5.dp, 15.dp, 5.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { @@ -252,31 +281,34 @@ fun InfoItem(title: Int, text: Int, withInfo: Boolean = false, onClick: () -> Un @Composable fun InfoItem(title: Int, text: String, withInfo: Boolean = false, onClick: () -> Unit = {}) { Row( - Modifier.fillMaxWidth().padding(vertical = 6.dp).padding(start = HorizontalPadding, end = 8.dp), + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .padding(start = HorizontalPadding, end = 8.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Column(Modifier.weight(1F)) { Text(stringResource(title), style = typography.titleLarge) Text(text, Modifier.alpha(0.8F)) } - if(withInfo) IconButton(onClick) { Icon(Icons.Outlined.Info, null) } + if (withInfo) IconButton(onClick) { Icon(Icons.Outlined.Info, null) } } } @Composable fun ListItem(text: String, onDelete: () -> Unit) { Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clip(RoundedCornerShape(15)).background(colorScheme.surfaceVariant) + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(15)) + .background(colorScheme.surfaceVariant), + Arrangement.SpaceBetween, Alignment.CenterVertically ) { - Text(text = text, modifier = Modifier.padding(start = 12.dp)) - IconButton( - onClick = onDelete - ) { + Text(text, Modifier.padding(start = 12.dp)) + IconButton(onDelete) { Icon( - painter = painterResource(R.drawable.close_fill0), - contentDescription = stringResource(R.string.delete) + painterResource(R.drawable.close_fill0), stringResource(R.string.delete) ) } } @@ -284,7 +316,13 @@ fun ListItem(text: String, onDelete: () -> Unit) { @Composable fun Notes(@StringRes strID: Int, horizonPadding: Dp = 0.dp) { - Icon(Icons.Outlined.Info, null, Modifier.padding(horizontal = horizonPadding).padding(top = 4.dp, bottom = 8.dp), colorScheme.onSurfaceVariant) + Icon( + Icons.Outlined.Info, null, + Modifier + .padding(horizontal = horizonPadding) + .padding(top = 4.dp, bottom = 8.dp), + colorScheme.onSurfaceVariant + ) Text( stringResource(strID), Modifier.padding(horizontal = horizonPadding), color = colorScheme.onSurfaceVariant, style = typography.bodyMedium @@ -343,7 +381,12 @@ fun MyLazyScaffold( }, contentWindowInsets = adaptiveInsets() ) { paddingValues -> - LazyColumn(Modifier.fillMaxSize().padding(paddingValues), content = content) + LazyColumn( + Modifier + .fillMaxSize() + .padding(paddingValues), + content = content + ) } } @@ -380,7 +423,7 @@ fun MySmallTitleScaffold( @Composable fun ErrorDialog(message: String?, onDismiss: () -> Unit) { - if(!message.isNullOrEmpty()) AlertDialog( + if (!message.isNullOrEmpty()) AlertDialog( title = { Text(stringResource(R.string.error)) }, text = { Text(message) }, confirmButton = { @@ -398,3 +441,28 @@ fun CircularProgressDialog(onDismiss: () -> Unit) { } } } + +@Composable +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(onChoosePackage) { + Icon(Icons.AutoMirrored.Default.List, null) + } + }, + isError = !parsePackageNames(value).all { it.isValidPackageName }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { fm.clearFocus() } + ) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt b/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt index d9dd4df..e4d6329 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/NavTransition.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith /** * Learned from AOSP's Activity animation @@ -42,4 +43,7 @@ object NavTransition { ) + fadeOut( tween(83, 35, LinearEasing) ) + + val transition = enterTransition togetherWith exitTransition + val popTransition = popEnterTransition togetherWith popExitTransition } diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/navigation/Destination.kt b/app/src/main/java/com/bintianqi/owndroid/ui/navigation/Destination.kt new file mode 100644 index 0000000..df08f20 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/navigation/Destination.kt @@ -0,0 +1,124 @@ +package com.bintianqi.owndroid.ui.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +sealed class Destination : NavKey { + override fun toString(): String { + return this::class.simpleName!! + } + + @Serializable object Home : Destination() + + @Serializable class WorkingModes(val canNavigateUp: Boolean) : Destination() + @Serializable object DhizukuServerSettings : Destination() + @Serializable object DelegatedAdmins : Destination() + @Serializable object DelegatedAdminDetails : Destination() + + @Serializable object TransferOwnership : Destination() + + @Serializable object System : Destination() + @Serializable object SystemOptions : Destination() + @Serializable object Keyguard : Destination() + @Serializable object HardwareMonitor : Destination() + @Serializable object DefaultInputMethod : Destination() + @Serializable object Time : Destination() + @Serializable object ContentProtectionPolicy : Destination() + @Serializable object PermissionPolicy : Destination() + @Serializable object MtePolicy : Destination() + @Serializable object NearbyStreamingPolicy : Destination() + @Serializable object LockTaskMode : Destination() + @Serializable object CaCert : Destination() + @Serializable object SecurityLogging : Destination() + @Serializable object DeviceInfo : Destination() + @Serializable object LockScreenInfo : Destination() + @Serializable object SupportMessage : Destination() + @Serializable object DisableAccountManagement : Destination() + @Serializable object FrpPolicy : Destination() + @Serializable object WipeData : Destination() + @Serializable object SystemUpdatePolicy : Destination() + @Serializable object InstallSystemUpdate : Destination() + + @Serializable object Network : Destination() + @Serializable object NetworkOptions : Destination() + @Serializable object WiFi : Destination() + @Serializable class UpdateNetwork(val index: Int) : Destination() + @Serializable object WifiSecurityLevel : Destination() + @Serializable object WifiSsidPolicy : Destination() + @Serializable object NetworkStats : Destination() + @Serializable object NetworkStatsViewer : Destination() + @Serializable object PrivateDns : Destination() + @Serializable object AlwaysOnVpnPackage : Destination() + @Serializable object RecommendedGlobalProxy : Destination() + @Serializable object NetworkLogging : Destination() + @Serializable object PreferentialNetworkService : Destination() + @Serializable object AddPreferentialNetworkServiceConfig : Destination() + @Serializable object OverrideApn : Destination() + @Serializable object AddApnSetting : Destination() + + @Serializable object WorkProfile : Destination() + @Serializable object CreateWorkProfile : Destination() + @Serializable object SuspendPersonalApp : Destination() + @Serializable object CrossProfileIntentFilter : Destination() + @Serializable object CrossProfileIntentFilterPresets: Destination() + @Serializable object DeleteWorkProfile : Destination() + + @Serializable object ApplicationFeatures : Destination() + @Serializable object Suspend : Destination() + @Serializable object Hide : Destination() + @Serializable object BlockUninstall : Destination() + @Serializable object DisableUserControl : Destination() + @Serializable object PermissionManager : Destination() + @Serializable class PermissionDetail(val permission: String) : Destination() + @Serializable object DisableMeteredData : Destination() + @Serializable object ClearAppStorage : Destination() + @Serializable object UninstallApp : Destination() + @Serializable object KeepUninstalledPackages : Destination() + @Serializable object InstallExistingApp : Destination() + @Serializable object CrossProfilePackages : Destination() + @Serializable object CrossProfileWidgetProviders : Destination() + @Serializable object CredentialManagerPolicy : Destination() + @Serializable object PermittedAccessibilityServices : Destination() + @Serializable object PermittedInputMethods : Destination() + @Serializable object EnableSystemApp : Destination() + @Serializable object SetDefaultDialer : Destination() + @Serializable object AppGroups : Destination() + @Serializable object EditAppGroup : Destination() + + @Serializable class ApplicationDetails(val packageName: String) : Destination() + @Serializable class AppPermissionsManager(val packageName: String) : Destination() + @Serializable class ManagedConfiguration(val packageName: String) : Destination() + + @Serializable data class ApplicationsList( + val canSwitchView: Boolean, val multiSelect: Boolean + ) : Destination() + + @Serializable object UserRestriction : Destination() + @Serializable data class UserRestrictionOptions(val id: String) : Destination() + @Serializable object UserRestrictionEditor : Destination() + + @Serializable object Users : Destination() + @Serializable object UsersOptions : Destination() + @Serializable object UserInfo : Destination() + @Serializable object UserOperation : Destination() + @Serializable object CreateUser : Destination() + @Serializable object AffiliationId : Destination() + @Serializable object ChangeUsername : Destination() + @Serializable object UserSessionMessage : Destination() + + @Serializable object Password : Destination() + @Serializable object PasswordInfo : Destination() + @Serializable object ResetPasswordToken : Destination() + @Serializable object ResetPassword : Destination() + @Serializable object RequiredPasswordComplexity : Destination() + @Serializable object KeyguardDisabledFeatures : Destination() + @Serializable object RequiredPasswordQuality : Destination() + + @Serializable object Settings : Destination() + @Serializable object SettingsOptions : Destination() + @Serializable object AppearanceSettings : Destination() + @Serializable object AppLockSettings : Destination() + @Serializable object ApiSettings : Destination() + @Serializable object NotificationSettings : Destination() + @Serializable object About : Destination() +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/navigation/EntryProvider.kt b/app/src/main/java/com/bintianqi/owndroid/ui/navigation/EntryProvider.kt new file mode 100644 index 0000000..9e2d614 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/navigation/EntryProvider.kt @@ -0,0 +1,734 @@ +package com.bintianqi.owndroid.ui.navigation + +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import com.bintianqi.owndroid.AppContainer +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.applications.AppChooserScreen +import com.bintianqi.owndroid.feature.applications.AppChooserViewModel +import com.bintianqi.owndroid.feature.applications.AppDetailsViewModel +import com.bintianqi.owndroid.feature.applications.AppFeaturesViewModel +import com.bintianqi.owndroid.feature.applications.AppGroupsScreen +import com.bintianqi.owndroid.feature.applications.AppPermissionsManagerScreen +import com.bintianqi.owndroid.feature.applications.ApplicationDetailsScreen +import com.bintianqi.owndroid.feature.applications.ApplicationsFeaturesScreen +import com.bintianqi.owndroid.feature.applications.ClearAppStorageScreen +import com.bintianqi.owndroid.feature.applications.CredentialManagerPolicyScreen +import com.bintianqi.owndroid.feature.applications.EditAppGroupScreen +import com.bintianqi.owndroid.feature.applications.EnableSystemAppScreen +import com.bintianqi.owndroid.feature.applications.InstallExistingAppScreen +import com.bintianqi.owndroid.feature.applications.ManagedConfigurationScreen +import com.bintianqi.owndroid.feature.applications.ManagedConfigurationViewModel +import com.bintianqi.owndroid.feature.applications.PackageFunctionScreen +import com.bintianqi.owndroid.feature.applications.PermissionDetailScreen +import com.bintianqi.owndroid.feature.applications.PermissionManagerScreen +import com.bintianqi.owndroid.feature.applications.PermittedAsAndImPackagesScreen +import com.bintianqi.owndroid.feature.applications.SetDefaultDialerScreen +import com.bintianqi.owndroid.feature.applications.UninstallAppScreen +import com.bintianqi.owndroid.feature.network.AddApnSettingScreen +import com.bintianqi.owndroid.feature.network.AddPreferentialNetworkServiceConfigScreen +import com.bintianqi.owndroid.feature.network.AlwaysOnVpnPackageScreen +import com.bintianqi.owndroid.feature.network.NetworkLoggingScreen +import com.bintianqi.owndroid.feature.network.NetworkOptionsScreen +import com.bintianqi.owndroid.feature.network.NetworkScreen +import com.bintianqi.owndroid.feature.network.NetworkStatsScreen +import com.bintianqi.owndroid.feature.network.NetworkStatsViewerScreen +import com.bintianqi.owndroid.feature.network.OverrideApnScreen +import com.bintianqi.owndroid.feature.network.PreferentialNetworkServiceScreen +import com.bintianqi.owndroid.feature.network.PrivateDnsScreen +import com.bintianqi.owndroid.feature.network.RecommendedGlobalProxyScreen +import com.bintianqi.owndroid.feature.network.UpdateNetworkScreen +import com.bintianqi.owndroid.feature.network.WifiScreen +import com.bintianqi.owndroid.feature.network.WifiSecurityLevelScreen +import com.bintianqi.owndroid.feature.network.WifiSsidPolicyScreen +import com.bintianqi.owndroid.feature.password.KeyguardDisabledFeaturesScreen +import com.bintianqi.owndroid.feature.password.PasswordInfoScreen +import com.bintianqi.owndroid.feature.password.PasswordScreen +import com.bintianqi.owndroid.feature.password.RequiredPasswordComplexityScreen +import com.bintianqi.owndroid.feature.password.RequiredPasswordQualityScreen +import com.bintianqi.owndroid.feature.password.ResetPasswordScreen +import com.bintianqi.owndroid.feature.password.ResetPasswordTokenScreen +import com.bintianqi.owndroid.feature.privilege.AddDelegatedAdminScreen +import com.bintianqi.owndroid.feature.privilege.DelegatedAdminsScreen +import com.bintianqi.owndroid.feature.privilege.DhizukuServerSettingsScreen +import com.bintianqi.owndroid.feature.privilege.TransferOwnershipScreen +import com.bintianqi.owndroid.feature.privilege.WorkModesScreen +import com.bintianqi.owndroid.feature.settings.AboutScreen +import com.bintianqi.owndroid.feature.settings.ApiSettings +import com.bintianqi.owndroid.feature.settings.AppLockSettingsScreen +import com.bintianqi.owndroid.feature.settings.AppearanceScreen +import com.bintianqi.owndroid.feature.settings.NotificationsScreen +import com.bintianqi.owndroid.feature.settings.SettingsOptionsScreen +import com.bintianqi.owndroid.feature.settings.SettingsScreen +import com.bintianqi.owndroid.feature.system.CaCertScreen +import com.bintianqi.owndroid.feature.system.ContentProtectionPolicyScreen +import com.bintianqi.owndroid.feature.system.DefaultInputMethodScreen +import com.bintianqi.owndroid.feature.system.DeviceInfoScreen +import com.bintianqi.owndroid.feature.system.DisableAccountManagementScreen +import com.bintianqi.owndroid.feature.system.FrpPolicyScreen +import com.bintianqi.owndroid.feature.system.HardwareMonitorScreen +import com.bintianqi.owndroid.feature.system.KeyguardScreen +import com.bintianqi.owndroid.feature.system.LockScreenInfoScreen +import com.bintianqi.owndroid.feature.system.LockTaskModeScreen +import com.bintianqi.owndroid.feature.system.MtePolicyScreen +import com.bintianqi.owndroid.feature.system.NearbyStreamingPolicyScreen +import com.bintianqi.owndroid.feature.system.PermissionPolicyScreen +import com.bintianqi.owndroid.feature.system.SecurityLoggingScreen +import com.bintianqi.owndroid.feature.system.SupportMessageScreen +import com.bintianqi.owndroid.feature.system.SystemOptionsScreen +import com.bintianqi.owndroid.feature.system.SystemScreen +import com.bintianqi.owndroid.feature.system.SystemUpdateScreen +import com.bintianqi.owndroid.feature.system.TimeScreen +import com.bintianqi.owndroid.feature.system.WipeDataScreen +import com.bintianqi.owndroid.feature.user_restriction.UserRestrictionEditorScreen +import com.bintianqi.owndroid.feature.user_restriction.UserRestrictionOptionsScreen +import com.bintianqi.owndroid.feature.user_restriction.UserRestrictionScreen +import com.bintianqi.owndroid.feature.users.AffiliationIdScreen +import com.bintianqi.owndroid.feature.users.ChangeUsernameScreen +import com.bintianqi.owndroid.feature.users.CreateUserScreen +import com.bintianqi.owndroid.feature.users.UserInfoScreen +import com.bintianqi.owndroid.feature.users.UserOperationScreen +import com.bintianqi.owndroid.feature.users.UserSessionMessageScreen +import com.bintianqi.owndroid.feature.users.UsersOptionsScreen +import com.bintianqi.owndroid.feature.users.UsersScreen +import com.bintianqi.owndroid.feature.work_profile.CreateWorkProfileScreen +import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterPresetsScreen +import com.bintianqi.owndroid.feature.work_profile.CrossProfileIntentFilterScreen +import com.bintianqi.owndroid.feature.work_profile.DeleteWorkProfileScreen +import com.bintianqi.owndroid.feature.work_profile.SuspendPersonalAppScreen +import com.bintianqi.owndroid.feature.work_profile.WorkProfileScreen +import com.bintianqi.owndroid.ui.screen.HomeScreen +import com.bintianqi.owndroid.utils.viewModelFactory + +fun myEntryProvider( + destination: Destination, backstack: NavBackStack, appChooserVm: AppChooserViewModel, + container: AppContainer +) = entryProvider { + fun navigate(dest: Destination) { + backstack += dest + } + + fun navigateUp() { + if (backstack.size > 1) backstack.removeLastOrNull() + } + + fun navigateToAppGroups() { + navigate(Destination.AppGroups) + } + + fun navigateAndPopAll(dest: Destination) { + navigate(dest) + repeat(backstack.size - 1) { + backstack.removeFirstOrNull() + } + } + + fun choosePackage() { + navigate(Destination.ApplicationsList(false, true)) + } + + fun chooseSinglePackage() { + navigate(Destination.ApplicationsList(false, false)) + } + + entry { + HomeScreen( + container.privilegeState, + { container.settingsRepo.data.applicationsListView }, + ::navigate + ) + } + entry { + WorkModesScreen(viewModel(factory = container.viewModelFactory), it, ::navigateUp, { + navigateAndPopAll(Destination.Home) + }, { + navigateAndPopAll(Destination.WorkingModes(false)) + }, ::navigate) + } + entry { + DhizukuServerSettingsScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + + entry { + DelegatedAdminsScreen( + viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate + ) + } + entry( + metadata = navParentKey(Destination.DelegatedAdmins) + ) { + AddDelegatedAdminScreen( + viewModel(), container.chosenPackage, ::chooseSinglePackage, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.System) + ) { + DeviceInfoScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + LockScreenInfoScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + SupportMessageScreen(viewModel(), ::navigateUp) + } + entry { + TransferOwnershipScreen( + viewModel(factory = container.viewModelFactory), ::navigateUp + ) { + navigate(Destination.WorkingModes(false)) + while (backstack.size > 1) { + backstack.removeFirstOrNull() + } + } + } + + entry { + SystemScreen(viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate) + } + entry { + SystemOptionsScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + KeyguardScreen(viewModel(), ::navigateUp) + } + entry { + HardwareMonitorScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + DefaultInputMethodScreen(viewModel(), ::navigateUp) + } + entry { + TimeScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + ContentProtectionPolicyScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + PermissionPolicyScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + MtePolicyScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + NearbyStreamingPolicyScreen(viewModel(), ::navigateUp) + } + entry { + LockTaskModeScreen( + viewModel(factory = container.viewModelFactory), + container.chosenPackage, ::chooseSinglePackage, ::choosePackage, ::navigateUp + ) + } + entry { + CaCertScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + entry { + SecurityLoggingScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + DisableAccountManagementScreen(viewModel(), ::navigateUp) + } + entry { + SystemUpdateScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { + FrpPolicyScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.System) + ) { WipeDataScreen(viewModel(), ::navigateUp) } + + entry { + NetworkScreen(viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate) + } + entry { + WifiScreen( + viewModel(factory = container.viewModelFactory), + ::navigate, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.WiFi) + ) { + UpdateNetworkScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.WiFi) + ) { + WifiSecurityLevelScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.WiFi) + ) { + WifiSsidPolicyScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Network) + ) { + NetworkOptionsScreen(viewModel(), ::navigateUp) + } + entry { + NetworkStatsScreen( + viewModel(factory = container.viewModelFactory), + ::chooseSinglePackage, container.chosenPackage, ::navigateUp + ) { + navigate(Destination.NetworkStatsViewer) + } + } + entry( + metadata = navParentKey(Destination.NetworkStats) + ) { + NetworkStatsViewerScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Network) + ) { + PrivateDnsScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Network) + ) { + AlwaysOnVpnPackageScreen( + viewModel(), container.chosenPackage, ::chooseSinglePackage, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.Network) + ) { + RecommendedGlobalProxyScreen(viewModel(), ::navigateUp) + } + entry { + NetworkLoggingScreen(viewModel(factory = container.viewModelFactory), ::navigateUp) + } + //entry { WifiAuthKeypairScreen(::navigateUp) } + entry { + PreferentialNetworkServiceScreen( + viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate + ) + } + entry( + metadata = navParentKey(Destination.PreferentialNetworkService) + ) { + AddPreferentialNetworkServiceConfigScreen(viewModel(), ::navigateUp) + } + entry { + OverrideApnScreen( + viewModel(factory = container.viewModelFactory), ::navigateUp + ) { navigate(Destination.AddApnSetting) } + } + entry( + metadata = navParentKey(Destination.OverrideApn) + ) { + AddApnSettingScreen(viewModel(), ::navigateUp) + } + + entry { + WorkProfileScreen(viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate) + } + entry { + CreateWorkProfileScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.WorkProfile) + ) { + SuspendPersonalAppScreen( + viewModel(), ::navigateUp + ) + } + entry { + CrossProfileIntentFilterScreen( + viewModel(factory = container.viewModelFactory), ::navigateUp + ) { + navigate(Destination.CrossProfileIntentFilterPresets) + } + } + entry( + metadata = navParentKey(Destination.CrossProfileIntentFilter) + ) { + CrossProfileIntentFilterPresetsScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.WorkProfile) + ) { + DeleteWorkProfileScreen(viewModel(), ::navigateUp) + } + + entry { params -> + AppChooserScreen( + params, appChooserVm, { name -> + if (params.canSwitchView) { + if (name == null) { + navigateUp() + } else { + navigate(Destination.ApplicationDetails(name)) + } + } else { + if (name != null) container.chosenPackage.trySend(name) + navigateUp() + } + }, { + container.settingsRepo.update { + it.applicationsListView = false + } + navigate(Destination.ApplicationFeatures) + backstack.removeAt(backstack.size - 2) + }) + } + + entry { + ApplicationsFeaturesScreen( + viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate + ) { + container.settingsRepo.update { + it.applicationsListView = true + } + navigate(Destination.ApplicationsList(true, true)) + backstack.removeAt(backstack.size - 2) + } + } + entry { + ApplicationDetailsScreen( + viewModel( + factory = viewModelFactory { + AppDetailsViewModel( + it.packageName, container.app, container.privilegeHelper, + container.privilegeState, container.toastChannel + ) + } + ), ::navigateUp, ::navigate + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.suspend, vm.suspendedPackages, vm::getSuspendedPackaged, + vm::setPackageSuspended, ::navigateUp, container.chosenPackage, ::choosePackage, + ::navigateToAppGroups, container.appGroupsState, R.string.info_suspend_app + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.hide, vm.hiddenPackages, vm::getHiddenPackages, vm::setPackageHidden, + ::navigateUp, container.chosenPackage, ::choosePackage, ::navigateToAppGroups, + container.appGroupsState + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.block_uninstall, vm.ubPackages, vm::getUbPackages, vm::setPackageUb, + ::navigateUp, container.chosenPackage, ::choosePackage, ::navigateToAppGroups, + container.appGroupsState + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.disable_user_control, vm.ucdPackages, vm::getUcdPackages, + vm::setPackageUcd, ::navigateUp, container.chosenPackage, ::choosePackage, + ::navigateToAppGroups, container.appGroupsState, R.string.info_disable_user_control + ) + } + entry( + metadata = navParentKey(Destination.ApplicationDetails) + ) { + AppPermissionsManagerScreen( + viewModel(), ::navigateUp + ) + } + entry { + PermissionManagerScreen(::navigate, ::navigateUp) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + PermissionDetailScreen( + it, viewModel(), ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.disable_metered_data, vm.mddPackages, vm::getMddPackages, + vm::setPackageMdd, ::navigateUp, container.chosenPackage, ::choosePackage, + ::navigateToAppGroups, container.appGroupsState + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + ClearAppStorageScreen( + viewModel(), container.chosenPackage, ::chooseSinglePackage, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + UninstallAppScreen( + vm, container.chosenPackage, ::chooseSinglePackage, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.keep_uninstalled_packages, vm.kuPackages, vm::getKuPackages, + vm::setPackageKu, ::navigateUp, container.chosenPackage, ::choosePackage, + ::navigateToAppGroups, container.appGroupsState, R.string.info_keep_uninstalled_apps + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + InstallExistingAppScreen( + vm, container.chosenPackage, ::chooseSinglePackage, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.cross_profile_apps, vm.cpPackages, + vm::getCpPackages, vm::setPackageCp, ::navigateUp, container.chosenPackage, + ::choosePackage, ::navigateToAppGroups, container.appGroupsState + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PackageFunctionScreen( + R.string.cross_profile_widget, vm.cpwProviders, + vm::getCpwProviders, vm::setCpwProvider, ::navigateUp, container.chosenPackage, + ::choosePackage, ::navigateToAppGroups, container.appGroupsState + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + CredentialManagerPolicyScreen( + vm, container.chosenPackage, ::choosePackage, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PermittedAsAndImPackagesScreen( + R.string.permitted_accessibility_services, + R.string.system_accessibility_always_allowed, container.chosenPackage, ::choosePackage, + vm.pasAllowAll, vm.pasPackages, vm::getPasPolicy, vm::setPasAllowAll, vm::setPasPackage, + vm::applyPasPolicy, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + PermittedAsAndImPackagesScreen( + R.string.permitted_ime, R.string.system_ime_always_allowed, + container.chosenPackage, ::choosePackage, vm.pimAllowAll, vm.pimPackages, + vm::getPimPolicy, vm::setPimAllowAll, + vm::setPimPackage, vm::applyPimPolicy, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + EnableSystemAppScreen( + container.chosenPackage, ::chooseSinglePackage, vm::enableSystemApp, ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.ApplicationFeatures) + ) { + val vm = viewModel() + SetDefaultDialerScreen( + container.chosenPackage, ::chooseSinglePackage, vm::setDefaultDialer, ::navigateUp + ) + } + entry { + ManagedConfigurationScreen( + viewModel(factory = viewModelFactory { + ManagedConfigurationViewModel( + it.packageName, container.app, container.privilegeHelper + ) + }), ::navigateUp + ) + } + entry { + AppGroupsScreen( + viewModel(factory = container.viewModelFactory), + { navigate(Destination.EditAppGroup) }, + ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.AppGroups) + ) { + EditAppGroupScreen( + viewModel(), ::navigateUp, ::choosePackage, container.chosenPackage + ) + } + + entry { + UserRestrictionScreen( + viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate + ) + } + entry( + metadata = navParentKey(Destination.UserRestriction) + ) { + UserRestrictionEditorScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.UserRestriction) + ) { + UserRestrictionOptionsScreen(it, viewModel(), ::navigateUp) + } + + entry { + UsersScreen(viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate) + } + entry( + metadata = navParentKey(Destination.Users) + ) { + UserInfoScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Users) + ) { + UsersOptionsScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Users) + ) { + UserOperationScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Users) + ) { + CreateUserScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Users) + ) { + ChangeUsernameScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Users) + ) { + UserSessionMessageScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Users) + ) { + AffiliationIdScreen(viewModel(), ::navigateUp) + } + + entry { + PasswordScreen(viewModel(factory = container.viewModelFactory), ::navigateUp, ::navigate) + } + entry( + metadata = navParentKey(Destination.Password) + ) { + PasswordInfoScreen( + viewModel(), ::navigateUp + ) + } + entry( + metadata = navParentKey(Destination.Password) + ) { + ResetPasswordTokenScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Password) + ) { + ResetPasswordScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Password) + ) { + RequiredPasswordComplexityScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Password) + ) { + KeyguardDisabledFeaturesScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Password) + ) { + RequiredPasswordQualityScreen(viewModel(), ::navigateUp) + } + + entry { + SettingsScreen(viewModel(factory = container.viewModelFactory), ::navigate, ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Settings) + ) { + SettingsOptionsScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Settings) + ) { + AppearanceScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Settings) + ) { + AppLockSettingsScreen(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Settings) + ) { + ApiSettings(viewModel(), ::navigateUp) + } + entry( + metadata = navParentKey(Destination.Settings) + ) { + NotificationsScreen(viewModel(), ::navigateUp) + } + entry { AboutScreen(::navigateUp) } +}(destination) as NavEntry diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/navigation/MyNavEntryDecorator.kt b/app/src/main/java/com/bintianqi/owndroid/ui/navigation/MyNavEntryDecorator.kt new file mode 100644 index 0000000..f0b236d --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/navigation/MyNavEntryDecorator.kt @@ -0,0 +1,154 @@ +package com.bintianqi.owndroid.ui.navigation + +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.SavedStateViewModelFactory +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner + +const val TAG = "MyNavEntryDecorator" + +@Composable +fun rememberSharedViewModelStoreNavEntryDecorator( + viewModelStoreOwner: ViewModelStoreOwner = + checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + removeViewModelStoreOnPop: () -> Boolean = { true }, +): SharedViewModelStoreNavEntryDecorator { + val currentRemoveViewModelStoreOnPop = rememberUpdatedState(removeViewModelStoreOnPop) + return remember(viewModelStoreOwner, currentRemoveViewModelStoreOnPop) { + SharedViewModelStoreNavEntryDecorator( + viewModelStoreOwner.viewModelStore, + removeViewModelStoreOnPop, + ) + } +} + +class SharedViewModelStoreNavEntryDecorator( + viewModelStore: ViewModelStore, + removeViewModelStoreOnPop: () -> Boolean, +) : + NavEntryDecorator( + onPop = ({ key -> + Log.d(TAG, "Popping $key") + if (removeViewModelStoreOnPop()) { + viewModelStore.getEntryViewModel().clearViewModelStoreOwnerForKey(key.toString()) + } + }), + decorate = { entry -> + LaunchedEffect(Unit) { + Log.d(TAG, "Decorating entry, key: ${entry.contentKey}, metadata: ${entry.metadata}") + } + // If the entry indicates it has a parent, use its parent's ViewModelStore. + val contentKey = entry.metadata[PARENT_CONTENT_KEY] ?: entry.contentKey + val viewModelStore = + viewModelStore.getEntryViewModel().viewModelStoreForKey(contentKey.toString()) + + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + val childViewModelStoreOwner = remember { + object : + ViewModelStoreOwner, + SavedStateRegistryOwner by savedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + override val viewModelStore: ViewModelStore + get() = viewModelStore + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = SavedStateViewModelFactory() + + override val defaultViewModelCreationExtras: CreationExtras + get() = + MutableCreationExtras().also { + it[SAVED_STATE_REGISTRY_OWNER_KEY] = this + it[VIEW_MODEL_STORE_OWNER_KEY] = this + } + + init { + require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) + enableSavedStateHandles() + } + } + } + CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelStoreOwner) { + entry.Content() + } + }, + ) { + + companion object { + + const val PARENT_CONTENT_KEY = "shared_decorator_parent_content_key" + + + } + +} + +fun navParentKey(contentKey: Any) = + mapOf(SharedViewModelStoreNavEntryDecorator.PARENT_CONTENT_KEY to contentKey) + +private class EntryViewModel : ViewModel() { + private val owners = mutableMapOf() + + fun viewModelStoreForKey(key: String): ViewModelStore { + Log.d(TAG, "Get ViewModelStore for key $key") + val result = owners.getOrPut(key) { ViewModelStore() } + Log.d(TAG, "EntryViewModel owners: $owners") + return result + } + + fun clearViewModelStoreOwnerForKey(key: String) { + Log.d(TAG, "Clear ViewModelStore for key $key") + owners.remove(key)?.clear() + } + + override fun onCleared() { + owners.forEach { (_, store) -> store.clear() } + } +} + + +private fun ViewModelStore.getEntryViewModel(): EntryViewModel { + val provider = + ViewModelProvider.create( + store = this, + factory = viewModelFactory { initializer { EntryViewModel() } }, + ) + return provider[EntryViewModel::class] +} diff --git a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt b/app/src/main/java/com/bintianqi/owndroid/ui/screen/AppLock.kt similarity index 90% rename from app/src/main/java/com/bintianqi/owndroid/AppLock.kt rename to app/src/main/java/com/bintianqi/owndroid/ui/screen/AppLock.kt index 98535c9..5374921 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppLock.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/screen/AppLock.kt @@ -1,4 +1,4 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.ui.screen import android.content.Context import android.hardware.biometrics.BiometricPrompt @@ -46,9 +46,15 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.settings.MySettings +import com.bintianqi.owndroid.utils.hash +import com.bintianqi.owndroid.utils.showOperationResultToast @Composable -fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismiss, DialogProperties(true, false)) { +fun AppLockDialog( + config: MySettings.AppLock, onSucceed: () -> Unit, onDismiss: () -> Unit +) = Dialog(onDismiss, DialogProperties(true, false)) { val context = LocalContext.current val fm = LocalFocusManager.current val fr = remember { FocusRequester() } @@ -56,7 +62,7 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi var isError by rememberSaveable { mutableStateOf(false) } var showPassword by remember { mutableStateOf(false) } fun unlock() { - if(input.hash() == SP.lockPasswordHash) { + if (input.hash() == config.passwordHash) { fm.clearFocus() onSucceed() } else { @@ -64,7 +70,7 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi } } LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= 28 && SP.biometricsUnlock) { + if (Build.VERSION.SDK_INT >= 28 && config.biometrics) { startBiometricsUnlock(context, onSucceed) } else { fr.requestFocus() @@ -93,7 +99,7 @@ fun AppLockDialog(onSucceed: () -> Unit, onDismiss: () -> Unit) = Dialog(onDismi } } ) - if(Build.VERSION.SDK_INT >= 28 && SP.biometricsUnlock) { + if (Build.VERSION.SDK_INT >= 28 && config.biometrics) { FilledTonalIconButton({ startBiometricsUnlock(context, onSucceed) }, Modifier.padding(start = 4.dp)) { Icon(painterResource(R.drawable.fingerprint_fill0), null) } diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/screen/Home.kt b/app/src/main/java/com/bintianqi/owndroid/ui/screen/Home.kt new file mode 100644 index 0000000..245cc38 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/screen/Home.kt @@ -0,0 +1,123 @@ +package com.bintianqi.owndroid.ui.screen + +import android.os.Build.VERSION +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ui.navigation.Destination +import com.bintianqi.owndroid.utils.BottomPadding +import com.bintianqi.owndroid.utils.PrivilegeStatus +import com.bintianqi.owndroid.utils.adaptiveInsets +import kotlinx.coroutines.flow.StateFlow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + privilegeState: StateFlow, getAppListViewMode: () -> Boolean, + onNavigate: (Destination) -> Unit +) { + val privilege by privilegeState.collectAsState() + val sb = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( + Modifier.nestedScroll(sb.nestedScrollConnection), + topBar = { + LargeTopAppBar( + { Text(stringResource(R.string.app_name)) }, + actions = { + IconButton({ onNavigate(Destination.WorkingModes(true)) }) { + Icon( + painterResource(R.drawable.security_fill0), null + ) + } + IconButton({ onNavigate(Destination.Settings) }) { + Icon(Icons.Default.Settings, null) + } + }, + scrollBehavior = sb + ) + }, + contentWindowInsets = adaptiveInsets() + ) { + Column( + Modifier + .fillMaxSize() + .padding(it) + .verticalScroll(rememberScrollState()) + ) { + if (privilege.device || privilege.profile) { + HomePageItem(R.string.system, R.drawable.android_fill0) { + onNavigate(Destination.System) + } + HomePageItem(R.string.network, R.drawable.wifi_fill0) { onNavigate(Destination.Network) } + } + if (privilege.work) { + HomePageItem(R.string.work_profile, R.drawable.work_fill0) { + onNavigate(Destination.WorkProfile) + } + } + if (privilege.device || privilege.profile) { + HomePageItem(R.string.applications, R.drawable.apps_fill0) { + onNavigate( + if (getAppListViewMode()) Destination.ApplicationsList(true, true) + else Destination.ApplicationFeatures + ) + } + if (VERSION.SDK_INT >= 24) { + HomePageItem(R.string.user_restriction, R.drawable.person_off) { + onNavigate(Destination.UserRestriction) + } + } + HomePageItem(R.string.users, R.drawable.manage_accounts_fill0) { + onNavigate(Destination.Users) + } + HomePageItem( + R.string.password_and_keyguard, R.drawable.password_fill0 + ) { onNavigate(Destination.Password) } + } + Spacer(Modifier.height(BottomPadding)) + } + } +} + +@Composable +fun HomePageItem(name: Int, icon: Int, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.padding(start = 30.dp)) + Icon(painterResource(icon), null) + Spacer(Modifier.padding(start = 15.dp)) + Text(stringResource(name), style = typography.headlineSmall) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/theme/Theme.kt b/app/src/main/java/com/bintianqi/owndroid/ui/theme/Theme.kt index 3e72f83..6713239 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ui/theme/Theme.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ui/theme/Theme.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -import com.bintianqi.owndroid.ThemeSettings +import com.bintianqi.owndroid.feature.settings.MySettings private val lightScheme = lightColorScheme( primary = primaryLight, @@ -94,10 +94,11 @@ private val darkScheme = darkColorScheme( @Composable fun OwnDroidTheme( - theme: ThemeSettings, + theme: MySettings.Theme, content: @Composable () -> Unit ) { - val darkTheme = theme.darkTheme == 1 || (theme.darkTheme == -1 && isSystemInDarkTheme()) + val darkTheme = theme.dark == MySettings.DarkMode.On || + (theme.dark == MySettings.DarkMode.FollowSystem && isSystemInDarkTheme()) val context = LocalContext.current val colorScheme = when { theme.materialYou && VERSION.SDK_INT >= 31 -> { @@ -106,7 +107,8 @@ fun OwnDroidTheme( darkTheme -> darkScheme else -> lightScheme }.let { - if(darkTheme && theme.blackTheme) it.copy(background = Color.Black, surface = Color.Black) else it + if (darkTheme && theme.black) it.copy(background = Color.Black, surface = Color.Black) + else it } val view = LocalView.current SideEffect { diff --git a/app/src/main/java/com/bintianqi/owndroid/utils/DpmUtils.kt b/app/src/main/java/com/bintianqi/owndroid/utils/DpmUtils.kt new file mode 100644 index 0000000..0f041ea --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/utils/DpmUtils.kt @@ -0,0 +1,287 @@ +package com.bintianqi.owndroid.utils + +import android.Manifest +import android.annotation.SuppressLint +import android.app.admin.ConnectEvent +import android.app.admin.DevicePolicyManager +import android.app.admin.DnsEvent +import android.app.admin.IDevicePolicyManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.IPackageInstaller +import android.content.pm.PackageInstaller +import android.os.Binder +import android.os.Build.VERSION +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.RequiresApi +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.network.NetworkLog +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.feature.users.UserOperationType +import com.rosan.dhizuku.api.Dhizuku +import com.rosan.dhizuku.api.DhizukuBinderWrapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("PrivateApi") +fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager { + val context = appContext.createPackageContext(Dhizuku.getOwnerComponent().packageName, Context.CONTEXT_IGNORE_SECURITY) + val manager = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val field = manager.javaClass.getDeclaredField("mService") + field.isAccessible = true + val oldInterface = field[manager] as IDevicePolicyManager + if (oldInterface is DhizukuBinderWrapper) return manager + val oldBinder = oldInterface.asBinder() + val newBinder = Dhizuku.binderWrapper(oldBinder) + val newInterface = IDevicePolicyManager.Stub.asInterface(newBinder) + field[manager] = newInterface + return manager +} + +@SuppressLint("PrivateApi") +private fun binderWrapperPackageInstaller(appContext: Context): PackageInstaller { + val context = appContext.createPackageContext(Dhizuku.getOwnerComponent().packageName, Context.CONTEXT_IGNORE_SECURITY) + val installer = context.packageManager.packageInstaller + val field = installer.javaClass.getDeclaredField("mInstaller") + field.isAccessible = true + val oldInterface = field[installer] as IPackageInstaller + if (oldInterface is DhizukuBinderWrapper) return installer + val oldBinder = oldInterface.asBinder() + val newBinder = Dhizuku.binderWrapper(oldBinder) + val newInterface = IPackageInstaller.Stub.asInterface(newBinder) + field[installer] = newInterface + return installer +} + +fun Context.getPackageInstaller(dhizuku: Boolean): PackageInstaller { + return if (dhizuku) { + try { + binderWrapperPackageInstaller(this) + } catch (e: Exception) { + throw DhizukuException(DhizukuError.Binder, e) + } + } else { + this.packageManager.packageInstaller + } +} + +data class PermissionItem( + val id: String, + val label: Int, + val icon: Int, + val profileOwnerRestricted: Boolean = false, + val requiresApi: Int = 23 +) + +@Suppress("InlinedApi") +val runtimePermissions = listOf( + PermissionItem(Manifest.permission.POST_NOTIFICATIONS, R.string.permission_POST_NOTIFICATIONS, R.drawable.notifications_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_EXTERNAL_STORAGE, R.string.permission_READ_EXTERNAL_STORAGE, R.drawable.folder_fill0), + PermissionItem(Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_WRITE_EXTERNAL_STORAGE, R.drawable.folder_fill0), + PermissionItem(Manifest.permission.READ_MEDIA_AUDIO, R.string.permission_READ_MEDIA_AUDIO, R.drawable.music_note_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_MEDIA_VIDEO, R.string.permission_READ_MEDIA_VIDEO, R.drawable.movie_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.READ_MEDIA_IMAGES, R.string.permission_READ_MEDIA_IMAGES, R.drawable.image_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.CAMERA, R.string.permission_CAMERA, R.drawable.photo_camera_fill0, true), + PermissionItem(Manifest.permission.RECORD_AUDIO, R.string.permission_RECORD_AUDIO, R.drawable.mic_fill0, true), + PermissionItem(Manifest.permission.ACCESS_COARSE_LOCATION, R.string.permission_ACCESS_COARSE_LOCATION, R.drawable.location_on_fill0, true), + PermissionItem(Manifest.permission.ACCESS_FINE_LOCATION, R.string.permission_ACCESS_FINE_LOCATION, R.drawable.location_on_fill0, true), + PermissionItem(Manifest.permission.ACCESS_BACKGROUND_LOCATION, R.string.permission_ACCESS_BACKGROUND_LOCATION, R.drawable.location_on_fill0, true, 29), + PermissionItem(Manifest.permission.READ_CONTACTS, R.string.permission_READ_CONTACTS, R.drawable.contacts_fill0), + PermissionItem(Manifest.permission.WRITE_CONTACTS, R.string.permission_WRITE_CONTACTS, R.drawable.contacts_fill0), + PermissionItem(Manifest.permission.READ_CALENDAR, R.string.permission_READ_CALENDAR, R.drawable.calendar_month_fill0), + PermissionItem(Manifest.permission.WRITE_CALENDAR, R.string.permission_WRITE_CALENDAR, R.drawable.calendar_month_fill0), + PermissionItem(Manifest.permission.BLUETOOTH_CONNECT, R.string.permission_BLUETOOTH_CONNECT, R.drawable.bluetooth_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.BLUETOOTH_SCAN, R.string.permission_BLUETOOTH_SCAN, R.drawable.bluetooth_searching_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.BLUETOOTH_ADVERTISE, R.string.permission_BLUETOOTH_ADVERTISE, R.drawable.bluetooth_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.NEARBY_WIFI_DEVICES, R.string.permission_NEARBY_WIFI_DEVICES, R.drawable.wifi_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.CALL_PHONE, R.string.permission_CALL_PHONE, R.drawable.call_fill0), + PermissionItem(Manifest.permission.ANSWER_PHONE_CALLS, R.string.permission_ANSWER_PHONE_CALLS, R.drawable.call_fill0, requiresApi = 26), + PermissionItem(Manifest.permission.READ_PHONE_NUMBERS, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0, requiresApi = 26), + PermissionItem(Manifest.permission.READ_PHONE_STATE, R.string.permission_READ_PHONE_STATE, R.drawable.mobile_phone_fill0), + PermissionItem(Manifest.permission.USE_SIP, R.string.permission_USE_SIP, R.drawable.call_fill0), + PermissionItem(Manifest.permission.UWB_RANGING, R.string.permission_UWB_RANGING, R.drawable.cell_tower_fill0, requiresApi = 31), + PermissionItem(Manifest.permission.READ_SMS, R.string.permission_READ_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.RECEIVE_SMS, R.string.permission_RECEIVE_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.SEND_SMS, R.string.permission_SEND_SMS, R.drawable.sms_fill0), + PermissionItem(Manifest.permission.READ_CALL_LOG, R.string.permission_READ_CALL_LOG, R.drawable.call_log_fill0), + PermissionItem(Manifest.permission.WRITE_CALL_LOG, R.string.permission_WRITE_CALL_LOG, R.drawable.call_log_fill0), + PermissionItem(Manifest.permission.RECEIVE_WAP_PUSH, R.string.permission_RECEIVE_WAP_PUSH, R.drawable.wifi_fill0), + PermissionItem(Manifest.permission.BODY_SENSORS, R.string.permission_BODY_SENSORS, R.drawable.sensors_fill0, true), + PermissionItem(Manifest.permission.BODY_SENSORS_BACKGROUND, R.string.permission_BODY_SENSORS_BACKGROUND, R.drawable.sensors_fill0, requiresApi = 33), + PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true, 29) +).filter { VERSION.SDK_INT >= it.requiresApi } + +@RequiresApi(26) +fun retrieveNetworkLogs(app: MyApplication, token: Long) { + CoroutineScope(Dispatchers.IO).launch { + val ph = app.container.privilegeHelper + val logs = ph.myDpm.retrieveNetworkLogs(ph.myDar, token)?.mapNotNull { + when (it) { + is DnsEvent -> NetworkLog( + if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, "dns", + it.hostname, it.totalResolvedAddressCount, + it.inetAddresses.mapNotNull { address -> address.hostAddress }, null, null + ) + is ConnectEvent -> NetworkLog( + if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, + "connect", null, null, null, it.inetAddress.hostAddress, it.port + ) + else -> null + } + } + if (logs.isNullOrEmpty()) return@launch + app.container.networkLoggingRepo.writeNetworkLogs(logs) + NotificationUtils.sendBasicNotification( + app, NotificationType.NetworkLogsCollected, + app.getString(R.string.n_logs_in_total, logs.size) + ) + } +} + +val activateOrgProfileCommand = "dpm mark-profile-owner-on-organization-owned-device --user " + + "${Binder.getCallingUid() / 100000} com.bintianqi.owndroid/com.bintianqi.owndroid.Receiver" + +@RequiresApi(24) +fun retrieveSecurityLogs(app: MyApplication) { + CoroutineScope(Dispatchers.IO).launch { + val ph = app.container.privilegeHelper + val logs = ph.myDpm.retrieveSecurityLogs(ph.myDar) + if (logs.isNullOrEmpty()) return@launch + app.container.securityLoggingRepo.writeSecurityLogs(logs) + NotificationUtils.sendBasicNotification( + app, NotificationType.SecurityLogsCollected, + app.getString(R.string.n_logs_in_total, logs.size) + ) + } +} + +fun setDefaultAffiliationID(ph: PrivilegeHelper, sr: SettingsRepository) { + if (VERSION.SDK_INT >= 26 && !sr.data.privilege.defaultAffiliationIdSet) { + try { + ph.dpm.setAffiliationIds(ph.dar, setOf("OwnDroid_default_affiliation_id")) + sr.update { it.privilege.defaultAffiliationIdSet = true } + Log.d("DPM", "Default affiliation id set") + } catch (e: Exception) { + e.printStackTrace() + } + } +} + +fun parsePackageInstallerMessage(context: Context, result: Intent): String { + val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + val statusMessage = result.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + val otherPackageName = result.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) + return when(status) { + PackageInstaller.STATUS_FAILURE_BLOCKED -> + context.getString( + R.string.status_failure_blocked, + otherPackageName ?: context.getString(R.string.unknown) + ) + PackageInstaller.STATUS_FAILURE_ABORTED -> + context.getString(R.string.status_failure_aborted) + PackageInstaller.STATUS_FAILURE_INVALID -> + context.getString(R.string.status_failure_invalid) + PackageInstaller.STATUS_FAILURE_CONFLICT -> + context.getString(R.string.status_failure_conflict, otherPackageName ?: "???") + PackageInstaller.STATUS_FAILURE_STORAGE -> + context.getString(R.string.status_failure_storage) + + result.getStringExtra(PackageInstaller.EXTRA_STORAGE_PATH).let { if(it == null) "" else "\n$it" } + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> + context.getString(R.string.status_failure_incompatible) + PackageInstaller.STATUS_FAILURE_TIMEOUT -> + context.getString(R.string.timeout) + else -> "" + } + statusMessage.let { if(it == null) "" else "\n$it" } +} + + +fun handlePrivilegeChange( + context: Context, ps: PrivilegeStatus, ph: PrivilegeHelper, sr: SettingsRepository +) { + if (ps.activated) { + ShortcutUtils.setAllShortcuts(context, sr, ph, true) + if (!ps.dhizuku) { + setDefaultAffiliationID(ph, sr) + } + } else { + sr.update { + it.privilege.defaultAffiliationIdSet = false + it.apiKeyHash = "" + } + ShortcutUtils.setAllShortcuts(context, sr, ph, false) + } +} + +fun doUserOperationWithContext( + context: Context, dpm: DevicePolicyManager, dar: ComponentName, + type: UserOperationType, id: Int, isUserId: Boolean +): Boolean { + val um = context.getSystemService(Context.USER_SERVICE) as UserManager + val handle = if (isUserId && VERSION.SDK_INT >= 24) { + UserHandle.getUserHandleForUid(id * 100000) + } else { + um.getUserForSerialNumber(id.toLong()) + } + if (handle == null) return false + return when (type) { + UserOperationType.Start -> { + if (VERSION.SDK_INT >= 28) + dpm.startUserInBackground(dar, handle) == UserManager.USER_OPERATION_SUCCESS + else false + } + UserOperationType.Switch -> dpm.switchUser(dar, handle) + UserOperationType.Stop -> { + if (VERSION.SDK_INT >= 28) + dpm.stopUser(dar, handle) == UserManager.USER_OPERATION_SUCCESS + else false + } + UserOperationType.Delete -> dpm.removeUser(dar, handle) + } +} + +const val ACTIVATE_DEVICE_OWNER_COMMAND = "dpm set-device-owner com.bintianqi.owndroid/.Receiver" + +class PrivilegeStatus( + val device: Boolean = false, + val profile: Boolean = false, + val dhizuku: Boolean = false, + val work: Boolean = false, + val org: Boolean = false, + val affiliated: Boolean = false +) { + val activated = device || profile +} + +fun getPrivilegeStatus(ph: PrivilegeHelper): PrivilegeStatus { + val profile = ph.dpm.isProfileOwnerApp(ph.dar.packageName) + val work = profile && VERSION.SDK_INT >= 24 && ph.dpm.isManagedProfile(ph.dar) + return PrivilegeStatus( + device = ph.dpm.isDeviceOwnerApp(ph.dar.packageName), + profile = profile, + dhizuku = ph.dhizuku, + work = work, + org = work && VERSION.SDK_INT >= 30 && ph.dpm.isOrganizationOwnedDeviceWithManagedProfile, + affiliated = VERSION.SDK_INT >= 28 && ph.dpm.isAffiliatedUser + ) +} + +class DhizukuException: Exception { + val reason: DhizukuError + constructor(r: DhizukuError) { + reason = r + } + constructor(r: DhizukuError, e: Throwable) { + reason = r + initCause(e) + } +} + +enum class DhizukuError { + Init, Permission, Binder +} diff --git a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt b/app/src/main/java/com/bintianqi/owndroid/utils/NotificationUtils.kt similarity index 92% rename from app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt rename to app/src/main/java/com/bintianqi/owndroid/utils/NotificationUtils.kt index 2c326f8..58b5881 100644 --- a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/utils/NotificationUtils.kt @@ -1,10 +1,12 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.utils import android.app.NotificationManager import android.content.Context import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.feature.settings.SettingsRepository object NotificationUtils { fun createChannels(context: Context) { @@ -26,9 +28,10 @@ object NotificationUtils { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager nm.notify(type.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) { + fun notifyEvent( + context: Context, sr: SettingsRepository, type: NotificationType, text: String + ) { + if (type.id in sr.data.notifications) { sendBasicNotification(context, type, text) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/utils/ShortcutUtils.kt similarity index 65% rename from app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt rename to app/src/main/java/com/bintianqi/owndroid/utils/ShortcutUtils.kt index 21e4e00..b763e8b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/utils/ShortcutUtils.kt @@ -1,36 +1,45 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.utils 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 +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.ShortcutsReceiverActivity +import com.bintianqi.owndroid.feature.settings.SettingsRepository +import com.bintianqi.owndroid.feature.user_restriction.UserRestrictionsRepository +import com.bintianqi.owndroid.feature.users.UserOperationType object ShortcutUtils { - fun setAllShortcuts(context: Context, enabled: Boolean) { + fun setAllShortcuts( + context: Context, sr: SettingsRepository, ph: PrivilegeHelper, enabled: Boolean + ) { if (enabled) { - setShortcutKey() + setShortcutKey(sr) 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)) + createShortcut(context, sr, MyShortcut.Lock, true), + createShortcut(context, sr, MyShortcut.DisableCamera, + !ph.dpm.getCameraDisabled(ph.dar)), + createShortcut(context, sr, MyShortcut.Mute, + !ph.dpm.isMasterVolumeMuted(ph.dar)) ) ShortcutManagerCompat.setDynamicShortcuts(context, list) } else { ShortcutManagerCompat.removeDynamicShortcuts(context, MyShortcut.entries.map { it.id }) } } - fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) { - setShortcutKey() + fun setShortcut( + context: Context, sr: SettingsRepository, shortcut: MyShortcut, state: Boolean + ) { + setShortcutKey(sr) ShortcutManagerCompat.pushDynamicShortcut( - context, createShortcut(context, shortcut, state) + context, createShortcut(context, sr, shortcut, state) ) } private fun createShortcut( - context: Context, shortcut: MyShortcut, state: Boolean + context: Context, sr: SettingsRepository, shortcut: MyShortcut, state: Boolean ): ShortcutInfoCompat { val icon = IconCompat.createWithResource( context, @@ -44,16 +53,18 @@ object ShortcutUtils { .setIntent( Intent(context, ShortcutsReceiverActivity::class.java) .setAction("com.bintianqi.owndroid.action.${shortcut.id}") - .putExtra("key", SP.shortcutKey) + .putExtra("key", sr.data.shortcut.key) ) .build() } /** @param state If true, set the user restriction */ - fun createUserRestrictionShortcut(context: Context, id: String, state: Boolean): ShortcutInfoCompat { + fun createUserRestrictionShortcut( + context: Context, sr: SettingsRepository, 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() + setShortcutKey(sr) return ShortcutInfoCompat.Builder(context, "USER_RESTRICTION-$id") .setIcon(IconCompat.createWithResource(context, restriction.icon)) .setShortLabel(label) @@ -62,28 +73,32 @@ object ShortcutUtils { .setAction("com.bintianqi.owndroid.action.USER_RESTRICTION") .putExtra("restriction", id) .putExtra("state", state) - .putExtra("key", SP.shortcutKey) + .putExtra("key", sr.data.shortcut.key) ) .build() } - fun setUserRestrictionShortcut(context: Context, id: String, state: Boolean): Boolean { - val shortcut = createUserRestrictionShortcut(context, id, state) + fun setUserRestrictionShortcut( + context: Context, sr: SettingsRepository, id: String, state: Boolean + ): Boolean { + val shortcut = createUserRestrictionShortcut(context, sr, id, state) return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) } - fun updateUserRestrictionShortcut(context: Context, id: String, state: Boolean, checkExist: Boolean) { + fun updateUserRestrictionShortcut( + context: Context, sr: SettingsRepository, 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) + val shortcut = createUserRestrictionShortcut(context, sr, id, state) ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut)) } fun buildUserOperationShortcut( - context: Context, type: UserOperationType, serial: Int + context: Context, sr: SettingsRepository, type: UserOperationType, serial: Int ): ShortcutInfoCompat { - setShortcutKey() + setShortcutKey(sr) val icon = when (type) { UserOperationType.Start, UserOperationType.Switch -> R.drawable.person_fill0 UserOperationType.Stop -> R.drawable.person_off @@ -103,12 +118,14 @@ object ShortcutUtils { .setAction("com.bintianqi.owndroid.action.USER_OPERATION") .putExtra("operation", type.name) .putExtra("serial", serial) - .putExtra("key", SP.shortcutKey) + .putExtra("key", sr.data.shortcut.key) ) .build() } - fun setUserOperationShortcut(context: Context, type: UserOperationType, serial: Int): Boolean { - val shortcut = buildUserOperationShortcut(context, type, serial) + fun setUserOperationShortcut( + context: Context, sr: SettingsRepository, type: UserOperationType, serial: Int + ): Boolean { + val shortcut = buildUserOperationShortcut(context, sr, type, serial) return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) } fun disableUserOperationShortcut(context: Context, serial: Int) { @@ -119,9 +136,11 @@ object ShortcutUtils { context, shortcuts, context.getString(R.string.user_removed) ) } - fun setShortcutKey() { - if (SP.shortcutKey.isNullOrEmpty()) { - SP.shortcutKey = generateBase64Key(10) + fun setShortcutKey(sr: SettingsRepository) { + if (sr.data.shortcut.key.isEmpty()) { + sr.update { + it.shortcut.key = generateBase64Key(10) + } } } } @@ -134,4 +153,4 @@ enum class MyShortcut( 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/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/utils/Utils.kt similarity index 54% rename from app/src/main/java/com/bintianqi/owndroid/Utils.kt rename to app/src/main/java/com/bintianqi/owndroid/utils/Utils.kt index c545970..2f11151 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/utils/Utils.kt @@ -1,5 +1,6 @@ -package com.bintianqi.owndroid +package com.bintianqi.owndroid.utils +import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.ClipData import android.content.ClipboardManager @@ -7,11 +8,15 @@ 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.PackageInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build +import android.os.Build.VERSION import android.widget.Toast -import androidx.annotation.StringRes import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation @@ -26,9 +31,20 @@ 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.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.bintianqi.owndroid.MyApplication +import com.bintianqi.owndroid.PrivilegeHelper +import com.bintianqi.owndroid.R +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import java.io.FileNotFoundException @@ -39,11 +55,8 @@ import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import java.util.concurrent.TimeUnit import kotlin.io.encoding.Base64 -var zhCN = true - fun uriToStream( context: Context, uri: Uri, @@ -81,7 +94,7 @@ fun formatFileSize(bytes: Long): String { } val Boolean.yesOrNo - @StringRes get() = if(this) R.string.yes else R.string.no + get() = if(this) R.string.yes else R.string.no fun formatDate(ms: Long): String { return formatDate(Date(ms)) @@ -96,16 +109,6 @@ fun Context.showOperationResultToast(success: Boolean) { const val APK_MIME = "application/vnd.android.package-archive" -fun exportLogs(context: Context, uri: Uri) { - context.contentResolver.openOutputStream(uri)?.use { output -> - val proc = Runtime.getRuntime().exec("logcat -d") - proc.inputStream.copyTo(output) - if(Build.VERSION.SDK_INT >= 26) proc.waitFor(2L, TimeUnit.SECONDS) - else proc.waitFor() - context.showOperationResultToast(proc.exitValue() == 0) - } -} - val HorizontalPadding = 16.dp val BottomPadding = 60.dp @@ -121,7 +124,7 @@ val MyAdminComponent = ComponentName.unflattenFromString("com.bintianqi.owndroid @OptIn(ExperimentalStdlibApi::class) fun getPackageSignature(info: PackageInfo): String? { - val signatures = if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else info.signatures + val signatures = if (VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else info.signatures return signatures?.firstOrNull()?.toByteArray() ?.let { MessageDigest.getInstance("SHA-256").digest(it) }?.toHexString() } @@ -178,4 +181,95 @@ fun registerPackageRemovedReceiver( ctx.registerReceiver(br, filter) } -fun parsePackageNames(input: String) = input.split('\n').filter { it.isNotEmpty() } +fun parsePackageNames(input: String) = input.lines().filter { it.isNotEmpty() } + +val getInstalledAppsFlags = + if(VERSION.SDK_INT >= 24) PackageManager.MATCH_DISABLED_COMPONENTS or PackageManager.MATCH_UNINSTALLED_PACKAGES else 0 + +fun searchInString(query: String, content: String) + = query.split(' ').all { content.contains(it, true) } + +class ToastChannel(val context: Context) { + val channel = Channel(0, BufferOverflow.DROP_LATEST) + fun sendStatus(status: Boolean) { + val resId = if (status) R.string.success else R.string.failed + channel.trySend(context.getString(resId)) + } + fun sendText(text: String) { + channel.trySend(text) + } + fun sendText(resId: Int) { + channel.trySend(context.getString(resId)) + } +} + +class AppInfo( + val name: String, + val label: String, + val icon: Drawable, + val flags: Int +) + +fun getAppInfo(pm: PackageManager, info: ApplicationInfo) = + AppInfo(info.packageName, info.loadLabel(pm).toString(), info.loadIcon(pm), info.flags) + +fun getAppInfo(pm: PackageManager, name: String): AppInfo { + return try { + getAppInfo(pm, pm.getApplicationInfo(name, getInstalledAppsFlags)) + } catch (_: PackageManager.NameNotFoundException) { + AppInfo(name, "???", Color.Transparent.toArgb().toDrawable(), 0) + } +} + +fun List.plusOrMinus(state: Boolean, item: T) = if (state) plus(item) else minus(item) +fun List.plusOrMinus(state: Boolean, items: Collection) = + if (state) plus(items) else minus(items) + +fun uninstallPackage( + application: MyApplication, privilegeHelper: PrivilegeHelper, + packageName: String, onComplete: (String?) -> Unit +) { + val action = "com.bintianqi.owndroid.action.PACKAGE_UNINSTALLED" + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + if (statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @SuppressWarnings("UnsafeIntentLaunch") + val confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) + confirmIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(confirmIntent) + } else { + context.unregisterReceiver(this) + if (statusExtra == PackageInstaller.STATUS_SUCCESS) { + onComplete(null) + } else { + onComplete(parsePackageInstallerMessage(context, intent)) + } + } + } + } + ContextCompat.registerReceiver( + application, receiver, IntentFilter(action), null, + null, ContextCompat.RECEIVER_NOT_EXPORTED + ) + val intent = Intent(action).setPackage(application.packageName) + val pi = if(VERSION.SDK_INT >= 34) { + PendingIntent.getBroadcast( + application, 0, intent, + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE + ).intentSender + } else { + PendingIntent.getBroadcast(application, 0, intent, PendingIntent.FLAG_MUTABLE).intentSender + } + application.getPackageInstaller(privilegeHelper.dhizuku).uninstall(packageName, pi) +} + +fun viewModelFactory(build: () -> ViewModel) = + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return build() as T + } + } + +val String.isValidPackageName + get() = Regex("""^(?:[a-zA-Z]\w*\.)+[a-zA-Z]\w*$""").matches(this) diff --git a/app/src/main/res/drawable/cancel_fill0.xml b/app/src/main/res/drawable/cancel_fill0.xml new file mode 100644 index 0000000..0f07315 --- /dev/null +++ b/app/src/main/res/drawable/cancel_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/light_off_fill0.xml b/app/src/main/res/drawable/light_off_fill0.xml new file mode 100644 index 0000000..6cb2da8 --- /dev/null +++ b/app/src/main/res/drawable/light_off_fill0.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ea24a39..7990e2f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -160,7 +160,7 @@ Политика потоковой передачи Nearby App Политика потоковой передачи Nearby Политика потоковой передачи уведомлений Nearby - Только для одной управляемой учетной записи + Только для одной управляемой учетной записи Режим закрепления задачи Приложение не разрешено Отключить все @@ -174,10 +174,10 @@ Блокировать запуск активности в задаче CA-сертификат - No user CA certificate installed - Failed to parse certificate + Пользовательский сертификат CA не установлен + Не удалось обработать сертификат Export - Select a CA certificate + Выберите сертификат CA Удалить все пользовательские CA-сертификаты Журнал безопасности Журналы безопасности перед перезагрузкой @@ -190,7 +190,7 @@ Ваш рабочий профиль будет УДАЛЕН Статус шифрования Политика FRP - Your device does not support FRP + Устройство не поддерживает защиту от сброса (FRP) Включить FRP Список аккаунтов: @@ -198,8 +198,7 @@ Патч безопасности: %1$s Нет системных обновлений Политика системных обновлений - Автоматически - Установить в окне + Установить в окне Отложить на 30 дней Время начала Время окончания @@ -531,6 +530,10 @@ Оформление + Длина должна быть не менее 4-х символов + Использовать биометрию + Блокировать при выходе + Блокировка приложения Безопасность Очистить хранилище @@ -643,4 +646,4 @@ Установка значения больше нуля включает политику, согласно которой этот пользователь будет удален после слишком большого количества неверных паролей разблокировки. После установки этого значения пользователь не сможет ввести новый пароль, который совпадает с каким-либо паролем из истории. Обратите внимание, что текущий пароль останется до тех пор, пока пользователь не установит новый, поэтому изменение не вступит в силу немедленно.\nЗначение 0 означает отсутствие ограничений. Определить, как долго пользователь сможет использовать вторичную, не строгую аутентификацию для аутентификации, с момента последнего использования строгой аутентификации (пароль, PIN-код или графический ключ). По истечении указанного времени пользователь должен будет использовать строгий метод аутентификации.\nЗначение 0 означает, что администратор не участвует в управлении этим временем ожидания. Минимальное и максимальное время ожидания определяются платформой и обычно составляют 1 час и 72 часа соответственно. - \ No newline at end of file + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9195b73..8da6671 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -191,7 +191,7 @@ Yakındaki Uygulama Akışı Yakındaki Akış Politikası Yakındaki Bildirim Akışı - Yalnızca aynı yönetilen hesap + Yalnızca aynı yönetilen hesap Görev Kilitleme Modu Uygulamaya izin verilmiyor Hepsini Devre Dışı Bırak @@ -228,8 +228,7 @@ Güvenlik yaması: %1$s Sistem güncellemesi yok Sistem Güncelleme Politikası - Otomatik - Pencereli Yükleme + Pencereli Yükleme 30 Gün Ertele Başlangıç Zamanı Bitiş Zamanı @@ -686,7 +685,7 @@ Son güçlü kimlik doğrulama yönteminin (parola, PIN veya desen) kullanıldığından beri kullanıcının ikincil, güçlü olmayan kimlik doğrulamayı ne kadar süre kullanabileceğini belirler. Dönen zaman aşımından sonra kullanıcı güçlü kimlik doğrulama yöntemi kullanmak zorundadır.\n0 değeri, yöneticinin zaman aşımı kontrolüne katılmadığı anlamına gelir. Minimum ve maksimum zaman aşımı süreleri platform tarafından tanımlanır ve genellikle sırasıyla 1 saat ve 72 saattir. OwnDroid yetkilerini kaybedecektir. - Çalışma Modunu Seç + Çalışma Modunu Seç Önerilen Etkinleştirme Yöntemi ADB Komutu diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 08879fb..72f2e54 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -69,6 +69,7 @@ 继续 退出 撤销 + 刷新 Profile owner @@ -134,6 +135,10 @@ 禁止蓝牙分享联系人 通用标准模式 启用USB信号 + 充电时保持亮屏 + 启用ADB + 启用USB大容量存储 + 跳过首次使用提示 锁屏 立即锁屏 锁屏 @@ -148,7 +153,8 @@ CPU使用情况 活动 总计 - Fan speeds + 风扇速度 + 默认输入法 错误报告 请求错误报告? 重启 @@ -157,6 +163,7 @@ 手动输入 日期 时间 + 时区 使用当前时区 更改时区 时区ID @@ -172,7 +179,7 @@ 附近应用传输 附近流式传输策略 附近通知传输 - 在足够安全时启用 + 账号相同时启用 锁定任务模式 清除任务(新实例) 显示通知以退出 @@ -214,8 +221,7 @@ 安全补丁: %1$s 暂无系统更新 系统更新策略 - 准备好后立即更新 - 在某段时间里更新 + 窗口期 延迟30天 开始时间 结束时间 @@ -331,6 +337,13 @@ 工作资料处于关闭状态的时间达到该限制后会挂起个人应用,0为无限制 个人应用已经因此挂起:%1$s Intent过滤器 + 预设 + 打开文件 + 分享 + 分享(多个) + 获取内容 + 选择文件 + 选择文件夹 方向 双向 工作到个人 @@ -342,6 +355,8 @@ 应用程序 + 应用视图 + 功能视图 显示用户应用 显示系统应用 挂起 @@ -352,6 +367,8 @@ 启用锁定 清除当前配置 权限 + 用户应用 + 系统应用 未安装 阻止卸载 禁止用户控制 @@ -696,8 +713,9 @@ 设置后,用户将无法输入与历史记录中任何密码相同的新密码。当前密码将保留,直到用户设置新密码为止,因此更改不会立即生效。值为0表示不做限制。 如果用户在这段时间内没有使用强认证(密码、PIN或图案)解锁设备,则要求使用强认证解锁设备。值为0表示OwnDroid不参与控制超时。一般来说,最少1小时,最多72小时。 OwnDroid将会丢失特权 + 你的个人应用被OwnDroid挂起 - 选择一个工作模式 + 选择一个工作模式 推荐 激活方法 ADB命令 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8ff66c..43da711 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,7 @@ Continue Exit Undo + Refresh Profile owner @@ -143,6 +144,10 @@ Disable bluetooth contact sharing Common criteria mode Enable USB signal + Stay on while plugged in + Enable ADB + Enable USB mass storage + Skip first use hints Keyguard Lock screen now Lock screen @@ -158,6 +163,7 @@ Active Total Fan speeds + Default input method Bug report Request bug report? Reboot @@ -166,6 +172,7 @@ Manually input Date Time + Timezone Use current timezone Change timezone Timezone ID @@ -200,7 +207,7 @@ Nearby App streaming policy Nearby streaming policy Nearby notification streaming policy - Same managed account only + Same managed account only Lock task mode Clear task (start fresh) Show a notification to exit @@ -241,8 +248,7 @@ Security patch: %1$s There\'s no system update System update policy - Automatic - Install windowed + Windowed Postpone 30 days Start time End time @@ -365,6 +371,13 @@ 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 Intent filter + Presets + Open file + Share + Share (multiple) + Get content + Choose file + Choose folder Direction Both direction Work to personal @@ -376,6 +389,8 @@ Applications + Apps view + Features view Show user apps Show system apps Suspend @@ -386,6 +401,8 @@ Enable lockdown Clear current config Permissions + User apps + System apps Not installed Block uninstall Enable system app @@ -733,8 +750,9 @@ After setting this, the user will not be able to enter a new password that is the same as any password in the history. Note that the current password will remain until the user has set a new one, so the change does not take place immediately.\nA value of 0 means there is no restriction. Determine for how long the user will be able to use secondary, non strong auth for authentication, since last strong method authentication (password, pin or pattern) was used. After the returned timeout the user is required to use strong authentication method.\nA value of 0 means the admin is not participating in controlling the timeout. The minimum and maximum timeouts are platform-defined and are typically 1 hour and 72 hours, respectively. OwnDroid will lost its privilege + Your personal apps is suspended by OwnDroid - Choose a work mode + Choose a working mode Recommended Activate method ADB command diff --git a/build.gradle.kts b/build.gradle.kts index 922f551..1a71b18 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false -} \ No newline at end of file +} + +buildscript { + dependencies { + classpath(libs.kgp) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 14feb9a..3653e46 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,10 @@ [versions] -agp = "8.13.2" -kotlin = "2.3.0" +agp = "9.0.1" +kotlin = "2.3.10" -navigation-compose = "2.9.6" -composeBom = "2025.12.01" +nav3 = "1.0.1" +composeBom = "2026.02.00" +lifecycle = "2.10.0" accompanist-drawablepainter = "0.37.3" accompanist-permissions = "0.37.3" shizuku = "13.1.5" @@ -13,18 +14,21 @@ dhizuku-server = "0.0.10" hiddenApiBypass = "6.1" libsu = "6.0.0" reoderable = "3.0.0" -serialization = "1.9.0" +serialization = "1.10.0" [libraries] androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-activity-compose = { module = "androidx.activity:activity-compose" } +androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } 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" } +androidx-nav3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3" } +androidx-nav3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "nav3" } + 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" } @@ -38,8 +42,9 @@ reoderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reod serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } +kgp = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } + [plugins] 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.3.0" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.3.10" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 388b252..1930c9f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Jan 12 20:22:20 CST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://downloads.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://downloads.gradle.org/distributions/gradle-9.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists