Add private space support

This commit is contained in:
MM20 2024-07-19 17:13:06 +02:00
parent 99c98e4127
commit 3993f9505c
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
11 changed files with 206 additions and 101 deletions

View File

@ -120,6 +120,7 @@ dependencies {
implementation(project(":core:i18n"))
implementation(project(":core:compat"))
implementation(project(":core:ktx"))
implementation(project(":core:profiles"))
implementation(project(":services:icons"))
implementation(project(":services:music"))
implementation(project(":services:tags"))

View File

@ -17,10 +17,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -74,8 +72,15 @@ fun SearchColumn(
val hideFavs by viewModel.hideFavorites
val favoritesEnabled by viewModel.favoritesEnabled.collectAsState(false)
val apps by viewModel.appResults
val workApps by viewModel.workAppResults
val privateApps by viewModel.privateSpaceAppResults
val workProfile by viewModel.workProfile.collectAsState(null)
val workProfileState by viewModel.workProfileState.collectAsState(null)
val privateProfile by viewModel.privateProfile.collectAsState(null)
val privateProfileState by viewModel.privateProfileState.collectAsState(null)
val appShortcuts by viewModel.appShortcutResults
val contacts by viewModel.contactResults
val files by viewModel.fileResults
@ -96,26 +101,13 @@ fun SearchColumn(
val missingContactsPermission by viewModel.missingContactsPermission.collectAsState(false)
val missingLocationPermission by viewModel.missingLocationPermission.collectAsState(false)
val missingFilesPermission by viewModel.missingFilesPermission.collectAsState(false)
val hasProfilesPermission by viewModel.hasProfilesPermission.collectAsState(false)
val pinnedTags by favoritesVM.pinnedTags.collectAsState(emptyList())
val selectedTag by favoritesVM.selectedTag.collectAsState(null)
val favoritesEditButton by favoritesVM.showEditButton.collectAsState(false)
val favoritesTagsExpanded by favoritesVM.tagsExpanded.collectAsState(false)
var showWorkProfileApps by remember { mutableStateOf(false) }
val separateWorkProfile by viewModel.separateWorkProfile.collectAsState(true)
val visibleApps by remember {
derivedStateOf {
when {
!separateWorkProfile -> (apps + workApps).sorted()
workApps.isEmpty() -> apps
apps.isEmpty() -> workApps
showWorkProfileApps -> workApps
else -> apps
}
}
}
val expandedCategory: SearchCategory? by viewModel.expandedCategory
var selectedContactIndex: Int by remember(contacts) { mutableIntStateOf(-1) }
@ -180,15 +172,43 @@ fun SearchColumn(
}
AppResults(
apps = visibleApps,
showTabs = separateWorkProfile && apps.isNotEmpty() && workApps.isNotEmpty(),
key = "apps",
apps = apps,
highlightedItem = bestMatch as? Application,
selectedTab = if (showWorkProfileApps) 1 else 0,
onSelectedTabChange = { showWorkProfileApps = it == 1 },
columns = columns,
reverse = reverse
)
if (privateProfile != null && isSearchEmpty) {
AppResults(
key = "apps-priv",
apps = privateApps,
profile = privateProfile,
isProfileLocked = privateProfileState?.locked == true,
onProfileLockChange = if (hasProfilesPermission) {
{ viewModel.setProfileLock(privateProfile, it) }
} else null,
highlightedItem = bestMatch as? Application,
columns = columns,
reverse = reverse
)
}
if (workProfile != null && isSearchEmpty) {
AppResults(
key = "apps-work",
apps = workApps,
profile = workProfile,
isProfileLocked = workProfileState?.locked == true,
onProfileLockChange = if (hasProfilesPermission) {
{ viewModel.setProfileLock(workProfile, it) }
} else null,
highlightedItem = bestMatch as? Application,
columns = columns,
reverse = reverse
)
}
if (!isSearchEmpty) {
ShortcutResults(

View File

@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.devicepose.DevicePoseProvider
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.SearchResultOrder
@ -16,6 +17,8 @@ import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.preferences.search.SearchFilterSettings
import de.mm20.launcher2.preferences.search.ShortcutSearchSettings
import de.mm20.launcher2.preferences.ui.SearchUiSettings
import de.mm20.launcher2.profiles.Profile
import de.mm20.launcher2.profiles.ProfileManager
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.Article
@ -41,6 +44,9 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -51,6 +57,7 @@ class SearchVM : ViewModel(), KoinComponent {
private val favoritesService: FavoritesService by inject()
private val searchableRepository: SavableSearchableRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val profileManager: ProfileManager by inject()
private val fileSearchSettings: FileSearchSettings by inject()
private val contactSearchSettings: ContactSearchSettings by inject()
@ -72,9 +79,37 @@ class SearchVM : ViewModel(), KoinComponent {
val expandedCategory = mutableStateOf<SearchCategory?>(null)
val locationResults = mutableStateOf<List<Location>>(emptyList())
val profiles = profileManager.profiles.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val workProfile = profiles.map {
it.find { it.type == Profile.Type.Work }
}
val privateProfile = profiles.map {
it.find { it.type == Profile.Type.Private }
}
val workProfileState = workProfile.flatMapLatest {
profileManager.getProfileState(it)
}
val privateProfileState = privateProfile.flatMapLatest {
profileManager.getProfileState(it)
}
val hasProfilesPermission = permissionsManager.hasPermission(PermissionGroup.ManageProfiles)
fun setProfileLock(profile: Profile?, locked: Boolean) {
if (isAtLeastApiLevel(28) && profile != null) {
if (locked) {
profileManager.lockProfile(profile)
} else {
profileManager.unlockProfile(profile)
}
}
}
val appResults = mutableStateOf<List<Application>>(emptyList())
val workAppResults = mutableStateOf<List<Application>>(emptyList())
val privateSpaceAppResults = mutableStateOf<List<Application>>(emptyList())
val appShortcutResults = mutableStateOf<List<AppShortcut>>(emptyList())
val fileResults = mutableStateOf<List<File>>(emptyList())
val contactResults = mutableStateOf<List<Contact>>(emptyList())

View File

@ -1,42 +1,86 @@
package de.mm20.launcher2.ui.launcher.search.apps
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Work
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.icons.PrivateSpace
import de.mm20.launcher2.profiles.Profile
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.animateCorners
import de.mm20.launcher2.ui.ktx.withCorners
import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem
import de.mm20.launcher2.ui.launcher.search.common.grid.GridResults
import de.mm20.launcher2.ui.layout.BottomReversed
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.locals.LocalGridSettings
fun LazyListScope.AppResults(
key: String,
profile: Profile? = null,
isProfileLocked: Boolean = false,
onProfileLockChange: ((Boolean) -> Unit)? = null,
apps: List<Application>,
showTabs: Boolean,
selectedTab: Int,
onSelectedTabChange: (Int) -> Unit,
highlightedItem: Application? = null,
columns: Int,
reverse: Boolean,
) {
GridResults(
key = "app",
key = key,
items = apps,
before = if (profile != null) {
{
Row(
modifier = Modifier
.padding(top = 4.dp, start = 16.dp, end = 4.dp, bottom = 4.dp)
.heightIn(min = 40.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
when (profile.type) {
Profile.Type.Work -> Icons.Rounded.Work
Profile.Type.Private -> Icons.Rounded.PrivateSpace
else -> Icons.Rounded.Person
},
null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = when (profile.type) {
Profile.Type.Personal -> stringResource(R.string.apps_profile_main)
Profile.Type.Work -> stringResource(R.string.apps_profile_work)
Profile.Type.Private -> stringResource(R.string.apps_profile_private)
},
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
)
if (onProfileLockChange != null) {
FilledIconToggleButton(checked = isProfileLocked, onCheckedChange = {
onProfileLockChange(it)
}) {
Icon(
if (isProfileLocked) Icons.Rounded.Lock else Icons.Rounded.LockOpen,
null
)
}
}
}
}
} else null,
itemContent = {
GridItem(
item = it,
@ -44,43 +88,6 @@ fun LazyListScope.AppResults(
highlight = it.key == highlightedItem?.key
)
},
before = if (showTabs) {
{
Column(
verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top,
) {
PrimaryTabRow(
selectedTabIndex = selectedTab,
modifier = Modifier
.fillMaxWidth()
.clip(
MaterialTheme.shapes.medium.withCorners(
topStart = !reverse,
topEnd = !reverse,
bottomEnd = reverse,
bottomStart = reverse,
)
),
divider = {},
containerColor = Color.Transparent
) {
Tab(
selected = selectedTab == 0,
onClick = { onSelectedTabChange(0) },
text = { Text(stringResource(R.string.apps_profile_main)) },
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
Tab(
selected = selectedTab == 1,
onClick = { onSelectedTabChange(1) },
text = { Text(stringResource(R.string.apps_profile_work)) },
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
}
}
} else null,
reverse = reverse,
columns = columns,
)

View File

@ -34,6 +34,10 @@ fun <T : SavableSearchable> LazyListScope.GridResults(
val isBottom = reverse || items.isEmpty() && after == null
Box(
modifier = Modifier
.padding(
top = if (reverse && isTop) 8.dp else 0.dp,
bottom = if (!reverse && isBottom) 8.dp else 0.dp,
)
.background(
MaterialTheme.colorScheme.surface.copy(alpha = LocalCardStyle.current.opacity),
MaterialTheme.shapes.medium.withCorners(

View File

@ -1647,4 +1647,38 @@ private val _Google = materialIcon("Icons.Rounded.Google") {
}
val Icons.Rounded.Google
get() = _Google
get() = _Google
private val _PrivateSpace = materialIcon("Icons.Rounded.PrivateSpace") {
materialPath {
moveTo(11.999784f, 1.9998779f)
lineTo(3.9997559f, 5.0002116f)
verticalLineToRelative(6.0895504f)
curveToRelative(0f, 5.049995f, 3.410033f, 9.760447f, 8.0000281f, 10.910446f)
curveToRelative(4.589996f, -1.149999f, 8.000029f, -5.860451f, 8.000029f, -10.910446f)
verticalLineTo(5.0002116f)
close()
moveToRelative(0f, 4.0002727f)
curveToRelative(1.929998f, 0f, 3.500045f, 1.5700466f, 3.500045f, 3.5000447f)
curveToRelative(0f, 1.5799987f, -1.05959f, 2.9098487f, -2.499589f, 3.3398477f)
verticalLineToRelative(2.160075f)
horizontalLineToRelative(1.999878f)
verticalLineToRelative(1.999878f)
horizontalLineTo(13.00024f)
verticalLineToRelative(0.999939f)
horizontalLineTo(10.999845f)
verticalLineTo(12.840043f)
curveTo(9.5598468f, 12.410044f, 8.5002563f, 11.090194f, 8.5002563f, 9.5001953f)
curveToRelative(0f, -1.9299981f, 1.5695297f, -3.5000447f, 3.4995277f, -3.5000447f)
close()
moveToRelative(0f, 1.9998779f)
curveToRelative(-0.827999f, 0f, -1.49965f, 0.6721676f, -1.49965f, 1.5001668f)
curveToRelative(0f, 0.8279987f, 0.671651f, 1.4996497f, 1.49965f, 1.4996497f)
curveToRelative(0.828f, 0f, 1.500167f, -0.671651f, 1.500167f, -1.4996497f)
curveToRelative(0f, -0.8279992f, -0.672167f, -1.5001668f, -1.500167f, -1.5001668f)
close()
}
}
val Icons.Rounded.PrivateSpace
get() = _PrivateSpace

View File

@ -682,6 +682,7 @@
<string name="icon_picker_filter_all_packs">All icon packs</string>
<string name="apps_profile_main">Personal</string>
<string name="apps_profile_work">Work</string>
<string name="apps_profile_private">Private space</string>
<string name="favorites">Favorites</string>
<string name="favorites_empty">Pinned and frequently used items will appear here</string>
<string name="favorites_empty_tag">There are no items with this tag</string>

View File

@ -18,7 +18,6 @@ import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.contracts.PluginContract
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -65,7 +64,7 @@ enum class PermissionGroup {
Notifications,
AppShortcuts,
Accessibility,
HiddenProfiles,
ManageProfiles,
}
internal class PermissionsManagerImpl(
@ -91,8 +90,8 @@ internal class PermissionsManagerImpl(
private val appShortcutsPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.AppShortcuts)
)
private val hiddenProfilesPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.HiddenProfiles)
private val mManageProfilesPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.ManageProfiles)
)
override fun requestPermission(context: AppCompatActivity, permissionGroup: PermissionGroup) {
@ -146,7 +145,7 @@ internal class PermissionsManagerImpl(
}
}
PermissionGroup.HiddenProfiles,
PermissionGroup.ManageProfiles,
PermissionGroup.AppShortcuts -> {
if (isAtLeastApiLevel(29)) {
val roleManager = context.getSystemService<RoleManager>()
@ -201,7 +200,7 @@ internal class PermissionsManagerImpl(
context.getSystemService<LauncherApps>()?.hasShortcutHostPermission() == true
}
PermissionGroup.HiddenProfiles -> {
PermissionGroup.ManageProfiles -> {
if (isAtLeastApiLevel(29)) {
context.getSystemService<RoleManager>()?.isRoleHeld(RoleManager.ROLE_HOME) == true
} else false
@ -222,7 +221,7 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState
PermissionGroup.AppShortcuts -> appShortcutsPermissionState
PermissionGroup.Accessibility -> accessibilityPermissionState
PermissionGroup.HiddenProfiles -> hiddenProfilesPermissionState
PermissionGroup.ManageProfiles -> mManageProfilesPermissionState
}
}
@ -241,14 +240,14 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState.value = granted
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
PermissionGroup.Accessibility -> accessibilityPermissionState.value = granted
PermissionGroup.HiddenProfiles -> hiddenProfilesPermissionState.value = granted
PermissionGroup.ManageProfiles -> mManageProfilesPermissionState.value = granted
}
}
override fun onResume() {
externalStoragePermissionState.value = checkPermissionOnce(PermissionGroup.ExternalStorage)
appShortcutsPermissionState.value = checkPermissionOnce(PermissionGroup.AppShortcuts)
hiddenProfilesPermissionState.value = checkPermissionOnce(PermissionGroup.HiddenProfiles)
mManageProfilesPermissionState.value = checkPermissionOnce(PermissionGroup.ManageProfiles)
}
override fun reportNotificationListenerState(running: Boolean) {

View File

@ -8,13 +8,11 @@ import android.content.pm.LauncherApps
import android.os.Process
import android.os.UserHandle
import android.os.UserManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.data.get
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -54,6 +52,10 @@ class ProfileManager(
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
val profiles: Flow<List<Profile>> = profileStates.map {
it.map { it.profile }
}.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
init {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@ -82,7 +84,7 @@ class ProfileManager(
)
scope.launch {
if (isAtLeastApiLevel(35)) {
permissionsManager.hasPermission(PermissionGroup.HiddenProfiles).collectLatest {
permissionsManager.hasPermission(PermissionGroup.ManageProfiles).collectLatest {
refreshProfiles()
}
} else {
@ -108,7 +110,6 @@ class ProfileManager(
)
)
}
Log.d("MM20", "Profiles: $profiles")
profileStates.value = profiles
}
}
@ -119,19 +120,16 @@ class ProfileManager(
}
}
fun getProfileState(profile: Profile): Flow<Profile.State?> {
fun getProfileState(profile: Profile?): Flow<Profile.State?> {
return profileStates.map { profiles ->
profiles.find { it.profile == profile }?.state
}
}
/**
* This only works when the launcher is installed in the primary profile.
*/
private fun getProfileType(userHandle: UserHandle): Profile.Type {
if (isAtLeastApiLevel(35)) {
val launcherUserInfo = launcherApps.getLauncherUserInfo(userHandle)
return when(launcherUserInfo?.userType) {
return when (launcherUserInfo?.userType) {
UserManager.USER_TYPE_PROFILE_PRIVATE -> Profile.Type.Private
UserManager.USER_TYPE_PROFILE_MANAGED -> Profile.Type.Work
else -> Profile.Type.Personal

View File

@ -5,6 +5,7 @@ import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Work
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.BadgeIcon
import de.mm20.launcher2.icons.PrivateSpace
import de.mm20.launcher2.profiles.Profile
import de.mm20.launcher2.profiles.ProfileManager
import de.mm20.launcher2.search.AppShortcut
@ -47,7 +48,7 @@ class ProfileBadgeProvider : BadgeProvider, KoinComponent {
)
private val PrivateProfile = Badge(
icon = BadgeIcon(Icons.Rounded.Lock)
icon = BadgeIcon(Icons.Rounded.PrivateSpace)
)
}
}

View File

@ -259,13 +259,18 @@ internal class SearchServiceImpl(
val privateSpace = profiles.find { it.type == Profile.Type.Private }
appRepository.search("", false)
.withCustomLabels(customAttributesRepository)
.map {
val grouped = it.groupBy { it.user }
val standardProfileApps =
standardProfile?.let { grouped[it.userHandle] } ?: emptyList()
val workProfileApps = workProfile?.let { grouped[it.userHandle] } ?: emptyList()
val privateSpaceApps =
privateSpace?.let { grouped[it.userHandle] } ?: emptyList()
.map { apps ->
val standardProfileApps = mutableListOf<Application>()
val workProfileApps = mutableListOf<Application>()
val privateSpaceApps = mutableListOf<Application>()
for (app in apps) {
when {
standardProfile != null && app.user == standardProfile.userHandle -> standardProfileApps.add(app)
workProfile != null && app.user == workProfile.userHandle -> workProfileApps.add(app)
privateSpace != null && app.user == privateSpace.userHandle -> privateSpaceApps.add(app)
else -> standardProfileApps.add(app)
}
}
AllAppsResults(
standardProfileApps = standardProfileApps.sorted(),