Feature: Respect hidden private space option (#1923)

* Hide private profile space apps from search when hide private space option is enabled, and add a search action for locking and unlocking the private space.

* Filter private space apps from favorites while hidden
This commit is contained in:
Alex Alwardt
2026-07-04 09:57:54 -04:00
committed by GitHub
parent 031359197c
commit db051292be
16 changed files with 167 additions and 17 deletions

View File

@@ -127,5 +127,6 @@ fun getSearchActionIconVector(icon: SearchActionIcon): Int {
SearchActionIcon.Game -> R.drawable.sports_esports_24px
SearchActionIcon.Note -> R.drawable.sticky_note_2_24px
SearchActionIcon.Share -> R.drawable.share_24px
SearchActionIcon.PrivateSpace -> R.drawable.encrypted_24px
}
}

View File

@@ -80,7 +80,7 @@ fun SearchColumn(
val apps = viewModel.appResults
val workApps = viewModel.workAppResults
val privateApps = viewModel.privateSpaceAppResults
val profiles by viewModel.profiles.collectAsState(emptyList())
val profiles by viewModel.visibleProfiles.collectAsState(emptyList())
val profileStates by viewModel.profileStates.collectAsState(emptyList())
val appShortcuts = viewModel.appShortcutResults
@@ -114,7 +114,7 @@ fun SearchColumn(
val expandedCategory: SearchCategory? by viewModel.expandedCategory
var selectedAppProfileIndex: Int by remember(isSearchEmpty) { mutableIntStateOf(0) }
var selectedAppProfileIndex by viewModel.selectedAppProfileIndex
var selectedAppIndex: Int by remember(query) { mutableIntStateOf(-1) }
var selectedContactIndex: Int by remember(query) { mutableIntStateOf(-1) }
var selectedFileIndex: Int by remember(query) { mutableIntStateOf(-1) }

View File

@@ -2,6 +2,7 @@ package de.mm20.launcher2.ui.launcher.search
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -91,7 +92,11 @@ class SearchVM : ViewModel(), KoinComponent {
combine(it.map { profileManager.getProfileState(it) }) {
it.toList()
}
}
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val visibleProfiles = combine(profiles, profileStates) { profs, states ->
profs.filterIndexed { i, _ -> states.getOrNull(i)?.hidden != true }
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val hasProfilesPermission = permissionsManager.hasPermission(PermissionGroup.ManageProfiles)
@@ -143,8 +148,28 @@ class SearchVM : ViewModel(), KoinComponent {
val bestMatch = mutableStateOf<Searchable?>(null)
val selectedAppProfileIndex = mutableIntStateOf(0)
init {
search("", forceRestart = true)
/*
* Handle clearing the search query when the user changes the private space
* lock from the search action chip
*/
viewModelScope.launch {
var prevPrivateLocked: Boolean? = null
combine(profiles, profileStates) { profiles, states -> profiles to states }
.collect { (profiles, states) ->
val privateIdx = profiles.indexOfFirst { it.type == Profile.Type.Private }
val isLocked = states.getOrNull(privateIdx)?.locked
if (prevPrivateLocked != null && isLocked != null && prevPrivateLocked != isLocked && searchQuery.value.isNotEmpty()) {
search("")
if (!isLocked) selectedAppProfileIndex.intValue = privateIdx
}
prevPrivateLocked = isLocked
}
}
}
fun launchBestMatchOrAction(context: Context) {

View File

@@ -41,6 +41,7 @@ data class Profile(
data class State(
val locked: Boolean = false,
val hidden: Boolean = false,
)
companion object {

View File

@@ -762,6 +762,9 @@
<string name="search_action_contact">Add to contacts</string>
<string name="search_action_open_url">View website</string>
<string name="search_action_event">Schedule event</string>
<string name="search_action_private_space">Private space</string>
<string name="search_action_private_space_lock">Lock private space</string>
<string name="search_action_private_space_unlock">Unlock private space</string>
<string name="create_search_action_title">New quick action</string>
<string name="edit_search_action_title">Edit quick action</string>
<string name="create_search_action_type">What type of action do you want to create?</string>

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.LauncherApps
import android.content.pm.LauncherUserInfo
import android.os.Process
import android.os.UserHandle
import android.os.UserManager
@@ -71,6 +72,11 @@ class ProfileManager(
it.mapNotNull { it?.profile }
}.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
val hiddenPrivateSpaceUser: Flow<UserHandle?> = profileStates.map { profiles ->
val private = profiles[2]
if (private?.state?.hidden == true) private.profile.userHandle else null
}.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
init {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -164,9 +170,16 @@ class ProfileManager(
}
private fun getProfileState(userHandle: UserHandle): Profile.State {
return Profile.State(
locked = !userManager.isUserUnlocked(userHandle),
)
val locked = !userManager.isUserUnlocked(userHandle)
val hidden = if (isAtLeastApiLevel(36) && locked) {
launcherApps.getLauncherUserInfo(userHandle)
?.getUserConfig()
?.getBoolean(LauncherUserInfo.PRIVATE_SPACE_ENTRYPOINT_HIDDEN, false)
?: false
} else {
false
}
return Profile.State(locked = locked, hidden = hidden)
}
@RequiresApi(28)

View File

@@ -45,6 +45,7 @@ import de.mm20.launcher2.database.migrations.Migration_28_29
import de.mm20.launcher2.database.migrations.Migration_29_30
import de.mm20.launcher2.database.migrations.Migration_30_31
import de.mm20.launcher2.database.migrations.Migration_31_32
import de.mm20.launcher2.database.migrations.Migration_32_33
import de.mm20.launcher2.database.migrations.Migration_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9
@@ -68,7 +69,7 @@ import java.util.UUID
ShapesEntity::class,
TransparenciesEntity::class,
TypographyEntity::class,
], version = 32, exportSchema = true
], version = 33, exportSchema = true
)
@TypeConverters(ComponentNameConverter::class)
abstract class AppDatabase : RoomDatabase() {
@@ -177,6 +178,7 @@ abstract class AppDatabase : RoomDatabase() {
Migration_29_30(),
Migration_30_31(),
Migration_31_32(),
Migration_32_33(),
).build()
if (_instance == null) _instance = instance
return instance

View File

@@ -0,0 +1,13 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_32_33 : Migration(32, 33) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"INSERT INTO `SearchAction` (`position`, `type`) VALUES " +
"((SELECT COALESCE(MIN(`position`) - 1, 0) FROM `SearchAction`), 'private_space')"
)
}
}

View File

@@ -8,6 +8,7 @@ import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.searchactions.builders.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.PrivateSpaceLockActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder
import de.mm20.launcher2.searchactions.builders.MessageActionBuilder
import de.mm20.launcher2.searchactions.builders.OpenUrlActionBuilder
@@ -60,6 +61,7 @@ internal class SearchActionRepositoryImpl(
OpenUrlActionBuilder(context),
WebsearchActionBuilder(context),
ShareActionBuilder(context),
PrivateSpaceLockActionBuilder(context),
)
return allActions

View File

@@ -0,0 +1,23 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.os.UserHandle
import android.os.UserManager
import de.mm20.launcher2.ktx.isAtLeastApiLevel
data class PrivateSpaceLockAction(
override val label: String,
val isLocked: Boolean,
val userHandle: UserHandle,
) : SearchAction {
override val icon = SearchActionIcon.PrivateSpace
override val iconColor = 0
override val customIcon = null
override fun start(context: Context) {
if (isAtLeastApiLevel(28)) {
context.getSystemService(UserManager::class.java)
?.requestQuietModeEnabled(!isLocked, userHandle)
}
}
}

View File

@@ -34,7 +34,8 @@ enum class SearchActionIcon(private val value: Int) {
Music(19),
Game(20),
Note(21),
Share(22);
Share(22),
PrivateSpace(23);
fun toInt(): Int {
return value
}

View File

@@ -0,0 +1,50 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.content.pm.LauncherApps
import android.os.UserManager
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.search.ResultScore
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.PrivateSpaceLockAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
class PrivateSpaceLockActionBuilder(
override val label: String,
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_private_space))
override val key = "private_space"
override val icon = SearchActionIcon.PrivateSpace
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (!isAtLeastApiLevel(35)) return null
val keyword = context.getString(R.string.search_action_private_space).lowercase()
val score = ResultScore.from(
query = classifiedQuery.text.lowercase(),
primaryFields = listOf(keyword),
)
if (score.score < 0.8f) return null
val launcherApps = context.getSystemService(LauncherApps::class.java) ?: return null
val userManager = context.getSystemService(UserManager::class.java) ?: return null
val privateHandle = launcherApps.profiles.firstOrNull {
launcherApps.getLauncherUserInfo(it)?.userType == UserManager.USER_TYPE_PROFILE_PRIVATE
} ?: return null
val isLocked = !userManager.isUserUnlocked(privateHandle)
return PrivateSpaceLockAction(
label = context.getString(
if (isLocked) R.string.search_action_private_space_unlock
else R.string.search_action_private_space_lock
),
isLocked = isLocked,
userHandle = privateHandle,
)
}
}

View File

@@ -71,6 +71,7 @@ interface SearchActionBuilder {
"website" -> return OpenUrlActionBuilder(context)
"websearch" -> return WebsearchActionBuilder(context)
"share" -> return ShareActionBuilder(context)
"private_space" -> return PrivateSpaceLockActionBuilder(context)
else -> return null
}
}

