Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/main/java/com/monstera/harbor/HarborApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions app/src/main/java/com/monstera/harbor/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
35 changes: 26 additions & 9 deletions app/src/main/java/com/monstera/harbor/ui/HarborRoot.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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) }
Expand Down Expand Up @@ -133,6 +140,7 @@ fun HarborRoot(
WorkProfileScreen(
catalog = graph.appCatalog,
controller = graph.policyController,
crossProfilePackagePolicy = graph.crossProfilePackagePolicy,
ownPackage = context.packageName,
privilegeState = privilegeState,
iconProvider = graph.iconProvider,
Expand All @@ -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,
Expand All @@ -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."
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 66 additions & 2 deletions app/src/main/java/com/monstera/harbor/ui/WorkProfileScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -84,6 +88,7 @@ import kotlinx.coroutines.launch
fun WorkProfileScreen(
catalog: AppCatalogRepository,
controller: WorkProfileController,
crossProfilePackagePolicy: CrossProfilePackagePolicy,
ownPackage: String,
privilegeState: HarborPrivilegeState,
iconProvider: AppIconProvider,
Expand All @@ -103,6 +108,9 @@ fun WorkProfileScreen(
var sharingBusy by remember { mutableStateOf(false) }
var sharingRequested by remember { mutableStateOf(false) }
var selectedPackage by remember { mutableStateOf<PackageName?>(null) }
var crossProfileAccess by remember { mutableStateOf<CrossProfilePackageAccess?>(null) }
var crossProfileAccessError by remember { mutableStateOf<String?>(null) }
var crossProfileAccessBusy by remember { mutableStateOf(false) }
var showControls by remember { mutableStateOf(false) }
var showNavigation by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
Expand All @@ -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") }
Expand All @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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)
Expand Down
Loading
Loading