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