diff --git a/app/src/directVersion/res/xml-v21/admin.xml b/app/src/directVersion/res/xml-v21/admin.xml index 62a0e74..7d22dbe 100644 --- a/app/src/directVersion/res/xml-v21/admin.xml +++ b/app/src/directVersion/res/xml-v21/admin.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/java/io/timelimit/android/integration/platform/DeviceOwnerApi.kt b/app/src/main/java/io/timelimit/android/integration/platform/DeviceOwnerApi.kt index acb632f..99049cc 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/DeviceOwnerApi.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/DeviceOwnerApi.kt @@ -23,4 +23,6 @@ interface DeviceOwnerApi { fun setDelegations(packageName: String, scopes: List) fun getDelegations(): Map> fun setOrganizationName(name: String) + + fun transferOwnership(packageName: String, dryRun: Boolean = false) } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidDeviceOwnerApi.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidDeviceOwnerApi.kt index 06ee2fa..5420d96 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidDeviceOwnerApi.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidDeviceOwnerApi.kt @@ -15,8 +15,11 @@ */ package io.timelimit.android.integration.platform.android +import android.app.admin.DeviceAdminReceiver import android.app.admin.DevicePolicyManager import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.util.Log @@ -25,7 +28,8 @@ import io.timelimit.android.integration.platform.DeviceOwnerApi class AndroidDeviceOwnerApi( private val componentName: ComponentName, - private val devicePolicyManager: DevicePolicyManager + private val devicePolicyManager: DevicePolicyManager, + private val packageManager: PackageManager ): DeviceOwnerApi { companion object { private const val LOG_TAG = "AndroidDeviceOwnerApi" @@ -102,4 +106,22 @@ class AndroidDeviceOwnerApi( } else throw SecurityException() } else throw SecurityException() } + + override fun transferOwnership(packageName: String, dryRun: Boolean) { + if (VERSION.SDK_INT < VERSION_CODES.P) throw IllegalStateException() + if (!devicePolicyManager.isDeviceOwnerApp(componentName.packageName)) throw SecurityException() + + val targetComponentName = packageManager.queryBroadcastReceivers( + Intent(DeviceAdminReceiver.ACTION_DEVICE_ADMIN_ENABLED) + .setPackage(packageName), + 0 + ).singleOrNull()?.activityInfo?.let { ComponentName(it.packageName, it.name) } + + if (targetComponentName == null) throw RuntimeException("no device admin in the target App found") + + if (dryRun) return + + devicePolicyManager.setDelegatedScopes(componentName, packageName, emptyList()) + devicePolicyManager.transferOwnership(componentName, targetComponentName, null) + } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt index d015595..a546450 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt @@ -823,5 +823,5 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio ) } - override val deviceOwner: DeviceOwnerApi = AndroidDeviceOwnerApi(deviceAdmin, policyManager) + override val deviceOwner: DeviceOwnerApi = AndroidDeviceOwnerApi(deviceAdmin, policyManager, context.packageManager) } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt index e3e5ade..65cc4c2 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt @@ -201,5 +201,7 @@ class DummyIntegration( override fun getDelegations(): Map> = emptyMap() override fun setOrganizationName(name: String) = throw SecurityException() + + override fun transferOwnership(packageName: String, dryRun: Boolean) = throw IllegalStateException("unsupported operation") } } diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/deviceowner/DeviceOwnerScreen.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/deviceowner/DeviceOwnerScreen.kt index a8599f4..053d01c 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/deviceowner/DeviceOwnerScreen.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/deviceowner/DeviceOwnerScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.timelimit.android.R +import io.timelimit.android.ui.diagnose.exception.SimpleErrorDialog import io.timelimit.android.ui.model.managedevice.DeviceOwnerHandling import io.timelimit.android.ui.overview.overview.ListCardCommon import io.timelimit.android.ui.overview.overview.ListCommon @@ -126,6 +127,12 @@ fun DeviceOwnerScreen( Text(scope.label) } } + + TextButton( + onClick = { screen.actions.transferOwnership(app.packageName) } + ) { + Text(stringResource(R.string.device_owner_transfer_title)) + } } } } @@ -138,7 +145,29 @@ fun DeviceOwnerScreen( Spacer(modifier = Modifier.height(8.dp)) - if (screen.appListDialog != null) AddAppDialog(screen.appListDialog, screen.actions) + when (val dialog = screen.dialog) { + is DeviceOwnerHandling.OwnerScreen.Normal.AppListDialog -> AddAppDialog(dialog, screen.actions) + is DeviceOwnerHandling.OwnerScreen.Normal.TransferOwnershipDialog -> AlertDialog( + title = { Text(stringResource(R.string.device_owner_transfer_title)) }, + text = { Text(stringResource(R.string.device_owner_transfer_text, dialog.packageName)) }, + buttons = { + TextButton(onClick = dialog.cancel) { + Text(stringResource(R.string.generic_cancel)) + } + + TextButton(onClick = dialog.confirm) { + Text(stringResource(R.string.device_owner_transfer_confirm)) + } + }, + onDismissRequest = dialog.cancel + ) + is DeviceOwnerHandling.OwnerScreen.Normal.ErrorDialog -> SimpleErrorDialog( + title = null, + message = dialog.message, + close = dialog.close + ) + null -> {} + } } } } diff --git a/app/src/main/java/io/timelimit/android/ui/model/managedevice/DeviceOwnerHandling.kt b/app/src/main/java/io/timelimit/android/ui/model/managedevice/DeviceOwnerHandling.kt index b5519bf..8230e36 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/managedevice/DeviceOwnerHandling.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/managedevice/DeviceOwnerHandling.kt @@ -17,7 +17,9 @@ package io.timelimit.android.ui.model.managedevice import android.graphics.drawable.Drawable import android.util.Log +import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHostState +import androidx.compose.material.SnackbarResult import androidx.lifecycle.asFlow import io.timelimit.android.BuildConfig import io.timelimit.android.R @@ -28,13 +30,16 @@ import io.timelimit.android.data.model.App import io.timelimit.android.data.model.Device import io.timelimit.android.integration.platform.DeviceOwnerApi import io.timelimit.android.integration.platform.PlatformIntegration +import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.logic.AppLogic +import io.timelimit.android.ui.diagnose.exception.ExceptionUtil import io.timelimit.android.ui.model.AuthenticationModelApi import io.timelimit.android.ui.model.BackStackItem import io.timelimit.android.ui.model.Screen import io.timelimit.android.ui.model.State +import io.timelimit.android.ui.model.flow.Case +import io.timelimit.android.ui.model.flow.splitConflated import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.* @@ -45,13 +50,23 @@ object DeviceOwnerHandling { private const val LOG_TAG = "DeviceOwnerHandling" data class OwnerState( - val appListDialog: AppListDialog? = null, val apps: List = emptyList(), + val dialog: Dialog? = null, val organizationName: OrganizationName = OrganizationName.Original ): Serializable { + sealed class Dialog: Serializable + data class AppListDialog( val filter: String = "" - ): Serializable + ): Dialog() + + data class TransferOwnershipDialog( + val packageName: String + ): Dialog() + + data class ErrorDialog( + val message: String + ): Dialog() } sealed class OrganizationName: Serializable { @@ -66,7 +81,7 @@ object DeviceOwnerHandling { data class Normal( val isParentAuthenticated: Boolean, val organizationName: String, - val appListDialog: AppListDialog?, + val dialog: Dialog?, val scopes: List, val apps: List, val actions: Actions @@ -78,10 +93,23 @@ object DeviceOwnerHandling { val scopes: Set ) + sealed class Dialog + data class AppListDialog( val filter: String, val apps: List - ) + ): Dialog() + + data class TransferOwnershipDialog( + val packageName: String, + val confirm: () -> Unit, + val cancel: () -> Unit + ): Dialog() + + data class ErrorDialog( + val message: String, + val close: () -> Unit + ): Dialog() data class Actions( val updateOrganizationName: ((String) -> Unit)?, @@ -89,6 +117,7 @@ object DeviceOwnerHandling { val dismissAppListDialog: () -> Unit, val addApp: (String) -> Unit, val updateScopeEnabled: (String, DeviceOwnerApi.DelegationScope, Boolean) -> Unit, + val transferOwnership: (String) -> Unit, val updateDialogSearch: (String) -> Unit ) } @@ -106,7 +135,7 @@ object DeviceOwnerHandling { val snackbarHostState = SnackbarHostState() val isMatchingDeviceLive = combine(deviceLive, logic.deviceId.asFlow()) { device, id -> - device.id == id + device.id == id && device.currentProtectionLevel == ProtectionLevel.DeviceOwner }.distinctUntilChanged() val screenLive = getScreen( @@ -124,7 +153,7 @@ object DeviceOwnerHandling { ) return combine(ownerStateLive, screenLive, backStackLive) { state, screen, backStack -> - Screen.DeviceOwnerScreen(state, screen, backStack, snackbarHostState) as Screen + Screen.DeviceOwnerScreen(state, screen, backStack, snackbarHostState) } } @@ -145,7 +174,15 @@ object DeviceOwnerHandling { try { action() } catch (ex: Exception) { - snackbarHostState.showSnackbar(logic.context.getString(R.string.error_general)) + val result = snackbarHostState.showSnackbar( + logic.context.getString(R.string.error_general), + logic.context.getString(R.string.generic_show_details), + SnackbarDuration.Short + ) + + if (result == SnackbarResult.ActionPerformed) updateState { + it.copy(dialog = OwnerState.ErrorDialog(ExceptionUtil.format(ex))) + } } } } @@ -189,17 +226,17 @@ object DeviceOwnerHandling { addApp = { packageName -> launch { if (authentication.authenticatedParentOnly.first() != null) updateState { state -> - state.copy(apps = state.apps + packageName, appListDialog = null) - } else updateState { it.copy(appListDialog = null) } + state.copy(apps = state.apps + packageName, dialog = null) + } else updateState { it.copy(dialog = null) } } }, dismissAppListDialog = { - updateState { it.copy(appListDialog = null) } + updateState { it.copy(dialog = null) } }, showAppListDialog = { launch { authentication.doParentAuthentication()?.also { - updateState { it.copy(appListDialog = OwnerState.AppListDialog()) } + updateState { it.copy(dialog = OwnerState.AppListDialog()) } } } }, @@ -215,9 +252,19 @@ object DeviceOwnerHandling { } else authentication.doParentAuthentication() } }, + transferOwnership = { packageName -> + launch { + owner.transferOwnership(packageName, dryRun = true) + + authentication.doParentAuthentication() + + updateState { it.copy(dialog = OwnerState.TransferOwnershipDialog(packageName)) } + } + }, updateDialogSearch = { filter -> updateState { - it.copy(appListDialog = it.appListDialog?.copy(filter = filter)) + if (it.dialog is OwnerState.AppListDialog) it.copy(dialog = it.dialog.copy(filter = filter)) + else it } } ) @@ -231,7 +278,9 @@ object DeviceOwnerHandling { val dialogLive = getNullableDialog( logic.platformIntegration, - state.map { it.appListDialog } + state.map { it.dialog }, + updateState, + ::launch ) emitAll( @@ -245,7 +294,7 @@ object DeviceOwnerHandling { if (isMatchingDevice) OwnerScreen.Normal( isParentAuthenticated = isParentAuthenticated, organizationName = organizationName, - appListDialog = dialog, + dialog = dialog, scopes = scopes, apps = apps, actions = actions.copy( @@ -311,20 +360,36 @@ object DeviceOwnerHandling { } } - @OptIn(ExperimentalCoroutinesApi::class) private fun getNullableDialog( integration: PlatformIntegration, - state: Flow - ): Flow { - val hasDialog = state.map { it != null }.distinctUntilChanged() + state: Flow, + updateState: ((OwnerState) -> OwnerState) -> Unit, + launch: (suspend () -> Unit) -> Unit + ): Flow = state.splitConflated( + Case.simple<_, _, OwnerState.AppListDialog> { getAppListDialog(integration, it) }, + Case.simple<_, _, OwnerState.TransferOwnershipDialog> { stateLive -> + stateLive.map { OwnerScreen.Normal.TransferOwnershipDialog( + packageName = it.packageName, + confirm = { launch { + try { + integration.deviceOwner.transferOwnership(it.packageName) + } finally { + updateState { it.copy(dialog = null) } + } + } }, + cancel = { updateState { it.copy(dialog = null) } } + ) } + }, + Case.simple<_, _, OwnerState.ErrorDialog> { stateLive -> + stateLive.map { OwnerScreen.Normal.ErrorDialog( + message = it.message, + close = { updateState { it.copy(dialog = null) } } + ) } + }, + Case.nil { flowOf(null) } + ) - return hasDialog.transformLatest { - if (it) emitAll(getDialog(integration, state.filterNotNull())) - else emit(null) - } - } - - private fun getDialog( + private fun getAppListDialog( integration: PlatformIntegration, state: Flow ): Flow { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 236d5ae..b1134c2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1779,4 +1779,10 @@ Ich verzichte ersatzlos auf eine evtl. vorhandene Restlaufzeit meiner Vollversion Löschung anfordern + + Gerätebesitzer übertragen + Soll TimeLimit die Gerätebesitzerrolle abgeben und an %s übergeben? + Das wird zu einer Manipulationswarnung führen. + + Berechtigung übertragen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e913df..29186af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1831,4 +1831,10 @@ I accept that my premium version (if it exists) will be deleted without replacement Request Deletion + + Transfer device ownership + Do you want that TimeLimit revokes this permission for itself and grants it to %s? + This will be interpreted as manipulation. + + Transfer permission