mirror of
https://codeberg.org/timelimit/timelimit-android.git
synced 2025-10-06 03:50:23 +02:00
Add option to transfer the device owner role
This commit is contained in:
parent
a53883e87a
commit
b1a3c10f96
9 changed files with 164 additions and 30 deletions
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue