Add option to transfer the device owner role

This commit is contained in:
Jonas Lochmann 2023-06-19 02:00:00 +02:00
parent a53883e87a
commit b1a3c10f96
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
9 changed files with 164 additions and 30 deletions

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
TimeLimit Copyright <C> 2019 Jonas Lochmann TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -17,4 +17,6 @@
<uses-policies> <uses-policies>
<!-- nothing --> <!-- nothing -->
</uses-policies> </uses-policies>
<support-transfer-ownership />
</device-admin> </device-admin>

View file

@ -23,4 +23,6 @@ interface DeviceOwnerApi {
fun setDelegations(packageName: String, scopes: List<DelegationScope>) fun setDelegations(packageName: String, scopes: List<DelegationScope>)
fun getDelegations(): Map<String, List<DelegationScope>> fun getDelegations(): Map<String, List<DelegationScope>>
fun setOrganizationName(name: String) fun setOrganizationName(name: String)
fun transferOwnership(packageName: String, dryRun: Boolean = false)
} }

View file

@ -15,8 +15,11 @@
*/ */
package io.timelimit.android.integration.platform.android package io.timelimit.android.integration.platform.android
import android.app.admin.DeviceAdminReceiver
import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build.VERSION import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import android.util.Log import android.util.Log
@ -25,7 +28,8 @@ import io.timelimit.android.integration.platform.DeviceOwnerApi
class AndroidDeviceOwnerApi( class AndroidDeviceOwnerApi(
private val componentName: ComponentName, private val componentName: ComponentName,
private val devicePolicyManager: DevicePolicyManager private val devicePolicyManager: DevicePolicyManager,
private val packageManager: PackageManager
): DeviceOwnerApi { ): DeviceOwnerApi {
companion object { companion object {
private const val LOG_TAG = "AndroidDeviceOwnerApi" private const val LOG_TAG = "AndroidDeviceOwnerApi"
@ -102,4 +106,22 @@ class AndroidDeviceOwnerApi(
} else throw SecurityException() } else throw SecurityException()
} 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)
}
} }

View file

@ -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)
} }

View file

@ -201,5 +201,7 @@ class DummyIntegration(
override fun getDelegations(): Map<String, List<DeviceOwnerApi.DelegationScope>> = emptyMap() override fun getDelegations(): Map<String, List<DeviceOwnerApi.DelegationScope>> = emptyMap()
override fun setOrganizationName(name: String) = throw SecurityException() override fun setOrganizationName(name: String) = throw SecurityException()
override fun transferOwnership(packageName: String, dryRun: Boolean) = throw IllegalStateException("unsupported operation")
} }
} }

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.timelimit.android.R 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.model.managedevice.DeviceOwnerHandling
import io.timelimit.android.ui.overview.overview.ListCardCommon import io.timelimit.android.ui.overview.overview.ListCardCommon
import io.timelimit.android.ui.overview.overview.ListCommon import io.timelimit.android.ui.overview.overview.ListCommon
@ -126,6 +127,12 @@ fun DeviceOwnerScreen(
Text(scope.label) 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)) 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 -> {}
}
} }
} }
} }

View file

