diff --git a/app/src/main/java/com/monstera/harbor/HarborApplication.kt b/app/src/main/java/com/monstera/harbor/HarborApplication.kt index 58f2cc2..8cba6ce 100644 --- a/app/src/main/java/com/monstera/harbor/HarborApplication.kt +++ b/app/src/main/java/com/monstera/harbor/HarborApplication.kt @@ -12,6 +12,8 @@ import com.monstera.harbor.core.data.AndroidPackageMetadataProvider import com.monstera.harbor.core.data.HarborPreferences import com.monstera.harbor.core.data.WorkspaceMetadataStore import com.monstera.harbor.core.policy.AndroidWorkProfileController +import com.monstera.harbor.core.policy.AndroidManagedProfileProvisioningPolicy +import com.monstera.harbor.core.policy.AndroidCrossProfilePackagePolicy import com.monstera.harbor.core.topology.ProfileTopologyDetector import com.monstera.harbor.privileged.shizuku.ShizukuPrivilegedBackend @@ -47,6 +49,10 @@ internal object FileImportReceiverAvailability { class HarborGraph(application: Application) { val admin = ComponentName(application, HarborDeviceAdminReceiver::class.java) val topologyDetector = ProfileTopologyDetector(application) + val provisioningPolicy = AndroidManagedProfileProvisioningPolicy( + application.getSystemService(DevicePolicyManager::class.java), + ) + val crossProfilePackagePolicy = AndroidCrossProfilePackagePolicy(application, admin) val policyController = AndroidWorkProfileController( context = application, admin = admin, diff --git a/app/src/main/java/com/monstera/harbor/MainActivity.kt b/app/src/main/java/com/monstera/harbor/MainActivity.kt index b01b013..d847708 100644 --- a/app/src/main/java/com/monstera/harbor/MainActivity.kt +++ b/app/src/main/java/com/monstera/harbor/MainActivity.kt @@ -21,6 +21,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri import com.monstera.harbor.ui.HarborRoot import com.monstera.harbor.ui.theme.HarborTheme +import com.monstera.harbor.core.policy.ManagedProfileProvisioningPreflight +import com.monstera.harbor.core.policy.ManagedProfileProvisioningStartResult class MainActivity : ComponentActivity() { private val provisioningLauncher = registerForActivityResult( @@ -57,11 +59,12 @@ class MainActivity : ComponentActivity() { (application as HarborApplication).graph.privilegedBackend.refresh() } - private fun beginProvisioning(graph: HarborGraph) { - val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) - .putExtra(DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, graph.admin) - provisioningLauncher.launch(intent) - } + private fun beginProvisioning(graph: HarborGraph): ManagedProfileProvisioningStartResult = + ManagedProfileProvisioningPreflight(graph.provisioningPolicy).start { + val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) + .putExtra(DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME, graph.admin) + provisioningLauncher.launch(intent) + } private fun openWorkHarbor(): Boolean { // CrossProfileApps exposes the profiles that this app can reach, rather than diff --git a/app/src/main/java/com/monstera/harbor/ui/HarborRoot.kt b/app/src/main/java/com/monstera/harbor/ui/HarborRoot.kt index 2d7d3f3..4c18659 100644 --- a/app/src/main/java/com/monstera/harbor/ui/HarborRoot.kt +++ b/app/src/main/java/com/monstera/harbor/ui/HarborRoot.kt @@ -1,6 +1,5 @@ package com.monstera.harbor.ui -import android.app.admin.DevicePolicyManager import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -20,6 +19,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.monstera.harbor.HarborGraph import com.monstera.harbor.core.policy.WorkProfileStatus +import com.monstera.harbor.core.policy.ManagedProfileProvisioningCapability +import com.monstera.harbor.core.policy.ManagedProfileProvisioningStartResult import com.monstera.harbor.core.topology.HarborPrivilegeResolver import com.monstera.harbor.core.topology.PrivilegedAvailability import com.monstera.harbor.core.topology.PrivilegedBackendState @@ -32,7 +33,7 @@ private enum class HarborDestination { HOME, ADVANCED } @Composable fun HarborRoot( graph: HarborGraph, - onProvision: () -> Unit, + onProvision: () -> ManagedProfileProvisioningStartResult, onOpenWorkHarbor: () -> Boolean, onOpenSystemSettings: () -> Unit, onLaunchPackage: (String) -> Boolean, @@ -46,6 +47,9 @@ fun HarborRoot( val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() var topology by remember { mutableStateOf(graph.topologyDetector.detect()) } + var provisioningCapability by remember { + mutableStateOf(graph.provisioningPolicy.capability()) + } val profileState by graph.policyController.observeState().collectAsStateWithLifecycle( initialValue = com.monstera.harbor.core.policy.WorkProfileState( WorkProfileStatus.NOT_PROFILE_OWNER, @@ -70,7 +74,10 @@ fun HarborRoot( DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) topology = graph.topologyDetector.detect() + if (event == Lifecycle.Event.ON_RESUME) { + topology = graph.topologyDetector.detect() + provisioningCapability = graph.provisioningPolicy.capability() + } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } @@ -133,6 +140,7 @@ fun HarborRoot( WorkProfileScreen( catalog = graph.appCatalog, controller = graph.policyController, + crossProfilePackagePolicy = graph.crossProfilePackagePolicy, ownPackage = context.packageName, privilegeState = privilegeState, iconProvider = graph.iconProvider, @@ -145,10 +153,6 @@ fun HarborRoot( onOpenPersonalHarbor = onOpenPersonalHarbor, ) } else { - val policyManager = context.getSystemService(DevicePolicyManager::class.java) - val provisioningAllowed = policyManager.isProvisioningAllowed( - DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE, - ) PersonalProfileScreen( topology = topology, privilegeState = privilegeState, @@ -167,9 +171,22 @@ fun HarborRoot( }, onRenameWorkspace = workspaceViewModel::rename, onChangeWorkspaceIcon = workspaceViewModel::setIcon, - provisioningAllowed = provisioningAllowed, + provisioningCapability = provisioningCapability, message = message, - onProvision = onProvision, + onProvision = { + when (val result = onProvision()) { + ManagedProfileProvisioningStartResult.Started -> message = null + is ManagedProfileProvisioningStartResult.Blocked -> { + provisioningCapability = result.capability + message = when (result.capability.reason) { + com.monstera.harbor.core.policy.ManagedProfileProvisioningBlockReason.ANDROID_MANAGEMENT_STATE -> + "Android's current device-management state prevents Harbor from creating its normal Work profile." + com.monstera.harbor.core.policy.ManagedProfileProvisioningBlockReason.CAPABILITY_CHECK_FAILED -> + "Harbor could not verify that Android currently permits Work profile setup." + } + } + } + }, onOpenWorkHarbor = { message = if (onOpenWorkHarbor()) null else "Open the work-badged Harbor icon from your launcher." }, diff --git a/app/src/main/java/com/monstera/harbor/ui/PersonalProfileScreen.kt b/app/src/main/java/com/monstera/harbor/ui/PersonalProfileScreen.kt index d9faf5c..47645b5 100644 --- a/app/src/main/java/com/monstera/harbor/ui/PersonalProfileScreen.kt +++ b/app/src/main/java/com/monstera/harbor/ui/PersonalProfileScreen.kt @@ -46,6 +46,7 @@ import com.monstera.harbor.core.topology.HarborPrivilegeState import com.monstera.harbor.core.topology.ProfileOwnership import com.monstera.harbor.core.topology.ProfileTopology import com.monstera.harbor.core.topology.SystemUser +import com.monstera.harbor.core.policy.ManagedProfileProvisioningCapability import com.monstera.harbor.ui.designsystem.HarborBottomBar import com.monstera.harbor.ui.designsystem.HarborColors import com.monstera.harbor.ui.designsystem.HarborHeroBackground @@ -85,7 +86,7 @@ fun PersonalProfileScreen( onCreateWorkspace: (String) -> Unit, onRenameWorkspace: (SystemUser, String?) -> Unit, onChangeWorkspaceIcon: (SystemUser, WorkspaceIconKey) -> Unit, - provisioningAllowed: Boolean, + provisioningCapability: ManagedProfileProvisioningCapability, message: String?, onProvision: () -> Unit, onOpenWorkHarbor: () -> Unit, @@ -135,9 +136,9 @@ fun PersonalProfileScreen( val associatedHarbor = topology.associatedProfiles.any { !it.isCurrent && it.ownership == ProfileOwnership.HARBOR_INSTALLED_OWNER_UNKNOWN } val hasForeignOrUnknownProfile = topology.associatedProfiles.any { !it.isCurrent && it.ownership == ProfileOwnership.FOREIGN_OR_UNKNOWN } - val presentation = workSpacePresentation(associatedHarbor, hasForeignOrUnknownProfile, provisioningAllowed) + val presentation = workSpacePresentation(associatedHarbor, hasForeignOrUnknownProfile, provisioningCapability) val ready = associatedHarbor - val resolvedHeroActions = personalHeroActions(associatedHarbor, hasForeignOrUnknownProfile, provisioningAllowed) + val resolvedHeroActions = personalHeroActions(associatedHarbor, hasForeignOrUnknownProfile, provisioningCapability) fun heroAction(action: PersonalHeroAction): HeroQuickAction = when (action) { PersonalHeroAction.OPEN_WORK -> HeroQuickAction("Open Work", HarborIconKind.Work, onOpenWorkHarbor) diff --git a/app/src/main/java/com/monstera/harbor/ui/WorkProfileScreen.kt b/app/src/main/java/com/monstera/harbor/ui/WorkProfileScreen.kt index 416a4a0..dce0aea 100644 --- a/app/src/main/java/com/monstera/harbor/ui/WorkProfileScreen.kt +++ b/app/src/main/java/com/monstera/harbor/ui/WorkProfileScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -57,6 +58,8 @@ import com.monstera.harbor.core.data.AppCatalogRepository import com.monstera.harbor.core.data.AppIconProvider import com.monstera.harbor.core.data.ManagedApp import com.monstera.harbor.core.policy.PolicyResult +import com.monstera.harbor.core.policy.CrossProfilePackageAccess +import com.monstera.harbor.core.policy.CrossProfilePackagePolicy import com.monstera.harbor.core.policy.WorkProfileController import com.monstera.harbor.core.topology.HarborPrivilegeState import com.monstera.harbor.core.topology.PackageName @@ -74,6 +77,7 @@ import com.monstera.harbor.ui.designsystem.StatusTone import com.monstera.harbor.ui.privacy.WorkAppRowClickIntent import com.monstera.harbor.ui.privacy.appStatusLabel import com.monstera.harbor.ui.privacy.appStatusTone +import com.monstera.harbor.ui.privacy.crossProfileAccessPresentation import com.monstera.harbor.ui.privacy.launcherShortcutActionLabel import com.monstera.harbor.ui.privacy.workAppCountLabel import com.monstera.harbor.ui.privacy.workAppRowClickIntent @@ -84,6 +88,7 @@ import kotlinx.coroutines.launch fun WorkProfileScreen( catalog: AppCatalogRepository, controller: WorkProfileController, + crossProfilePackagePolicy: CrossProfilePackagePolicy, ownPackage: String, privilegeState: HarborPrivilegeState, iconProvider: AppIconProvider, @@ -103,6 +108,9 @@ fun WorkProfileScreen( var sharingBusy by remember { mutableStateOf(false) } var sharingRequested by remember { mutableStateOf(false) } var selectedPackage by remember { mutableStateOf(null) } + var crossProfileAccess by remember { mutableStateOf(null) } + var crossProfileAccessError by remember { mutableStateOf(null) } + var crossProfileAccessBusy by remember { mutableStateOf(false) } var showControls by remember { mutableStateOf(false) } var showNavigation by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -111,13 +119,27 @@ fun WorkProfileScreen( LaunchedEffect(uiState.message) { uiState.message?.let { snackbar.showSnackbar(it); viewModel.clearMessage() } } LaunchedEffect(uiState.operationInProgress, uiState.selectedPackages) { if (!uiState.operationInProgress && uiState.selectedPackages.isEmpty()) selectionMode = false } + LaunchedEffect(selectedPackage) { + crossProfileAccess = null + crossProfileAccessError = null + crossProfileAccessBusy = false + selectedPackage?.let { packageName -> + when (val result = crossProfilePackagePolicy.accessFor(packageName)) { + is PolicyResult.Success -> crossProfileAccess = result.value + is PolicyResult.Failure -> crossProfileAccessError = result.reason + } + } + } selectedApp?.let { app -> AppActionSheet( app = app, iconProvider = iconProvider, sheetState = sheetState, - busy = uiState.operationInProgress, + busy = uiState.operationInProgress || crossProfileAccessBusy, + crossProfileAccess = crossProfileAccess, + crossProfileAccessError = crossProfileAccessError, + crossProfileAccessBusy = crossProfileAccessBusy, onDismiss = { selectedPackage = null }, onLaunch = { if (!onLaunchPackage(app.packageName.value)) scope.launch { snackbar.showSnackbar("No launchable activity is available") } @@ -130,6 +152,28 @@ fun WorkProfileScreen( scope.launch { if (!onAddShortcut(app.packageName.value)) snackbar.showSnackbar("This launcher cannot pin Harbor shortcuts") } selectedPackage = null }, + onToggleCrossProfileAccess = { + val enabled = crossProfileAccess != CrossProfilePackageAccess.Enabled + crossProfileAccessBusy = true + scope.launch { + when (val result = crossProfilePackagePolicy.setAccess(app.packageName, enabled)) { + is PolicyResult.Success -> { + crossProfileAccess = result.value + snackbar.showSnackbar( + if (enabled) { + "Allowed for ${app.label}; Android still requires user consent" + } else { + "Cross-profile access is off for ${app.label}" + }, + ) + } + is PolicyResult.Failure -> { + snackbar.showSnackbar(result.reason) + } + } + crossProfileAccessBusy = false + } + }, ) } if (showControls) { @@ -286,7 +330,12 @@ private fun EmptyWorkState(queryBlank: Boolean) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AppActionSheet(app: ManagedApp, iconProvider: AppIconProvider, sheetState: SheetState, busy: Boolean, onDismiss: () -> Unit, onLaunch: () -> Unit, onToggleHidden: () -> Unit, onDetails: () -> Unit, onUninstall: () -> Unit, onAddShortcut: () -> Unit) { +private fun AppActionSheet(app: ManagedApp, iconProvider: AppIconProvider, sheetState: SheetState, busy: Boolean, crossProfileAccess: CrossProfilePackageAccess?, crossProfileAccessError: String?, crossProfileAccessBusy: Boolean, onDismiss: () -> Unit, onLaunch: () -> Unit, onToggleHidden: () -> Unit, onDetails: () -> Unit, onUninstall: () -> Unit, onAddShortcut: () -> Unit, onToggleCrossProfileAccess: () -> Unit) { + val crossProfilePresentation = crossProfileAccessPresentation( + access = crossProfileAccess, + error = crossProfileAccessError, + busy = busy, + ) ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = HarborColors.sheet, dragHandle = { Box(Modifier.padding(top = 10.dp).size(width = 44.dp, height = 4.dp).clip(RoundedCornerShape(50)).background(HarborColors.textSecondary)) }) { Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { Row(Modifier.fillMaxWidth().padding(bottom = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { @@ -295,6 +344,21 @@ private fun AppActionSheet(app: ManagedApp, iconProvider: AppIconProvider, sheet } if (app.isLaunchable && !app.isHidden) Direction2ActionRow(HarborIconKind.Open, "Open", null, !busy, onLaunch) if (!app.isSystem) Direction2ActionRow(HarborIconKind.Freeze, if (app.isHidden) "Unfreeze" else "Freeze", if (app.isHidden) "Make the app available again" else "Prevent the app from running", !busy, onToggleHidden, trailing = { Switch(checked = app.isHidden, onCheckedChange = { onToggleHidden() }, enabled = !busy) }) + Direction2ActionRow( + icon = HarborIconKind.Work, + title = "Cross-profile access", + body = crossProfilePresentation.body, + enabled = crossProfilePresentation.toggleEnabled, + onClick = onToggleCrossProfileAccess, + trailing = { + Switch( + checked = crossProfilePresentation.checked, + onCheckedChange = null, + enabled = crossProfilePresentation.toggleEnabled, + modifier = Modifier.clearAndSetSemantics { }, + ) + }, + ) if (app.isLaunchable) Direction2ActionRow(HarborIconKind.Shortcut, launcherShortcutActionLabel(app.isHidden), null, !busy, onAddShortcut) Direction2ActionRow(HarborIconKind.Details, "App details", null, !busy, onDetails) if (!app.isSystem) Direction2ActionRow(HarborIconKind.Uninstall, "Uninstall", null, !busy, onUninstall, tint = HarborColors.danger) diff --git a/app/src/main/java/com/monstera/harbor/ui/privacy/PrivacyPresentation.kt b/app/src/main/java/com/monstera/harbor/ui/privacy/PrivacyPresentation.kt index e167c38..52a32d0 100644 --- a/app/src/main/java/com/monstera/harbor/ui/privacy/PrivacyPresentation.kt +++ b/app/src/main/java/com/monstera/harbor/ui/privacy/PrivacyPresentation.kt @@ -1,6 +1,7 @@ package com.monstera.harbor.ui.privacy import com.monstera.harbor.core.data.ManagedApp +import com.monstera.harbor.core.policy.ManagedProfileProvisioningCapability import com.monstera.harbor.ui.designsystem.HarborIconKind import com.monstera.harbor.ui.designsystem.PrivacyFact import com.monstera.harbor.ui.designsystem.StatusTone @@ -27,19 +28,19 @@ internal data class PersonalHeroActions( internal fun personalHeroActions( harborManagedProfile: Boolean, foreignProfile: Boolean, - provisioningAllowed: Boolean, + provisioningCapability: ManagedProfileProvisioningCapability, ): PersonalHeroActions = when { harborManagedProfile -> PersonalHeroActions( primary = PersonalHeroAction.OPEN_WORK, quickLeft = PersonalHeroAction.SEND_FILES, quickRight = PersonalHeroAction.ADVANCED, ) - foreignProfile && provisioningAllowed -> PersonalHeroActions( + foreignProfile && provisioningCapability is ManagedProfileProvisioningCapability.Allowed -> PersonalHeroActions( primary = PersonalHeroAction.PROVISION_WORK, quickLeft = null, quickRight = PersonalHeroAction.ADVANCED, ) - !foreignProfile && provisioningAllowed -> PersonalHeroActions( + !foreignProfile && provisioningCapability is ManagedProfileProvisioningCapability.Allowed -> PersonalHeroActions( primary = PersonalHeroAction.PROVISION_WORK, quickLeft = null, quickRight = PersonalHeroAction.ADVANCED, @@ -54,14 +55,14 @@ internal fun personalHeroActions( fun workSpacePresentation( harborManagedProfile: Boolean, foreignProfile: Boolean, - provisioningAllowed: Boolean, + provisioningCapability: ManagedProfileProvisioningCapability, ): WorkSpacePresentation = when { harborManagedProfile -> WorkSpacePresentation( title = "Work space is ready", body = "Your work apps and data stay separate from your personal apps.", tone = StatusTone.Positive, ) - foreignProfile && provisioningAllowed -> WorkSpacePresentation( + foreignProfile && provisioningCapability is ManagedProfileProvisioningCapability.Allowed -> WorkSpacePresentation( title = "Work space setup is available", body = "Another profile exists, but Harbor does not manage it.", tone = StatusTone.Warning, @@ -71,14 +72,14 @@ fun workSpacePresentation( body = "Android has another profile and does not currently allow Harbor to create one.", tone = StatusTone.Warning, ) - provisioningAllowed -> WorkSpacePresentation( + provisioningCapability is ManagedProfileProvisioningCapability.Allowed -> WorkSpacePresentation( title = "Set up your Work space", body = "Keep selected apps and their data separate from your personal apps.", tone = StatusTone.Neutral, ) else -> WorkSpacePresentation( title = "Work profile setup is unavailable", - body = "Android does not currently allow another Work profile.", + body = "Android's current device-management state prevents Harbor from creating its normal Work profile.", tone = StatusTone.Warning, ) } diff --git a/app/src/main/java/com/monstera/harbor/ui/privacy/WorkAppPresentation.kt b/app/src/main/java/com/monstera/harbor/ui/privacy/WorkAppPresentation.kt index 353f7f0..7f7cec3 100644 --- a/app/src/main/java/com/monstera/harbor/ui/privacy/WorkAppPresentation.kt +++ b/app/src/main/java/com/monstera/harbor/ui/privacy/WorkAppPresentation.kt @@ -1,5 +1,7 @@ package com.monstera.harbor.ui.privacy +import com.monstera.harbor.core.policy.CrossProfilePackageAccess + /** * Pure presentation helpers for Work app interaction and copy. * @@ -32,3 +34,41 @@ internal fun workAppCountLabel( internal fun launcherShortcutActionLabel(isHidden: Boolean): String = if (isHidden) "Add & unfreeze" else "Add to launcher" + +internal data class CrossProfileAccessPresentation( + val body: String, + val checked: Boolean, + val toggleEnabled: Boolean, +) + +internal fun crossProfileAccessPresentation( + access: CrossProfilePackageAccess?, + error: String?, + busy: Boolean, +): CrossProfileAccessPresentation = when { + error != null -> CrossProfileAccessPresentation( + body = "Unavailable · $error", + checked = false, + toggleEnabled = false, + ) + access == null -> CrossProfileAccessPresentation( + body = "Checking Android policy…", + checked = false, + toggleEnabled = false, + ) + access == CrossProfilePackageAccess.Unsupported -> CrossProfileAccessPresentation( + body = "Requires Android 11 or newer", + checked = false, + toggleEnabled = false, + ) + access == CrossProfilePackageAccess.Enabled -> CrossProfileAccessPresentation( + body = "Allowed by Harbor · Android consent is still required", + checked = true, + toggleEnabled = !busy, + ) + else -> CrossProfileAccessPresentation( + body = "Off · Android consent is still required", + checked = false, + toggleEnabled = !busy, + ) +} diff --git a/app/src/test/java/com/monstera/harbor/ui/privacy/PrivacyPresentationTest.kt b/app/src/test/java/com/monstera/harbor/ui/privacy/PrivacyPresentationTest.kt index 63e5402..d607a23 100644 --- a/app/src/test/java/com/monstera/harbor/ui/privacy/PrivacyPresentationTest.kt +++ b/app/src/test/java/com/monstera/harbor/ui/privacy/PrivacyPresentationTest.kt @@ -1,6 +1,8 @@ package com.monstera.harbor.ui.privacy import com.monstera.harbor.core.data.ManagedApp +import com.monstera.harbor.core.policy.ManagedProfileProvisioningBlockReason +import com.monstera.harbor.core.policy.ManagedProfileProvisioningCapability import com.monstera.harbor.core.topology.PackageName import com.monstera.harbor.ui.designsystem.StatusTone import org.junit.Assert.assertEquals @@ -13,7 +15,7 @@ class PrivacyPresentationTest { val result = workSpacePresentation( harborManagedProfile = true, foreignProfile = false, - provisioningAllowed = false, + provisioningCapability = blockedProvisioning, ) assertEquals("Work space is ready", result.title) @@ -25,7 +27,7 @@ class PrivacyPresentationTest { val result = workSpacePresentation( harborManagedProfile = false, foreignProfile = true, - provisioningAllowed = false, + provisioningCapability = blockedProvisioning, ) assertEquals("Work profile setup is unavailable", result.title) @@ -37,7 +39,7 @@ class PrivacyPresentationTest { val result = workSpacePresentation( harborManagedProfile = false, foreignProfile = false, - provisioningAllowed = true, + provisioningCapability = ManagedProfileProvisioningCapability.Allowed, ) assertEquals("Set up your Work space", result.title) @@ -49,7 +51,7 @@ class PrivacyPresentationTest { val result = personalHeroActions( harborManagedProfile = true, foreignProfile = false, - provisioningAllowed = false, + provisioningCapability = blockedProvisioning, ) assertEquals(PersonalHeroAction.OPEN_WORK, result.primary) @@ -63,7 +65,7 @@ class PrivacyPresentationTest { val result = personalHeroActions( harborManagedProfile = false, foreignProfile = false, - provisioningAllowed = true, + provisioningCapability = ManagedProfileProvisioningCapability.Allowed, ) assertEquals(PersonalHeroAction.PROVISION_WORK, result.primary) @@ -77,7 +79,7 @@ class PrivacyPresentationTest { val result = personalHeroActions( harborManagedProfile = false, foreignProfile = true, - provisioningAllowed = true, + provisioningCapability = ManagedProfileProvisioningCapability.Allowed, ) assertEquals(PersonalHeroAction.PROVISION_WORK, result.primary) @@ -91,7 +93,7 @@ class PrivacyPresentationTest { val result = personalHeroActions( harborManagedProfile = false, foreignProfile = true, - provisioningAllowed = false, + provisioningCapability = blockedProvisioning, ) assertNull(result.primary) @@ -105,7 +107,7 @@ class PrivacyPresentationTest { val result = personalHeroActions( harborManagedProfile = false, foreignProfile = false, - provisioningAllowed = false, + provisioningCapability = blockedProvisioning, ) assertNull(result.primary) @@ -134,4 +136,10 @@ class PrivacyPresentationTest { isHidden = hidden, isLaunchable = true, ) + + private companion object { + val blockedProvisioning = ManagedProfileProvisioningCapability.Blocked( + ManagedProfileProvisioningBlockReason.ANDROID_MANAGEMENT_STATE, + ) + } } diff --git a/app/src/test/java/com/monstera/harbor/ui/privacy/WorkAppPresentationTest.kt b/app/src/test/java/com/monstera/harbor/ui/privacy/WorkAppPresentationTest.kt index 7080950..25a4d08 100644 --- a/app/src/test/java/com/monstera/harbor/ui/privacy/WorkAppPresentationTest.kt +++ b/app/src/test/java/com/monstera/harbor/ui/privacy/WorkAppPresentationTest.kt @@ -1,6 +1,9 @@ package com.monstera.harbor.ui.privacy +import com.monstera.harbor.core.policy.CrossProfilePackageAccess import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class WorkAppPresentationTest { @@ -53,4 +56,54 @@ class WorkAppPresentationTest { fun normalShortcutCopyUsesStandardLabel() { assertEquals("Add to launcher", launcherShortcutActionLabel(isHidden = false)) } + + @Test + fun crossProfileAccessDefaultsToExplicitlyOff() { + val result = crossProfileAccessPresentation( + access = CrossProfilePackageAccess.Disabled, + error = null, + busy = false, + ) + + assertEquals("Off · Android consent is still required", result.body) + assertFalse(result.checked) + assertTrue(result.toggleEnabled) + } + + @Test + fun crossProfileAccessExplainsAndroidConsentWhenEnabled() { + val result = crossProfileAccessPresentation( + access = CrossProfilePackageAccess.Enabled, + error = null, + busy = false, + ) + + assertEquals("Allowed by Harbor · Android consent is still required", result.body) + assertTrue(result.checked) + assertTrue(result.toggleEnabled) + } + + @Test + fun unsupportedCrossProfileAccessCannotBeToggled() { + val result = crossProfileAccessPresentation( + access = CrossProfilePackageAccess.Unsupported, + error = null, + busy = false, + ) + + assertEquals("Requires Android 11 or newer", result.body) + assertFalse(result.toggleEnabled) + } + + @Test + fun crossProfileAccessCannotBeToggledDuringAnotherPolicyOperation() { + val result = crossProfileAccessPresentation( + access = CrossProfilePackageAccess.Disabled, + error = null, + busy = true, + ) + + assertFalse(result.checked) + assertFalse(result.toggleEnabled) + } } diff --git a/core/policy/src/main/java/com/monstera/harbor/core/policy/CrossProfilePackagePolicy.kt b/core/policy/src/main/java/com/monstera/harbor/core/policy/CrossProfilePackagePolicy.kt new file mode 100644 index 0000000..55a6516 --- /dev/null +++ b/core/policy/src/main/java/com/monstera/harbor/core/policy/CrossProfilePackagePolicy.kt @@ -0,0 +1,103 @@ +package com.monstera.harbor.core.policy + +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Context +import android.os.Build +import com.monstera.harbor.core.topology.PackageName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +sealed interface CrossProfilePackageAccess { + data object Enabled : CrossProfilePackageAccess + data object Disabled : CrossProfilePackageAccess + data object Unsupported : CrossProfilePackageAccess +} + +interface CrossProfilePackagePolicy { + suspend fun accessFor(packageName: PackageName): PolicyResult + + suspend fun setAccess( + packageName: PackageName, + enabled: Boolean, + ): PolicyResult +} + +internal fun updatedCrossProfilePackages( + current: Set, + packageName: PackageName, + enabled: Boolean, +): Set = if (enabled) { + current + packageName.value +} else { + current - packageName.value +} + +internal fun crossProfilePackageAccess( + apiLevel: Int, + current: Set, + packageName: PackageName, +): CrossProfilePackageAccess = when { + apiLevel < Build.VERSION_CODES.R -> CrossProfilePackageAccess.Unsupported + packageName.value in current -> CrossProfilePackageAccess.Enabled + else -> CrossProfilePackageAccess.Disabled +} + +class AndroidCrossProfilePackagePolicy( + context: Context, + private val admin: ComponentName, +) : CrossProfilePackagePolicy { + private val policyManager = context.getSystemService(DevicePolicyManager::class.java) + private val packageName = context.packageName + + override suspend fun accessFor( + packageName: PackageName, + ): PolicyResult = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return@withContext PolicyResult.Success(CrossProfilePackageAccess.Unsupported) + } + if (!isOwner()) { + return@withContext PolicyResult.Failure("Harbor is not the profile owner", false) + } + runCatching { + crossProfilePackageAccess( + apiLevel = Build.VERSION.SDK_INT, + current = policyManager.getCrossProfilePackages(admin), + packageName = packageName, + ) + }.fold( + onSuccess = { PolicyResult.Success(it) }, + onFailure = { PolicyResult.Failure(it.message ?: it.javaClass.simpleName) }, + ) + } + + override suspend fun setAccess( + packageName: PackageName, + enabled: Boolean, + ): PolicyResult = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return@withContext PolicyResult.Failure( + "Cross-profile access requires Android 11 or newer", + false, + ) + } + if (!isOwner()) { + return@withContext PolicyResult.Failure("Harbor is not the profile owner", false) + } + runCatching { + val current = policyManager.getCrossProfilePackages(admin) + val updated = updatedCrossProfilePackages(current, packageName, enabled) + if (updated != current) { + // This setter replaces the complete profile-owner allowlist. Always + // derive the update from Android's latest set so unrelated entries survive. + policyManager.setCrossProfilePackages(admin, updated) + } + if (enabled) CrossProfilePackageAccess.Enabled else CrossProfilePackageAccess.Disabled + }.fold( + onSuccess = { PolicyResult.Success(it) }, + onFailure = { PolicyResult.Failure(it.message ?: it.javaClass.simpleName) }, + ) + } + + private fun isOwner(): Boolean = policyManager.isProfileOwnerApp(packageName) +} diff --git a/core/policy/src/main/java/com/monstera/harbor/core/policy/ManagedProfileProvisioningPolicy.kt b/core/policy/src/main/java/com/monstera/harbor/core/policy/ManagedProfileProvisioningPolicy.kt new file mode 100644 index 0000000..86cf1fa --- /dev/null +++ b/core/policy/src/main/java/com/monstera/harbor/core/policy/ManagedProfileProvisioningPolicy.kt @@ -0,0 +1,69 @@ +package com.monstera.harbor.core.policy + +import android.app.admin.DevicePolicyManager + +enum class ManagedProfileProvisioningBlockReason { + ANDROID_MANAGEMENT_STATE, + CAPABILITY_CHECK_FAILED, +} + +sealed interface ManagedProfileProvisioningCapability { + data object Allowed : ManagedProfileProvisioningCapability + + data class Blocked( + val reason: ManagedProfileProvisioningBlockReason, + ) : ManagedProfileProvisioningCapability +} + +interface ManagedProfileProvisioningPolicy { + fun capability(): ManagedProfileProvisioningCapability +} + +class AndroidManagedProfileProvisioningPolicy internal constructor( + private val isProvisioningAllowed: (String) -> Boolean, +) : ManagedProfileProvisioningPolicy { + constructor(policyManager: DevicePolicyManager) : this(policyManager::isProvisioningAllowed) + + override fun capability(): ManagedProfileProvisioningCapability = runCatching { + isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) + }.fold( + onSuccess = { allowed -> + if (allowed) { + ManagedProfileProvisioningCapability.Allowed + } else { + ManagedProfileProvisioningCapability.Blocked( + ManagedProfileProvisioningBlockReason.ANDROID_MANAGEMENT_STATE, + ) + } + }, + onFailure = { + ManagedProfileProvisioningCapability.Blocked( + ManagedProfileProvisioningBlockReason.CAPABILITY_CHECK_FAILED, + ) + }, + ) +} + +sealed interface ManagedProfileProvisioningStartResult { + data object Started : ManagedProfileProvisioningStartResult + + data class Blocked( + val capability: ManagedProfileProvisioningCapability.Blocked, + ) : ManagedProfileProvisioningStartResult +} + +/** Re-checks Android's current management state immediately before launching setup. */ +class ManagedProfileProvisioningPreflight( + private val policy: ManagedProfileProvisioningPolicy, +) { + fun start(launchProvisioning: () -> Unit): ManagedProfileProvisioningStartResult = + when (val capability = policy.capability()) { + ManagedProfileProvisioningCapability.Allowed -> { + launchProvisioning() + ManagedProfileProvisioningStartResult.Started + } + + is ManagedProfileProvisioningCapability.Blocked -> + ManagedProfileProvisioningStartResult.Blocked(capability) + } +} diff --git a/core/policy/src/test/java/com/monstera/harbor/core/policy/CrossProfilePackagePolicyTest.kt b/core/policy/src/test/java/com/monstera/harbor/core/policy/CrossProfilePackagePolicyTest.kt new file mode 100644 index 0000000..49f343f --- /dev/null +++ b/core/policy/src/test/java/com/monstera/harbor/core/policy/CrossProfilePackagePolicyTest.kt @@ -0,0 +1,69 @@ +package com.monstera.harbor.core.policy + +import com.monstera.harbor.core.topology.PackageName +import org.junit.Assert.assertEquals +import org.junit.Test + +class CrossProfilePackagePolicyTest { + private val target = PackageName("com.example.target") + + @Test + fun enablingMergesTargetIntoCurrentAllowlist() { + assertEquals( + setOf("com.example.existing", target.value), + updatedCrossProfilePackages( + current = setOf("com.example.existing"), + packageName = target, + enabled = true, + ), + ) + } + + @Test + fun disablingRemovesOnlyTargetFromCurrentAllowlist() { + assertEquals( + setOf("com.example.existing", "com.example.other"), + updatedCrossProfilePackages( + current = setOf("com.example.existing", target.value, "com.example.other"), + packageName = target, + enabled = false, + ), + ) + } + + @Test + fun api29ReportsUnsupportedWithoutInspectingMembership() { + assertEquals( + CrossProfilePackageAccess.Unsupported, + crossProfilePackageAccess( + apiLevel = 29, + current = setOf(target.value), + packageName = target, + ), + ) + } + + @Test + fun api30DefaultsToDisabledWhenPackageIsAbsent() { + assertEquals( + CrossProfilePackageAccess.Disabled, + crossProfilePackageAccess( + apiLevel = 30, + current = emptySet(), + packageName = target, + ), + ) + } + + @Test + fun api30ReportsEnabledWhenPackageIsPresent() { + assertEquals( + CrossProfilePackageAccess.Enabled, + crossProfilePackageAccess( + apiLevel = 30, + current = setOf(target.value), + packageName = target, + ), + ) + } +} diff --git a/core/policy/src/test/java/com/monstera/harbor/core/policy/ManagedProfileProvisioningPolicyTest.kt b/core/policy/src/test/java/com/monstera/harbor/core/policy/ManagedProfileProvisioningPolicyTest.kt new file mode 100644 index 0000000..fd54954 --- /dev/null +++ b/core/policy/src/test/java/com/monstera/harbor/core/policy/ManagedProfileProvisioningPolicyTest.kt @@ -0,0 +1,80 @@ +package com.monstera.harbor.core.policy + +import android.app.admin.DevicePolicyManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManagedProfileProvisioningPolicyTest { + @Test + fun allowedCapabilityUsesManagedProfileProvisioningActionOnMinSdk29Path() { + var checkedAction: String? = null + val policy = AndroidManagedProfileProvisioningPolicy { action -> + checkedAction = action + true + } + + assertEquals(ManagedProfileProvisioningCapability.Allowed, policy.capability()) + assertEquals(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE, checkedAction) + } + + @Test + fun blockedCapabilityReportsAndroidManagementState() { + val policy = AndroidManagedProfileProvisioningPolicy { false } + + assertEquals( + ManagedProfileProvisioningCapability.Blocked( + ManagedProfileProvisioningBlockReason.ANDROID_MANAGEMENT_STATE, + ), + policy.capability(), + ) + } + + @Test + fun failedCapabilityCheckFailsClosed() { + val policy = AndroidManagedProfileProvisioningPolicy { + error("Device policy service unavailable") + } + + assertEquals( + ManagedProfileProvisioningCapability.Blocked( + ManagedProfileProvisioningBlockReason.CAPABILITY_CHECK_FAILED, + ), + policy.capability(), + ) + } + + @Test + fun allowedPreflightLaunchesProvisioning() { + var launched = false + val preflight = ManagedProfileProvisioningPreflight( + policy = FixedProvisioningPolicy(ManagedProfileProvisioningCapability.Allowed), + ) + + val result = preflight.start { launched = true } + + assertTrue(launched) + assertEquals(ManagedProfileProvisioningStartResult.Started, result) + } + + @Test + fun blockedPreflightDoesNotLaunchProvisioning() { + var launched = false + val capability = ManagedProfileProvisioningCapability.Blocked( + ManagedProfileProvisioningBlockReason.ANDROID_MANAGEMENT_STATE, + ) + val preflight = ManagedProfileProvisioningPreflight(FixedProvisioningPolicy(capability)) + + val result = preflight.start { launched = true } + + assertFalse(launched) + assertEquals(ManagedProfileProvisioningStartResult.Blocked(capability), result) + } + + private class FixedProvisioningPolicy( + private val value: ManagedProfileProvisioningCapability, + ) : ManagedProfileProvisioningPolicy { + override fun capability(): ManagedProfileProvisioningCapability = value + } +} diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 77461b8..e60934b 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -23,6 +23,7 @@ Planned but not yet tested: OnePlus 13. ## Conservative compatibility behavior - Freeze/unfreeze treats a `false` result from Android as a failed policy update and leaves the displayed state unchanged. +- Managed-profile setup uses `DevicePolicyManager.isProvisioningAllowed()` before offering setup and re-checks immediately before launching Android's provisioning activity. This public API predates Harbor's API 29 minimum, so the same fail-closed preflight applies across the supported range. It cannot make an unsupported device-owner or OEM management state compatible. - The work-profile catalog includes installed user apps and launchable system apps, while filtering non-launchable system components that are not user-facing applications. - Public profile discovery does not expose numeric user IDs. Shizuku commands accept only IDs parsed from a fresh system user listing or from the result of the allowlisted user-creation command and then revalidate them before use. - Package cloning is enabled only when the current full user and one active managed profile can be resolved unambiguously. A sibling profile, Private Space, malformed user-list output, or an unrecognized topology disables cloning with an explanation. @@ -30,6 +31,7 @@ Planned but not yet tested: OnePlus 13. - Advanced tools can be disabled locally; this releases Harbor's Shizuku binding but does not revoke Shizuku globally. - Harbor can clear the work-profile `DISALLOW_INSTALL_UNKNOWN_SOURCES` restriction so Android can show the normal per-source consent flow for APKs opened from Chrome, Files, F-Droid, or another source. Harbor never grants a source app permission itself; OEMs may still block sideloading. - Harbor enables the supported personal-to-work file flow by clearing `DISALLOW_SHARE_INTO_MANAGED_PROFILE` and registering explicit `ACTION_SEND`/`ACTION_SEND_MULTIPLE` filters, including an action-only fallback for OEM senders that omit a MIME type. These are generic Android share filters, so another compatible work app may also be offered as a recipient; choosing that recipient sends it the selected file, and Harbor warns before enabling sharing. Work Harbor can open the Personal Harbor main activity through public `CrossProfileApps`; file selection then occurs locally in Personal and is forwarded through Android Share to Harbor's work-only receiver. A personal app may also select the work-profile Harbor instance directly from its Share sheet. Both paths copy the granted content URI into work-profile `Downloads/Harbor`; the personal original is never deleted. Harbor does not register work-to-personal export targets. Existing profiles receive the policy when their work-profile Harbor instance starts; OEM-specific “Move to work” and work-side personal-picker commands may still be blocked even when Android sharing is enabled. +- On Android 11+, each Work app has an explicit cross-profile access control backed by `getCrossProfilePackages()` and `setCrossProfilePackages()`. Harbor only changes Android's eligibility allowlist and preserves unrelated entries; Android still owns the final consent flow. Android 10 reports this feature as unsupported. - Work-profile app icons are loaded lazily from the local package manager with an in-memory bounded cache and a generic fallback. Icon failures do not block policy actions. - Batch freeze/unfreeze runs sequentially and reports partial failures without rolling back successful operations. Selection is limited to the current filtered catalog and excludes Harbor/system entries from mutation. - Clone candidates use local package labels/icons when available and fall back to the package name. Target-installed state is queried only after fresh clone-topology validation; if the query is unavailable, Harbor shows a conservative unavailable state. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index a74e3c6..ec72ae6 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -26,21 +26,25 @@ - Partially recognized user-list output cannot authorize a privileged operation. - Package cloning requires an unambiguous current full-user/active managed-profile pair. Additional profiles disable cloning instead of being guessed as a parent or target. - Harbor and recovery-critical system packages cannot be frozen; system-app freezing is disabled in the MVP UI. +- Managed-profile setup is fail-closed: Harbor checks Android's public provisioning capability before showing setup as available and re-checks immediately before launching the system provisioning flow. - Full-user creation requires explicit confirmation. Full-user deletion is not implemented. - Root-backed Shizuku receives the same operation allowlist as ADB-backed Shizuku. - Disabling Advanced tools clears the local opt-in and releases Harbor's Shizuku UserService without changing global Shizuku permission. - APK sideloading remains Android-mediated: Harbor only clears its profile-local unknown-source restriction after the user requests it, while Android asks the source app for separate consent. Harbor does not silently approve any installer. - Cross-profile file sharing is directional: Harbor clears `DISALLOW_SHARE_INTO_MANAGED_PROFILE` and installs parent-to-managed `ACTION_SEND`/`ACTION_SEND_MULTIPLE` filters. File selection occurs in Personal Harbor or another personal app; the work-profile Harbor share receiver is disabled in Personal, accepts only granted `content://` URIs in Harbor's owned work profile, and copies them into `Downloads/Harbor`. Because the filters are generic Android share filters, other compatible work-profile apps may also be offered as recipients; choosing one of those apps sends it the selected file, and Harbor warns the user before enabling this flow. It never deletes the personal original or exposes a work-to-personal export target. +- Cross-profile app access is a separate, per-package Android 11+ policy. It is off unless the user enables it for an installed app; Harbor merges that package into Android's current profile-owner allowlist without discarding unrelated entries. Android retains the final user-consent decision, and Harbor does not request `INTERACT_ACROSS_PROFILES` for itself. - Pinned launch shortcuts are created by the Harbor instance inside the work profile. Each shortcut stores an opaque UUID mapped to a validated local package name and signer fingerprint set; the exported entry activity accepts no package or command arguments and refuses to unhide a package whose signing identity changed. - Workspace aliases and icon choices are local Harbor metadata. They are reconciled against a fresh user ID/name pair, and stale records are not silently applied to a reused Android user ID. ## Residual risks - OEM package-manager commands can behave differently or expose undocumented failures. +- Android or an OEM can refuse managed-profile provisioning because of an existing device-management state; Harbor reports that state but does not bypass it. - OEM user-list formats that Harbor cannot fully parse disable privileged user/profile operations until explicitly supported. - Harbor's conservative clone resolver can reject a legitimate work profile when any additional profile is visible because supported APIs do not expose a reliable parent mapping to the ordinary app. - A compromised Shizuku service already has privileges outside Harbor's control. - Users who enable personal-to-work file sharing intentionally weaken the isolation boundary for the files they send; the Harbor receiver copies only explicitly shared, granted content URIs into the work profile. Android/OEM file managers can still reject or reinterpret their proprietary “move” operation, so Harbor describes the supported Share flow instead. +- Allowlisting an app for cross-profile access makes it eligible to request Android's user consent; the app's own implementation and the OEM consent UI determine what cross-profile capabilities become available after consent. - `QUERY_ALL_PACKAGES` exposes local package inventory to Harbor; the inventory remains on-device. - Removing a work profile or full Android user outside Harbor is destructive. - A launcher may retain a stale pinned shortcut after an app is removed or a work profile is paused; Harbor invalidates the UUID mapping or reports the local policy failure and does not launch a package whose signer no longer matches the pinned identity.