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
This commit is contained in:
MrSluffy
2025-11-16 11:55:24 +08:00
parent 6509c05cdf
commit 1bb5878457
5 changed files with 452 additions and 9 deletions

View File

@@ -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<FolderInfo>,
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<ItemInfo>()
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<ItemInfo>()
val addNotAnimated = ArrayList<ItemInfo>()
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
}
}

View File

@@ -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<Unit>()
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<FolderInfo>()
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<String, List<AppInfo>>)
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<AppInfo>): 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(

View File

@@ -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 = ""
}
}
}

View File

@@ -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,

View File

@@ -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);
}
}
}
}