From 1bb58784577bc906b5d7c590b2f0d3caaeeaf69b Mon Sep 17 00:00:00 2001 From: MrSluffy Date: Sun, 16 Nov 2025 11:55:24 +0800 Subject: [PATCH] feat(deck): app categorization to folders using flowerpot - Categorize apps using flowerpot and organize into folders - Add blocking loading dialog during categorization process - Prevent user cancellation during workspace setup - Auto-add newly installed apps to appropriate folders - closes : #5846 --- .../lawnchair/deck/AddFoldersWithItemsTask.kt | 191 ++++++++++++++++++ .../src/app/lawnchair/deck/LawndeckManager.kt | 190 ++++++++++++++++- .../components/HomeLayoutPreferences.kt | 65 +++++- .../destinations/HomeScreenPreferences.kt | 2 +- .../launcher3/model/PackageUpdatedTask.java | 13 ++ 5 files changed, 452 insertions(+), 9 deletions(-) create mode 100644 lawnchair/src/app/lawnchair/deck/AddFoldersWithItemsTask.kt diff --git a/lawnchair/src/app/lawnchair/deck/AddFoldersWithItemsTask.kt b/lawnchair/src/app/lawnchair/deck/AddFoldersWithItemsTask.kt new file mode 100644 index 0000000000..0c530ffbde --- /dev/null +++ b/lawnchair/src/app/lawnchair/deck/AddFoldersWithItemsTask.kt @@ -0,0 +1,191 @@ +package app.lawnchair.deck + +import android.content.Context +import android.content.Intent +import android.os.UserHandle +import android.util.Pair +import com.android.launcher3.LauncherAppState +import com.android.launcher3.LauncherModel +import com.android.launcher3.LauncherSettings +import com.android.launcher3.model.AllAppsList +import com.android.launcher3.model.BgDataModel +import com.android.launcher3.model.ModelTaskController +import com.android.launcher3.model.WorkspaceItemSpaceFinder +import com.android.launcher3.model.data.CollectionInfo +import com.android.launcher3.model.data.FolderInfo +import com.android.launcher3.model.data.ItemInfo +import com.android.launcher3.model.data.WorkspaceItemInfo +import com.android.launcher3.util.IntArray +import com.android.launcher3.util.PackageManagerHelper + +/** + * Custom model task to add folders with their items to the workspace. + * This properly handles adding folders and then adding items to those folders. + */ +class AddFoldersWithItemsTask( + private val folders: List, + private val onComplete: (() -> Unit)? = null, +) : LauncherModel.ModelUpdateTask { + + private val itemSpaceFinder = WorkspaceItemSpaceFinder() + + override fun execute( + taskController: ModelTaskController, + dataModel: BgDataModel, + apps: AllAppsList, + ) { + if (folders.isEmpty()) { + return + } + + val context = taskController.app.context + val addedItemsFinal = ArrayList() + val addedWorkspaceScreensFinal = IntArray() + + synchronized(dataModel) { + val workspaceScreens = dataModel.collectWorkspaceScreens() + val modelWriter = taskController.getModelWriter() + + folders.forEach { folderInfo -> + // Find space for the folder + val coords = itemSpaceFinder.findSpaceForItem( + taskController.app, + dataModel, + workspaceScreens, + addedWorkspaceScreensFinal, + folderInfo.spanX, + folderInfo.spanY, + ) + val screenId = coords[0] + val cellX = coords[1] + val cellY = coords[2] + + // Add folder to database + modelWriter.addItemToDatabase( + folderInfo, + LauncherSettings.Favorites.CONTAINER_DESKTOP, + screenId, + cellX, + cellY, + ) + + // Now add items to the folder + // Items need to be added with proper rank/position + folderInfo.getContents().forEachIndexed { index, item -> + if (item is WorkspaceItemInfo) { + // Check if item already exists on workspace + if (shortcutExists(dataModel, item.intent, item.user)) { + return@forEachIndexed + } + + // Add item to folder using folder's ID as container + // Use rank as position - folder will arrange items + modelWriter.addOrMoveItemInDatabase( + item, + folderInfo.id, + 0, // screenId is 0 for items in folders + index % 4, // cellX - approximate grid position + index / 4, // cellY - approximate grid position + ) + } + } + + addedItemsFinal.add(folderInfo) + } + } + + // Schedule callback to bind items + if (addedItemsFinal.isNotEmpty()) { + taskController.scheduleCallbackTask { callbacks -> + val addAnimated = ArrayList() + val addNotAnimated = ArrayList() + + if (addedItemsFinal.isNotEmpty()) { + val lastScreenId = addedItemsFinal.last().screenId + addedItemsFinal.forEach { item -> + if (item.screenId == lastScreenId) { + addAnimated.add(item) + } else { + addNotAnimated.add(item) + } + } + } + + callbacks.bindAppsAdded( + addedWorkspaceScreensFinal, + ArrayList(addNotAnimated), + ArrayList(addAnimated), + ) + + // Notify completion after items are bound + onComplete?.invoke() + } + } else { + // No items to add, notify completion immediately + onComplete?.invoke() + } + } + + /** + * Returns true if the shortcut already exists on the workspace. + * Based on AddWorkspaceItemsTask.shortcutExists + */ + private fun shortcutExists( + dataModel: BgDataModel, + intent: Intent?, + user: UserHandle, + ): Boolean { + if (intent == null) { + return true + } + + val compPkgName: String? + val intentWithPkg: String + val intentWithoutPkg: String + + if (intent.component != null) { + compPkgName = intent.component!!.packageName + if (intent.`package` != null) { + intentWithPkg = intent.toUri(0) + intentWithoutPkg = Intent(intent).apply { `package` = null }.toUri(0) + } else { + intentWithPkg = Intent(intent).apply { `package` = compPkgName }.toUri(0) + intentWithoutPkg = intent.toUri(0) + } + } else { + compPkgName = null + intentWithPkg = intent.toUri(0) + intentWithoutPkg = intent.toUri(0) + } + + val isLauncherAppTarget = PackageManagerHelper.isLauncherAppTarget(intent) + + synchronized(dataModel) { + dataModel.itemsIdMap.forEach { existingItem -> + if (existingItem is WorkspaceItemInfo) { + val existingIntent = existingItem.intent + if (existingIntent != null && existingItem.user == user) { + val copyIntent = Intent(existingIntent) + copyIntent.sourceBounds = intent.sourceBounds + val s = copyIntent.toUri(0) + if (intentWithPkg == s || intentWithoutPkg == s) { + return true + } + + // Check for existing promise icon with same package name + if (isLauncherAppTarget && + existingItem.isPromise() && + existingItem.hasStatusFlag(WorkspaceItemInfo.FLAG_AUTOINSTALL_ICON) && + existingItem.targetComponent != null && + compPkgName != null && + compPkgName == existingItem.targetComponent!!.packageName + ) { + return true + } + } + } + } + } + return false + } +} diff --git a/lawnchair/src/app/lawnchair/deck/LawndeckManager.kt b/lawnchair/src/app/lawnchair/deck/LawndeckManager.kt index f6e3e43b21..d7a7c74c3f 100644 --- a/lawnchair/src/app/lawnchair/deck/LawndeckManager.kt +++ b/lawnchair/src/app/lawnchair/deck/LawndeckManager.kt @@ -3,15 +3,23 @@ package app.lawnchair.deck import android.content.Context import android.util.Log import app.lawnchair.LawnchairLauncher +import app.lawnchair.flowerpot.Flowerpot import app.lawnchair.launcher import app.lawnchair.launcherNullable import app.lawnchair.util.restartLauncher import com.android.launcher3.InvariantDeviceProfile +import com.android.launcher3.LauncherAppState +import com.android.launcher3.LauncherSettings import com.android.launcher3.model.ItemInstallQueue import com.android.launcher3.model.ModelDbController +import com.android.launcher3.model.data.AppInfo +import com.android.launcher3.model.data.FolderInfo +import com.android.launcher3.model.data.WorkspaceItemInfo import com.android.launcher3.provider.RestoreDbTask +import com.android.launcher3.util.ComponentKey import java.io.File import java.util.Locale +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -21,13 +29,24 @@ class LawndeckManager(private val context: Context) { private val launcher = context.launcherNullable ?: LawnchairLauncher.instance?.launcher - suspend fun enableLawndeck() = withContext(Dispatchers.IO) { + suspend fun enableLawndeck( + onProgress: ((String) -> Unit)? = null, + ) = withContext(Dispatchers.IO) { + val completionDeferred = CompletableDeferred() + if (!backupExists("bk")) createBackup("bk") if (backupExists("lawndeck")) { + onProgress?.invoke("Restoring previous layout...") restoreBackup("lawndeck") + completionDeferred.complete(Unit) } else { - addAllAppsToWorkspace() + onProgress?.invoke("Categorizing apps...") + addAllAppsToWorkspace(onProgress) { + completionDeferred.complete(Unit) + } } + + completionDeferred.await() } suspend fun disableLawndeck() = withContext(Dispatchers.IO) { @@ -70,12 +89,171 @@ class LawndeckManager(private val context: Context) { restartLauncher(context) } - private fun addAllAppsToWorkspace() { - launcher?.mAppsView?.appsStore?.apps - ?.sortedBy { it.title?.toString()?.lowercase(Locale.getDefault()) } - ?.forEach { app -> + private fun addAllAppsToWorkspace( + onProgress: ((String) -> Unit)?, + onComplete: (() -> Unit)?, + ) { + val apps = launcher?.mAppsView?.appsStore?.apps ?: return + if (apps.isEmpty()) { + onComplete?.invoke() + return + } + + onProgress?.invoke("Categorizing apps...") + + // Use flowerpot to categorize apps + val potsManager = Flowerpot.Manager.getInstance(context) + val categorizedApps = potsManager.categorizeApps(apps.map { it as? AppInfo }) + + onProgress?.invoke("Adding apps to workspace...") + + val launcher = this.launcher ?: return + val model = launcher.model + + // Collect folders to add and count single apps + val foldersToAdd = mutableListOf() + var singleAppCount = 0 + + // Process each category + categorizedApps.forEach { (category, categoryApps) -> + if (categoryApps.isEmpty()) return@forEach + + if (categoryApps.size == 1) { + // Single app - add directly to workspace + val app = categoryApps.first() ItemInstallQueue.INSTANCE.get(context).queueItem(app.targetPackage, app.user) + singleAppCount++ + } else { + // Multiple apps - create folder + onProgress?.invoke("Creating folder: $category...") + val folderInfo = createFolderInfo(category, categoryApps) + if (folderInfo != null) { + foldersToAdd.add(folderInfo) + } } + } + + // Add all folders with their items to workspace using custom task + if (foldersToAdd.isNotEmpty()) { + // Wait for folder task to complete + model.enqueueModelUpdateTask( + AddFoldersWithItemsTask(foldersToAdd) { + // Callback runs on UI thread from model task + // Also wait for ItemInstallQueue to finish for single apps + // ItemInstallQueue processes asynchronously, so we need to wait a bit + if (singleAppCount > 0) { + // Post to handler to give ItemInstallQueue time to process + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + onComplete?.invoke() + }, 800) // Wait for queue to process + } else { + onComplete?.invoke() + } + }, + ) + } else { + // No folders, but may have single apps + if (singleAppCount > 0) { + // Give ItemInstallQueue time to process + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + onComplete?.invoke() + }, 800) // Wait for queue to process + } else { + onComplete?.invoke() + } + } + } + + /** + * Adds a newly installed app to the workspace with proper categorization. + * This is called when a new app is installed and deck layout is enabled. + * + * @param packageName The package name of the newly installed app + * @param user The user handle for the app + * @param modelWriter The ModelWriter to use for database operations (must be called from model thread) + * @param dataModel The BgDataModel to search for existing folders + */ + fun addNewlyInstalledApp( + packageName: String, + user: android.os.UserHandle, + modelWriter: com.android.launcher3.model.ModelWriter, + dataModel: com.android.launcher3.model.BgDataModel, + ) { + // Get app info from LauncherApps directly (app might not be in all apps list yet) + val launcherApps = context.getSystemService(android.content.pm.LauncherApps::class.java) + ?: return + val activities = launcherApps.getActivityList(packageName, user) + if (activities.isEmpty()) return + + val activityInfo = activities[0] + val appInfo = AppInfo(context, activityInfo, user) + + // Use flowerpot to categorize the app + val potsManager = Flowerpot.Manager.getInstance(context) + val categorizedApps = potsManager.categorizeApps(listOf(appInfo)) + + if (categorizedApps.isEmpty()) { + // No category found, add directly to workspace + ItemInstallQueue.INSTANCE.get(context).queueItem(packageName, user) + return + } + + // Get the category for this app (categorizedApps is a Map>) + val categoryEntry = categorizedApps.entries.firstOrNull() ?: run { + ItemInstallQueue.INSTANCE.get(context).queueItem(packageName, user) + return + } + val category = categoryEntry.key + + // Check if there's already a folder for this category on workspace + val existingFolder = findFolderByCategory(category, dataModel) + + if (existingFolder != null) { + // Add app to existing folder + val workspaceItem = appInfo.makeWorkspaceItem(context) ?: return + existingFolder.add(workspaceItem) + // Update folder in database + modelWriter.addOrMoveItemInDatabase( + workspaceItem, + existingFolder.id, + 0, + existingFolder.getContents().size % 4, + existingFolder.getContents().size / 4, + ) + } else { + // Single app in category, add directly to workspace + // The app will be categorized properly when added + ItemInstallQueue.INSTANCE.get(context).queueItem(packageName, user) + } + } + + private fun findFolderByCategory(category: String, dataModel: com.android.launcher3.model.BgDataModel): FolderInfo? { + // Search through workspace items to find folder with matching category name + synchronized(dataModel) { + dataModel.itemsIdMap.forEach { item -> + if (item is FolderInfo && item.title?.toString() == category) { + return item + } + } + } + return null + } + + private fun createFolderInfo(categoryName: String, apps: List): FolderInfo? { + if (apps.isEmpty()) return null + + val folderInfo = FolderInfo().apply { + title = categoryName + } + + // Create workspace items for each app and add to folder + apps.forEach { app -> + val workspaceItem = app.makeWorkspaceItem(context) ?: return@forEach + folderInfo.add(workspaceItem) + } + + // Only return folder if it has items + return if (folderInfo.getContents().isNotEmpty()) folderInfo else null } private data class DatabaseFiles( diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/HomeLayoutPreferences.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/HomeLayoutPreferences.kt index d6ff670f12..d82021cf7d 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/components/HomeLayoutPreferences.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/HomeLayoutPreferences.kt @@ -9,12 +9,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf @@ -26,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.lawnchair.deck.LawndeckManager @@ -34,6 +38,7 @@ import app.lawnchair.preferences.getAdapter import app.lawnchair.preferences.preferenceManager import app.lawnchair.preferences2.preferenceManager2 import app.lawnchair.ui.preferences.components.controls.SwitchPreferenceWithPreview +import app.lawnchair.util.BackHandler import com.android.launcher3.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -55,21 +60,76 @@ fun HomeLayoutSettings( val coroutineScope = rememberCoroutineScope() var isLoading by remember { mutableStateOf(false) } + var loadingMessage by remember { mutableStateOf("") } + + // Block back button when loading + if (isLoading) { + BackHandler { + // Prevent back button during loading - do nothing + } + } + + // Show blocking loading dialog + if (isLoading) { + AlertDialog( + onDismissRequest = { + // Prevent dismissal during loading + }, + title = { + Text( + text = stringResource(R.string.home_lawn_deck_label_beta), + textAlign = TextAlign.Center, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator( + modifier = Modifier.padding(bottom = 16.dp), + ) + if (loadingMessage.isNotEmpty()) { + Text( + text = loadingMessage, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = "Please wait...", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + }, + confirmButton = {}, + dismissButton = {}, + ) + } SwitchPreferenceWithPreview( label = stringResource(R.string.layout), checked = deskLayout.state.value, onCheckedChange = { newValue -> isLoading = true + loadingMessage = "" deskLayout.onChange(newValue) if (newValue) { coroutineScope.launch { swipeUpGesture.onChange(GestureHandlerConfig.NoOp) addNewAppToHome.onChange(true) withContext(Dispatchers.IO) { - deckManager.enableLawndeck() - isLoading = false + deckManager.enableLawndeck { message -> + // Update on main thread using coroutine scope + coroutineScope.launch(Dispatchers.Main) { + loadingMessage = message + } + } } + isLoading = false + loadingMessage = "" } } else { coroutineScope.launch { @@ -77,6 +137,7 @@ fun HomeLayoutSettings( withContext(Dispatchers.IO) { deckManager.disableLawndeck() isLoading = false + loadingMessage = "" } } } diff --git a/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt b/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt index 60fbb457a6..7f76135e77 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt @@ -78,7 +78,7 @@ fun HomeScreenPreferences( val isDeckLayoutAdapter = prefs2.deckLayout.getAdapter() ExpandAndShrink(visible = !isDeckLayoutAdapter.state.value) { SwitchPreference( - checked = !lockHomeScreenAdapter.state.value && addIconToHomeAdapter.state.value, + checked = (!lockHomeScreenAdapter.state.value && addIconToHomeAdapter.state.value) || isDeckLayoutAdapter.state.value, onCheckedChange = addIconToHomeAdapter::onChange, label = stringResource(id = R.string.auto_add_shortcuts_label), description = if (lockHomeScreenAdapter.state.value) stringResource(id = R.string.home_screen_locked) else null, diff --git a/src/com/android/launcher3/model/PackageUpdatedTask.java b/src/com/android/launcher3/model/PackageUpdatedTask.java index e8b30f015d..999aba4517 100644 --- a/src/com/android/launcher3/model/PackageUpdatedTask.java +++ b/src/com/android/launcher3/model/PackageUpdatedTask.java @@ -68,7 +68,10 @@ import java.util.Objects; import java.util.function.Predicate; import java.util.stream.Collectors; +import app.lawnchair.deck.LawndeckManager; import app.lawnchair.preferences.PreferenceManager; +import app.lawnchair.preferences2.PreferenceManager2; +import com.patrykmichalik.opto.core.PreferenceExtensionsKt; /** * Handles updates due to changes in package manager (app installed/updated/removed) @@ -457,6 +460,16 @@ public class PackageUpdatedTask implements ModelUpdateTask { dataModel.widgetsModel.update(app, new PackageUserKey(packages[i], mUser)); } taskController.bindUpdatedWidgets(dataModel); + + // If deck layout is enabled, add newly installed apps to workspace with categorization + PreferenceManager2 pref2 = PreferenceManager2.INSTANCE.get(context); + if (PreferenceExtensionsKt.firstBlocking(pref2.getDeckLayout())) { + LawndeckManager deckManager = new LawndeckManager(context); + ModelWriter modelWriter = taskController.getModelWriter(); + for (int i = 0; i < N; i++) { + deckManager.addNewlyInstalledApp(packages[i], mUser, modelWriter, dataModel); + } + } } }