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.Game -> R.drawable.sports_esports_24px
SearchActionIcon.Note -> R.drawable.sticky_note_2_24px SearchActionIcon.Note -> R.drawable.sticky_note_2_24px
SearchActionIcon.Share -> R.drawable.share_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 apps = viewModel.appResults
val workApps = viewModel.workAppResults val workApps = viewModel.workAppResults
val privateApps = viewModel.privateSpaceAppResults 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 profileStates by viewModel.profileStates.collectAsState(emptyList())
val appShortcuts = viewModel.appShortcutResults val appShortcuts = viewModel.appShortcutResults
@@ -114,7 +114,7 @@ fun SearchColumn(
val expandedCategory: SearchCategory? by viewModel.expandedCategory 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 selectedAppIndex: Int by remember(query) { mutableIntStateOf(-1) }
var selectedContactIndex: Int by remember(query) { mutableIntStateOf(-1) } var selectedContactIndex: Int by remember(query) { mutableIntStateOf(-1) }
var selectedFileIndex: 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 android.content.Context
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -91,7 +92,11 @@ class SearchVM : ViewModel(), KoinComponent {
combine(it.map { profileManager.getProfileState(it) }) { combine(it.map { profileManager.getProfileState(it) }) {
it.toList() 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) val hasProfilesPermission = permissionsManager.hasPermission(PermissionGroup.ManageProfiles)
@@ -143,8 +148,28 @@ class SearchVM : ViewModel(), KoinComponent {
val bestMatch = mutableStateOf<Searchable?>(null) val bestMatch = mutableStateOf<Searchable?>(null)
val selectedAppProfileIndex = mutableIntStateOf(0)
init { init {
search("", forceRestart = true) 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) { fun launchBestMatchOrAction(context: Context) {

View File

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

View File

@@ -762,6 +762,9 @@
<string name="search_action_contact">Add to contacts</string> <string name="search_action_contact">Add to contacts</string>
<string name="search_action_open_url">View website</string> <string name="search_action_open_url">View website</string>
<string name="search_action_event">Schedule event</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="create_search_action_title">New quick action</string>
<string name="edit_search_action_title">Edit 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> <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.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.LauncherApps import android.content.pm.LauncherApps
import android.content.pm.LauncherUserInfo
import android.os.Process import android.os.Process
import android.os.UserHandle import android.os.UserHandle
import android.os.UserManager import android.os.UserManager
@@ -71,6 +72,11 @@ class ProfileManager(
it.mapNotNull { it?.profile } it.mapNotNull { it?.profile }
}.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1) }.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 { init {
val receiver = object : BroadcastReceiver() { val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@@ -164,9 +170,16 @@ class ProfileManager(
} }
private fun getProfileState(userHandle: UserHandle): Profile.State { private fun getProfileState(userHandle: UserHandle): Profile.State {
return Profile.State( val locked = !userManager.isUserUnlocked(userHandle)
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) @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_29_30
import de.mm20.launcher2.database.migrations.Migration_30_31 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_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_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8 import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9 import de.mm20.launcher2.database.migrations.Migration_8_9
@@ -68,7 +69,7 @@ import java.util.UUID
ShapesEntity::class, ShapesEntity::class,
TransparenciesEntity::class, TransparenciesEntity::class,
TypographyEntity::class, TypographyEntity::class,
], version = 32, exportSchema = true ], version = 33, exportSchema = true
) )
@TypeConverters(ComponentNameConverter::class) @TypeConverters(ComponentNameConverter::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -177,6 +178,7 @@ abstract class AppDatabase : RoomDatabase() {
Migration_29_30(), Migration_29_30(),
Migration_30_31(), Migration_30_31(),
Migration_31_32(), Migration_31_32(),
Migration_32_33(),
).build() ).build()
if (_instance == null) _instance = instance if (_instance == null) _instance = instance
return 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.ktx.jsonObjectOf
import de.mm20.launcher2.searchactions.builders.CallActionBuilder import de.mm20.launcher2.searchactions.builders.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder 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.EmailActionBuilder
import de.mm20.launcher2.searchactions.builders.MessageActionBuilder import de.mm20.launcher2.searchactions.builders.MessageActionBuilder
import de.mm20.launcher2.searchactions.builders.OpenUrlActionBuilder import de.mm20.launcher2.searchactions.builders.OpenUrlActionBuilder
@@ -60,6 +61,7 @@ internal class SearchActionRepositoryImpl(
OpenUrlActionBuilder(context), OpenUrlActionBuilder(context),
WebsearchActionBuilder(context), WebsearchActionBuilder(context),
ShareActionBuilder(context), ShareActionBuilder(context),
PrivateSpaceLockActionBuilder(context),
) )
return allActions 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), Music(19),
Game(20), Game(20),
Note(21), Note(21),
Share(22); Share(22),
PrivateSpace(23);
fun toInt(): Int { fun toInt(): Int {
return value 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) "website" -> return OpenUrlActionBuilder(context)
"websearch" -> return WebsearchActionBuilder(context) "websearch" -> return WebsearchActionBuilder(context)
"share" -> return ShareActionBuilder(context) "share" -> return ShareActionBuilder(context)
"private_space" -> return PrivateSpaceLockActionBuilder(context)
else -> return null else -> return null
} }
} }

View File

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

View File

@@ -1,13 +1,18 @@
package de.mm20.launcher2.services.favorites 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.search.SavableSearchable
import de.mm20.launcher2.searchable.PinnedLevel import de.mm20.launcher2.searchable.PinnedLevel
import de.mm20.launcher2.searchable.SavableSearchableRepository import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.searchable.VisibilityLevel import de.mm20.launcher2.searchable.VisibilityLevel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
class FavoritesService( class FavoritesService(
private val searchableRepository: SavableSearchableRepository, private val searchableRepository: SavableSearchableRepository,
private val profileManager: ProfileManager,
) { ) {
fun getFavorites( fun getFavorites(
includeTypes: List<String>? = null, includeTypes: List<String>? = null,
@@ -16,14 +21,23 @@ class FavoritesService(
maxPinnedLevel: PinnedLevel = PinnedLevel.ManuallySorted, maxPinnedLevel: PinnedLevel = PinnedLevel.ManuallySorted,
limit: Int = 100, limit: Int = 100,
): Flow<List<SavableSearchable>> { ): Flow<List<SavableSearchable>> {
return searchableRepository.get( return profileManager.activeProfiles.flatMapLatest {
includeTypes = includeTypes, combine(
excludeTypes = excludeTypes, searchableRepository.get(
minPinnedLevel = minPinnedLevel, includeTypes = includeTypes,
maxPinnedLevel = maxPinnedLevel, excludeTypes = excludeTypes,
limit = limit, minPinnedLevel = minPinnedLevel,
minVisibility = VisibilityLevel.SearchOnly, 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> { fun isPinned(searchable: SavableSearchable): Flow<Boolean> {

View File

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