@ -17,7 +17,9 @@ package io.timelimit.android.ui.model.managedevice
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Log import android.util.Log
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHostState import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.R 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.data.model.Device
import io.timelimit.android.integration.platform.DeviceOwnerApi import io.timelimit.android.integration.platform.DeviceOwnerApi
import io.timelimit.android.integration.platform.PlatformIntegration import io.timelimit.android.integration.platform.PlatformIntegration
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.logic.AppLogic 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.AuthenticationModelApi
import io.timelimit.android.ui.model.BackStackItem import io.timelimit.android.ui.model.BackStackItem
import io.timelimit.android.ui.model.Screen import io.timelimit.android.ui.model.Screen
import io.timelimit.android.ui.model.State 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.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -45,13 +50,23 @@ object DeviceOwnerHandling {
private const val LOG_TAG = "DeviceOwnerHandling" private const val LOG_TAG = "DeviceOwnerHandling"
data class OwnerState( data class OwnerState(
val appListDialog: AppListDialog? = null,
val apps: List<String> = emptyList(), val apps: List<String> = emptyList(),
val dialog: Dialog? = null,
val organizationName: OrganizationName = OrganizationName.Original val organizationName: OrganizationName = OrganizationName.Original
): Serializable { ): Serializable {
sealed class Dialog: Serializable
data class AppListDialog( data class AppListDialog(
val filter: String = "" val filter: String = ""
): Serializable ): Dialog()
data class TransferOwnershipDialog(
val packageName: String
): Dialog()
data class ErrorDialog(
val message: String
): Dialog()
} }
sealed class OrganizationName: Serializable { sealed class OrganizationName: Serializable {
@ -66,7 +81,7 @@ object DeviceOwnerHandling {
data class Normal( data class Normal(
val isParentAuthenticated: Boolean, val isParentAuthenticated: Boolean,
val organizationName: String, val organizationName: String,
val appListDialog: AppListDialog?, val dialog: Dialog?,
val scopes: List<DeviceOwnerApi.DelegationScope>, val scopes: List<DeviceOwnerApi.DelegationScope>,
val apps: List<AppInfo>, val apps: List<AppInfo>,
val actions: Actions val actions: Actions
@ -78,10 +93,23 @@ object DeviceOwnerHandling {
val scopes: Set<DeviceOwnerApi.DelegationScope> val scopes: Set<DeviceOwnerApi.DelegationScope>
) )
sealed class Dialog
data class AppListDialog( data class AppListDialog(
val filter: String, val filter: String,
val apps: List<App> val apps: List<App>
) ): 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( data class Actions(
val updateOrganizationName: ((String) -> Unit)?, val updateOrganizationName: ((String) -> Unit)?,
@ -89,6 +117,7 @@ object DeviceOwnerHandling {
val dismissAppListDialog: () -> Unit, val dismissAppListDialog: () -> Unit,
val addApp: (String) -> Unit, val addApp: (String) -> Unit,
val updateScopeEnabled: (String, DeviceOwnerApi.DelegationScope, Boolean) -> Unit, val updateScopeEnabled: (String, DeviceOwnerApi.DelegationScope, Boolean) -> Unit,
val transferOwnership: (String) -> Unit,
val updateDialogSearch: (String) -> Unit val updateDialogSearch: (String) -> Unit
) )
} }
@ -106,7 +135,7 @@ object DeviceOwnerHandling {
val snackbarHostState = SnackbarHostState() val snackbarHostState = SnackbarHostState()
val isMatchingDeviceLive = combine(deviceLive, logic.deviceId.asFlow()) { device, id -> val isMatchingDeviceLive = combine(deviceLive, logic.deviceId.asFlow()) { device, id ->
device.id == id device.id == id && device.currentProtectionLevel == ProtectionLevel.DeviceOwner
}.distinctUntilChanged() }.distinctUntilChanged()
val screenLive = getScreen( val screenLive = getScreen(
@ -124,7 +153,7 @@ object DeviceOwnerHandling {
) )
return combine(ownerStateLive, screenLive, backStackLive) { state, screen, backStack -> 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 { try {
action() action()
} catch (ex: Exception) { } 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 -> addApp = { packageName ->
launch { launch {
if (authentication.authenticatedParentOnly.first() != null) updateState { state -> if (authentication.authenticatedParentOnly.first() != null) updateState { state ->
state.copy(apps = state.apps + packageName, appListDialog = null) state.copy(apps = state.apps + packageName, dialog = null)
} else updateState { it.copy(appListDialog = null) } } else updateState { it.copy(dialog = null) }
} }
}, },
dismissAppListDialog = { dismissAppListDialog = {
updateState { it.copy(appListDialog = null) } updateState { it.copy(dialog = null) }
}, },
showAppListDialog = { showAppListDialog = {
launch { launch {
authentication.doParentAuthentication()?.also { authentication.doParentAuthentication()?.also {
updateState { it.copy(appListDialog = OwnerState.AppListDialog()) } updateState { it.copy(dialog = OwnerState.AppListDialog()) }
} }
} }
}, },
@ -215,9 +252,19 @@ object DeviceOwnerHandling {
} else authentication.doParentAuthentication() } else authentication.doParentAuthentication()
} }
}, },
transferOwnership = { packageName ->
launch {
owner.transferOwnership(packageName, dryRun = true)
authentication.doParentAuthentication()
updateState { it.copy(dialog = OwnerState.TransferOwnershipDialog(packageName)) }
}
},
updateDialogSearch = { filter -> updateDialogSearch = { filter ->
updateState { 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( val dialogLive = getNullableDialog(
logic.platformIntegration, logic.platformIntegration,
state.map { it.appListDialog } state.map { it.dialog },
updateState,
::launch
) )
emitAll( emitAll(
@ -245,7 +294,7 @@ object DeviceOwnerHandling {
if (isMatchingDevice) OwnerScreen.Normal( if (isMatchingDevice) OwnerScreen.Normal(
isParentAuthenticated = isParentAuthenticated, isParentAuthenticated = isParentAuthenticated,
organizationName = organizationName, organizationName = organizationName,
appListDialog = dialog, dialog = dialog,
scopes = scopes, scopes = scopes,
apps = apps, apps = apps,
actions = actions.copy( actions = actions.copy(
@ -311,20 +360,36 @@ object DeviceOwnerHandling {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
private fun getNullableDialog( private fun getNullableDialog(
integration: PlatformIntegration, integration: PlatformIntegration,
state: Flow<OwnerState.AppListDialog?> state: Flow<OwnerState.Dialog?>,
): Flow<OwnerScreen.Normal.AppListDialog?> { updateState: ((OwnerState) -> OwnerState) -> Unit,
val hasDialog = state.map { it != null }.distinctUntilChanged() launch: (suspend () -> Unit) -> Unit
): Flow<OwnerScreen.Normal.Dialog?> = 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 { private fun getAppListDialog(
if (it) emitAll(getDialog(integration, state.filterNotNull()))
else emit(null)
}
}
private fun getDialog(
integration: PlatformIntegration, integration: PlatformIntegration,
state: Flow<OwnerState.AppListDialog> state: Flow<OwnerState.AppListDialog>
): Flow<OwnerScreen.Normal.AppListDialog> { ): Flow<OwnerScreen.Normal.AppListDialog> {

View file

@ -1779,4 +1779,10 @@
</string> </string>
<string name="account_deletion_confirmation_premium_toggle">Ich verzichte ersatzlos auf eine evtl. vorhandene Restlaufzeit meiner Vollversion</string> <string name="account_deletion_confirmation_premium_toggle">Ich verzichte ersatzlos auf eine evtl. vorhandene Restlaufzeit meiner Vollversion</string>
<string name="account_deletion_confirmation_button">Löschung anfordern</string> <string name="account_deletion_confirmation_button">Löschung anfordern</string>
<string name="device_owner_transfer_title">Gerätebesitzer übertragen</string>
<string name="device_owner_transfer_text">Soll TimeLimit die Gerätebesitzerrolle abgeben und an %s übergeben?
Das wird zu einer Manipulationswarnung führen.
</string>
<string name="device_owner_transfer_confirm">Berechtigung übertragen</string>
</resources> </resources>

View file

@ -1831,4 +1831,10 @@
</string> </string>
<string name="account_deletion_confirmation_premium_toggle">I accept that my premium version (if it exists) will be deleted without replacement</string> <string name="account_deletion_confirmation_premium_toggle">I accept that my premium version (if it exists) will be deleted without replacement</string>
<string name="account_deletion_confirmation_button">Request Deletion</string> <string name="account_deletion_confirmation_button">Request Deletion</string>
<string name="device_owner_transfer_title">Transfer device ownership</string>
<string name="device_owner_transfer_text">Do you want that TimeLimit revokes this permission for itself and grants it to %s?
This will be interpreted as manipulation.
</string>
<string name="device_owner_transfer_confirm">Transfer permission</string>
</resources> </resources>