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"?>
<!--
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
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -17,4 +17,6 @@
<uses-policies>
<!-- nothing -->
</uses-policies>
<support-transfer-ownership />
</device-admin>

View file

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

View file

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

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 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.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 -> {}
}
}
}
}

View file

@ -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<String> = 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<DeviceOwnerApi.DelegationScope>,
val apps: List<AppInfo>,
val actions: Actions
@ -78,10 +93,23 @@ object DeviceOwnerHandling {
val scopes: Set<DeviceOwnerApi.DelegationScope>
)
sealed class Dialog
data class AppListDialog(
val filter: String,
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(
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<OwnerState.AppListDialog?>
): Flow<OwnerScreen.Normal.AppListDialog?> {
val hasDialog = state.map { it != null }.distinctUntilChanged()
state: Flow<OwnerState.Dialog?>,
updateState: ((OwnerState) -> OwnerState) -> Unit,
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 {
if (it) emitAll(getDialog(integration, state.filterNotNull()))
else emit(null)
}
}
private fun getDialog(
private fun getAppListDialog(
integration: PlatformIntegration,
state: Flow<OwnerState.AppListDialog>
): Flow<OwnerScreen.Normal.AppListDialog> {

View file

@ -1779,4 +1779,10 @@
</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="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>

View file

@ -1831,4 +1831,10 @@
</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="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>