diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt index a4767759b..4b0e99490 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt @@ -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 } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index f92be26fa..587ec8c5c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -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) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt index 9e594e892..111570930 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt @@ -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(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) { diff --git a/core/base/src/main/java/de/mm20/launcher2/profiles/Profile.kt b/core/base/src/main/java/de/mm20/launcher2/profiles/Profile.kt index 4f74a618d..36404b78a 100644 --- a/core/base/src/main/java/de/mm20/launcher2/profiles/Profile.kt +++ b/core/base/src/main/java/de/mm20/launcher2/profiles/Profile.kt @@ -41,6 +41,7 @@ data class Profile( data class State( val locked: Boolean = false, + val hidden: Boolean = false, ) companion object { diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 4473c5088..73fab4719 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -762,6 +762,9 @@ Add to contacts View website Schedule event + Private space + Lock private space + Unlock private space New quick action Edit quick action What type of action do you want to create? diff --git a/core/profiles/src/main/java/de/mm20/launcher2/profiles/ProfileManager.kt b/core/profiles/src/main/java/de/mm20/launcher2/profiles/ProfileManager.kt index 641184905..514854aef 100644 --- a/core/profiles/src/main/java/de/mm20/launcher2/profiles/ProfileManager.kt +++ b/core/profiles/src/main/java/de/mm20/launcher2/profiles/ProfileManager.kt @@ -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 = 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) diff --git a/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt b/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt index ff147354c..01e9866e2 100644 --- a/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt +++ b/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt @@ -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 diff --git a/data/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_32_33.kt b/data/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_32_33.kt new file mode 100644 index 000000000..d7b3b46ce --- /dev/null +++ b/data/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_32_33.kt @@ -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')" + ) + } +} diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt index ec7b824c8..5ff34da87 100644 --- a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt @@ -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 diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/PrivateSpaceLockAction.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/PrivateSpaceLockAction.kt new file mode 100644 index 000000000..92d30d172 --- /dev/null +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/PrivateSpaceLockAction.kt @@ -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) + } + } +} diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt index 3b2719d6b..82cecd301 100644 --- a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt @@ -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 } diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/PrivateSpaceLockActionBuilder.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/PrivateSpaceLockActionBuilder.kt new file mode 100644 index 000000000..2ccfe644d --- /dev/null +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/PrivateSpaceLockActionBuilder.kt @@ -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, + ) + } +} diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt index c12350868..8858d0904 100644 --- a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt @@ -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 } } diff --git a/services/favorites/build.gradle.kts b/services/favorites/build.gradle.kts index ce55f6f63..149458d31 100644 --- a/services/favorites/build.gradle.kts +++ b/services/favorites/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(project(":core:base")) implementation(project(":core:i18n")) + implementation(project(":core:profiles")) implementation(project(":data:searchable")) } \ No newline at end of file diff --git a/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt b/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt index 82add75f0..34ca51da0 100644 --- a/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt +++ b/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/FavoritesService.kt @@ -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? = null, @@ -16,14 +21,23 @@ class FavoritesService( maxPinnedLevel: PinnedLevel = PinnedLevel.ManuallySorted, limit: Int = 100, ): Flow> { - 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 { diff --git a/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/Module.kt b/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/Module.kt index a455c66d3..844a782f1 100644 --- a/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/Module.kt +++ b/services/favorites/src/main/java/de/mm20/launcher2/services/favorites/Module.kt @@ -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()) } } \ No newline at end of file