View File

@@ -47,6 +47,7 @@ dependencies {
implementation(project(":core:base"))
implementation(project(":core:i18n"))
implementation(project(":core:profiles"))
implementation(project(":data:searchable"))
}

View File

@@ -1,13 +1,18 @@
package de.mm20.launcher2.services.favorites
import de.mm20.launcher2.profiles.ProfileManager
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.searchable.PinnedLevel
import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.searchable.VisibilityLevel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
class FavoritesService(
private val searchableRepository: SavableSearchableRepository,
private val profileManager: ProfileManager,
) {
fun getFavorites(
includeTypes: List<String>? = null,
@@ -16,14 +21,23 @@ class FavoritesService(
maxPinnedLevel: PinnedLevel = PinnedLevel.ManuallySorted,
limit: Int = 100,
): Flow<List<SavableSearchable>> {
return searchableRepository.get(
includeTypes = includeTypes,
excludeTypes = excludeTypes,
minPinnedLevel = minPinnedLevel,
maxPinnedLevel = maxPinnedLevel,
limit = limit,
minVisibility = VisibilityLevel.SearchOnly,
)
return profileManager.activeProfiles.flatMapLatest {
combine(
searchableRepository.get(
includeTypes = includeTypes,
excludeTypes = excludeTypes,
minPinnedLevel = minPinnedLevel,
maxPinnedLevel = maxPinnedLevel,
limit = Int.MAX_VALUE,
minVisibility = VisibilityLevel.SearchOnly,
),
profileManager.hiddenPrivateSpaceUser,
) { items, hiddenUser ->
val filtered = if (hiddenUser == null) items
else items.filter { it !is Application || it.user != hiddenUser }
filtered.take(limit)
}
}
}
fun isPinned(searchable: SavableSearchable): Flow<Boolean> {

View File

@@ -3,5 +3,5 @@ package de.mm20.launcher2.services.favorites
import org.koin.dsl.module
val favoritesModule = module {
factory { FavoritesService(get()) }
factory { FavoritesService(get(), get()) }
}