| /* |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.launcher3.model; |
| |
| import static com.android.launcher3.LauncherSettings.Favorites.TABLE_NAME; |
| import static com.android.launcher3.provider.LauncherDbUtils.itemIdMatch; |
| import static com.android.launcher3.util.Executors.MODEL_EXECUTOR; |
| |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| |
| import com.android.launcher3.LauncherAppState; |
| import com.android.launcher3.LauncherModel; |
| import com.android.launcher3.LauncherModel.CallbackTask; |
| import com.android.launcher3.LauncherSettings.Favorites; |
| import com.android.launcher3.Utilities; |
| import com.android.launcher3.celllayout.CellPosMapper; |
| import com.android.launcher3.celllayout.CellPosMapper.CellPos; |
| import com.android.launcher3.config.FeatureFlags; |
| import com.android.launcher3.logging.FileLog; |
| import com.android.launcher3.model.BgDataModel.Callbacks; |
| import com.android.launcher3.model.data.FolderInfo; |
| import com.android.launcher3.model.data.ItemInfo; |
| import com.android.launcher3.model.data.LauncherAppWidgetInfo; |
| import com.android.launcher3.model.data.WorkspaceItemInfo; |
| import com.android.launcher3.provider.LauncherDbUtils.SQLiteTransaction; |
| import com.android.launcher3.util.ContentWriter; |
| import com.android.launcher3.util.Executors; |
| import com.android.launcher3.util.ItemInfoMatcher; |
| import com.android.launcher3.util.LooperExecutor; |
| import com.android.launcher3.widget.LauncherWidgetHolder; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.function.Predicate; |
| import java.util.function.Supplier; |
| import java.util.stream.Collectors; |
| import java.util.stream.StreamSupport; |
| |
| /** |
| * Class for handling model updates. |
| */ |
| public class ModelWriter { |
| |
| private static final String TAG = "ModelWriter"; |
| |
| private final Context mContext; |
| private final LauncherModel mModel; |
| private final BgDataModel mBgDataModel; |
| private final LooperExecutor mUiExecutor; |
| |
| @Nullable |
| private final Callbacks mOwner; |
| |
| private final boolean mHasVerticalHotseat; |
| private final boolean mVerifyChanges; |
| |
| // Keep track of delete operations that occur when an Undo option is present; we may not commit. |
| private final List<ModelTask> mDeleteRunnables = new ArrayList<>(); |
| private boolean mPreparingToUndo; |
| private final CellPosMapper mCellPosMapper; |
| |
| public ModelWriter(Context context, LauncherModel model, BgDataModel dataModel, |
| boolean hasVerticalHotseat, boolean verifyChanges, CellPosMapper cellPosMapper, |
| @Nullable Callbacks owner) { |
| mContext = context; |
| mModel = model; |
| mBgDataModel = dataModel; |
| mHasVerticalHotseat = hasVerticalHotseat; |
| mVerifyChanges = verifyChanges; |
| mOwner = owner; |
| mCellPosMapper = cellPosMapper; |
| mUiExecutor = Executors.MAIN_EXECUTOR; |
| } |
| |
| private void updateItemInfoProps( |
| ItemInfo item, int container, int screenId, int cellX, int cellY) { |
| CellPos modelPos = mCellPosMapper.mapPresenterToModel(cellX, cellY, screenId, container); |
| |
| item.container = container; |
| item.cellX = modelPos.cellX; |
| item.cellY = modelPos.cellY; |
| // We store hotseat items in canonical form which is this orientation invariant position |
| // in the hotseat |
| if (container == Favorites.CONTAINER_HOTSEAT) { |
| item.screenId = mHasVerticalHotseat |
| ? LauncherAppState.getIDP(mContext).numDatabaseHotseatIcons - cellY - 1 : cellX; |
| } else { |
| item.screenId = modelPos.screenId; |
| } |
| } |
| |
| /** |
| * Adds an item to the DB if it was not created previously, or move it to a new |
| * <container, screen, cellX, cellY> |
| */ |
| public void addOrMoveItemInDatabase(ItemInfo item, |
| int container, int screenId, int cellX, int cellY) { |
| if (item.id == ItemInfo.NO_ID) { |
| // From all apps |
| addItemToDatabase(item, container, screenId, cellX, cellY); |
| } else { |
| // From somewhere else |
| moveItemInDatabase(item, container, screenId, cellX, cellY); |
| } |
| } |
| |
| private void checkItemInfoLocked(int itemId, ItemInfo item, StackTraceElement[] stackTrace) { |
| ItemInfo modelItem = mBgDataModel.itemsIdMap.get(itemId); |
| if (modelItem != null && item != modelItem) { |
| // check all the data is consistent |
| if (!Utilities.IS_DEBUG_DEVICE && !FeatureFlags.IS_STUDIO_BUILD |
| && modelItem instanceof WorkspaceItemInfo |
| && item instanceof WorkspaceItemInfo) { |
| if (modelItem.title.toString().equals(item.title.toString()) && |
| modelItem.getIntent().filterEquals(item.getIntent()) && |
| modelItem.id == item.id && |
| modelItem.itemType == item.itemType && |
| modelItem.container == item.container && |
| modelItem.screenId == item.screenId && |
| modelItem.cellX == item.cellX && |
| modelItem.cellY == item.cellY && |
| modelItem.spanX == item.spanX && |
| modelItem.spanY == item.spanY) { |
| // For all intents and purposes, this is the same object |
| return; |
| } |
| } |
| |
| // the modelItem needs to match up perfectly with item if our model is |
| // to be consistent with the database-- for now, just require |
| // modelItem == item or the equality check above |
| String msg = "item: " + ((item != null) ? item.toString() : "null") + |
| "modelItem: " + |
| ((modelItem != null) ? modelItem.toString() : "null") + |
| "Error: ItemInfo passed to checkItemInfo doesn't match original"; |
| RuntimeException e = new RuntimeException(msg); |
| if (stackTrace != null) { |
| e.setStackTrace(stackTrace); |
| } |
| throw e; |
| } |
| } |
| |
| /** |
| * Move an item in the DB to a new <container, screen, cellX, cellY> |
| */ |
| public void moveItemInDatabase(final ItemInfo item, |
| int container, int screenId, int cellX, int cellY) { |
| updateItemInfoProps(item, container, screenId, cellX, cellY); |
| notifyItemModified(item); |
| |
| enqueueDeleteRunnable(new UpdateItemRunnable(item, () -> |
| new ContentWriter(mContext) |
| .put(Favorites.CONTAINER, item.container) |
| .put(Favorites.CELLX, item.cellX) |
| .put(Favorites.CELLY, item.cellY) |
| .put(Favorites.RANK, item.rank) |
| .put(Favorites.SCREEN, item.screenId))); |
| } |
| |
| /** |
| * Move items in the DB to a new <container, screen, cellX, cellY>. We assume that the |
| * cellX, cellY have already been updated on the ItemInfos. |
| */ |
| public void moveItemsInDatabase(final ArrayList<ItemInfo> items, int container, int screen) { |
| ArrayList<ContentValues> contentValues = new ArrayList<>(); |
| int count = items.size(); |
| notifyOtherCallbacks(c -> c.bindItemsModified(items)); |
| |
| for (int i = 0; i < count; i++) { |
| ItemInfo item = items.get(i); |
| updateItemInfoProps(item, container, screen, item.cellX, item.cellY); |
| |
| final ContentValues values = new ContentValues(); |
| values.put(Favorites.CONTAINER, item.container); |
| values.put(Favorites.CELLX, item.cellX); |
| values.put(Favorites.CELLY, item.cellY); |
| values.put(Favorites.RANK, item.rank); |
| values.put(Favorites.SCREEN, item.screenId); |
| |
| contentValues.add(values); |
| } |
| enqueueDeleteRunnable(new UpdateItemsRunnable(items, contentValues)); |
| } |
| |
| /** |
| * Move and/or resize item in the DB to a new <container, screen, cellX, cellY, spanX, spanY> |
| */ |
| public void modifyItemInDatabase(final ItemInfo item, |
| int container, int screenId, int cellX, int cellY, int spanX, int spanY) { |
| updateItemInfoProps(item, container, screenId, cellX, cellY); |
| item.spanX = spanX; |
| item.spanY = spanY; |
| notifyItemModified(item); |
| new UpdateItemRunnable(item, () -> |
| new ContentWriter(mContext) |
| .put(Favorites.CONTAINER, item.container) |
| .put(Favorites.CELLX, item.cellX) |
| .put(Favorites.CELLY, item.cellY) |
| .put(Favorites.RANK, item.rank) |
| .put(Favorites.SPANX, item.spanX) |
| .put(Favorites.SPANY, item.spanY) |
| .put(Favorites.SCREEN, item.screenId)) |
| .executeOnModelThread(); |
| } |
| |
| /** |
| * Update an item to the database in a specified container. |
| */ |
| public void updateItemInDatabase(ItemInfo item) { |
| notifyItemModified(item); |
| new UpdateItemRunnable(item, () -> { |
| ContentWriter writer = new ContentWriter(mContext); |
| item.onAddToDatabase(writer); |
| return writer; |
| }).executeOnModelThread(); |
| } |
| |
| private void notifyItemModified(ItemInfo item) { |
| notifyOtherCallbacks(c -> c.bindItemsModified(Collections.singletonList(item))); |
| } |
| |
| /** |
| * Add an item to the database in a specified container. Sets the container, screen, cellX and |
| * cellY fields of the item. Also assigns an ID to the item. |
| */ |
| public void addItemToDatabase(final ItemInfo item, |
| int container, int screenId, int cellX, int cellY) { |
| updateItemInfoProps(item, container, screenId, cellX, cellY); |
| |
| item.id = mModel.getModelDbController().generateNewItemId(); |
| notifyOtherCallbacks(c -> c.bindItems(Collections.singletonList(item), false)); |
| |
| ModelVerifier verifier = new ModelVerifier(); |
| final StackTraceElement[] stackTrace = new Throwable().getStackTrace(); |
| newModelTask(() -> { |
| // Write the item on background thread, as some properties might have been updated in |
| // the background. |
| final ContentWriter writer = new ContentWriter(mContext); |
| item.onAddToDatabase(writer); |
| writer.put(Favorites._ID, item.id); |
| |
| mModel.getModelDbController().insert(Favorites.TABLE_NAME, writer.getValues(mContext)); |
| synchronized (mBgDataModel) { |
| checkItemInfoLocked(item.id, item, stackTrace); |
| mBgDataModel.addItem(mContext, item, true); |
| verifier.verifyModel(); |
| } |
| }).executeOnModelThread(); |
| } |
| |
| /** |
| * Removes the specified item from the database |
| */ |
| public void deleteItemFromDatabase(ItemInfo item, @Nullable final String reason) { |
| deleteItemsFromDatabase(Arrays.asList(item), reason); |
| } |
| |
| /** |
| * Removes all the items from the database matching {@param matcher}. |
| */ |
| public void deleteItemsFromDatabase(@NonNull final Predicate<ItemInfo> matcher, |
| @Nullable final String reason) { |
| deleteItemsFromDatabase(StreamSupport.stream(mBgDataModel.itemsIdMap.spliterator(), false) |
| .filter(matcher).collect(Collectors.toList()), reason); |
| } |
| |
| /** |
| * Removes the specified items from the database |
| */ |
| public void deleteItemsFromDatabase(final Collection<? extends ItemInfo> items, |
| @Nullable final String reason) { |
| ModelVerifier verifier = new ModelVerifier(); |
| FileLog.d(TAG, "removing items from db " + items.stream().map( |
| (item) -> item.getTargetComponent() == null ? "" |
| : item.getTargetComponent().getPackageName()).collect( |
| Collectors.joining(",")) |
| + ". Reason: [" + (TextUtils.isEmpty(reason) ? "unknown" : reason) + "]"); |
| notifyDelete(items); |
| enqueueDeleteRunnable(newModelTask(() -> { |
| for (ItemInfo item : items) { |
| mModel.getModelDbController().delete(TABLE_NAME, itemIdMatch(item.id), null); |
| mBgDataModel.removeItem(mContext, item); |
| verifier.verifyModel(); |
| } |
| })); |
| } |
| |
| /** |
| * Remove the specified folder and all its contents from the database. |
| */ |
| public void deleteFolderAndContentsFromDatabase(final FolderInfo info) { |
| ModelVerifier verifier = new ModelVerifier(); |
| notifyDelete(Collections.singleton(info)); |
| |
| enqueueDeleteRunnable(newModelTask(() -> { |
| mModel.getModelDbController().delete(Favorites.TABLE_NAME, |
| Favorites.CONTAINER + "=" + info.id, null); |
| mBgDataModel.removeItem(mContext, info.contents); |
| info.contents.clear(); |
| |
| mModel.getModelDbController().delete(Favorites.TABLE_NAME, |
| Favorites._ID + "=" + info.id, null); |
| mBgDataModel.removeItem(mContext, info); |
| verifier.verifyModel(); |
| })); |
| } |
| |
| /** |
| * Deletes the widget info and the widget id. |
| */ |
| public void deleteWidgetInfo(final LauncherAppWidgetInfo info, LauncherWidgetHolder holder, |
| @Nullable final String reason) { |
| notifyDelete(Collections.singleton(info)); |
| if (holder != null && !info.isCustomWidget() && info.isWidgetIdAllocated()) { |
| // Deleting an app widget ID is a void call but writes to disk before returning |
| // to the caller... |
| enqueueDeleteRunnable(newModelTask(() -> holder.deleteAppWidgetId(info.appWidgetId))); |
| } |
| deleteItemFromDatabase(info, reason); |
| } |
| |
| private void notifyDelete(Collection<? extends ItemInfo> items) { |
| notifyOtherCallbacks(c -> c.bindWorkspaceComponentsRemoved(ItemInfoMatcher.ofItems(items))); |
| } |
| |
| /** |
| * Delete operations tracked using {@link #enqueueDeleteRunnable} will only be called |
| * if {@link #commitDelete} is called. Note that one of {@link #commitDelete()} or |
| * {@link #abortDelete} MUST be called after this method, or else all delete |
| * operations will remain uncommitted indefinitely. |
| */ |
| public void prepareToUndoDelete() { |
| if (!mPreparingToUndo) { |
| if (!mDeleteRunnables.isEmpty() && FeatureFlags.IS_STUDIO_BUILD) { |
| throw new IllegalStateException("There are still uncommitted delete operations!"); |
| } |
| mDeleteRunnables.clear(); |
| mPreparingToUndo = true; |
| } |
| } |
| |
| /** |
| * If {@link #prepareToUndoDelete} has been called, we store the Runnable to be run when |
| * {@link #commitDelete()} is called (or abandoned if {@link #abortDelete} is called). |
| * Otherwise, we run the Runnable immediately. |
| */ |
| private void enqueueDeleteRunnable(ModelTask r) { |
| if (mPreparingToUndo) { |
| mDeleteRunnables.add(r); |
| } else { |
| r.executeOnModelThread(); |
| } |
| } |
| |
| public void commitDelete() { |
| mPreparingToUndo = false; |
| mDeleteRunnables.forEach(ModelTask::executeOnModelThread); |
| mDeleteRunnables.clear(); |
| } |
| |
| /** |
| * Aborts a previous delete operation pending commit |
| */ |
| public void abortDelete() { |
| mPreparingToUndo = false; |
| mDeleteRunnables.clear(); |
| // We do a full reload here instead of just a rebind because Folders change their internal |
| // state when dragging an item out, which clobbers the rebind unless we load from the DB. |
| mModel.forceReload(); |
| } |
| |
| private void notifyOtherCallbacks(CallbackTask task) { |
| if (mOwner == null) { |
| // If the call is happening from a model, it will take care of updating the callbacks |
| return; |
| } |
| mUiExecutor.execute(() -> { |
| for (Callbacks c : mModel.getCallbacks()) { |
| if (c != mOwner) { |
| task.execute(c); |
| } |
| } |
| }); |
| } |
| |
| private class UpdateItemRunnable extends UpdateItemBaseRunnable { |
| private final ItemInfo mItem; |
| private final Supplier<ContentWriter> mWriter; |
| private final int mItemId; |
| |
| UpdateItemRunnable(ItemInfo item, Supplier<ContentWriter> writer) { |
| mItem = item; |
| mWriter = writer; |
| mItemId = item.id; |
| } |
| |
| @Override |
| public void runImpl() { |
| mModel.getModelDbController().update( |
| TABLE_NAME, mWriter.get().getValues(mContext), itemIdMatch(mItemId), null); |
| updateItemArrays(mItem, mItemId); |
| } |
| } |
| |
| private class UpdateItemsRunnable extends UpdateItemBaseRunnable { |
| private final ArrayList<ContentValues> mValues; |
| private final ArrayList<ItemInfo> mItems; |
| |
| UpdateItemsRunnable(ArrayList<ItemInfo> items, ArrayList<ContentValues> values) { |
| mValues = values; |
| mItems = items; |
| } |
| |
| @Override |
| public void runImpl() { |
| try (SQLiteTransaction t = mModel.getModelDbController().newTransaction()) { |
| int count = mItems.size(); |
| for (int i = 0; i < count; i++) { |
| ItemInfo item = mItems.get(i); |
| final int itemId = item.id; |
| mModel.getModelDbController().update( |
| TABLE_NAME, mValues.get(i), itemIdMatch(itemId), null); |
| updateItemArrays(item, itemId); |
| } |
| t.commit(); |
| } catch (Exception e) { |
| e.printStackTrace(); |
| } |
| } |
| } |
| |
| private abstract class UpdateItemBaseRunnable extends ModelTask { |
| private final StackTraceElement[] mStackTrace; |
| private final ModelVerifier mVerifier = new ModelVerifier(); |
| |
| UpdateItemBaseRunnable() { |
| mStackTrace = new Throwable().getStackTrace(); |
| } |
| |
| protected void updateItemArrays(ItemInfo item, int itemId) { |
| // Lock on mBgLock *after* the db operation |
| synchronized (mBgDataModel) { |
| checkItemInfoLocked(itemId, item, mStackTrace); |
| |
| if (item.container != Favorites.CONTAINER_DESKTOP && |
| item.container != Favorites.CONTAINER_HOTSEAT) { |
| // Item is in a folder, make sure this folder exists |
| if (!mBgDataModel.folders.containsKey(item.container)) { |
| // An items container is being set to a that of an item which is not in |
| // the list of Folders. |
| String msg = "item: " + item + " container being set to: " + |
| item.container + ", not in the list of folders"; |
| Log.e(TAG, msg); |
| } |
| } |
| |
| // Items are added/removed from the corresponding FolderInfo elsewhere, such |
| // as in Workspace.onDrop. Here, we just add/remove them from the list of items |
| // that are on the desktop, as appropriate |
| ItemInfo modelItem = mBgDataModel.itemsIdMap.get(itemId); |
| if (modelItem != null && |
| (modelItem.container == Favorites.CONTAINER_DESKTOP || |
| modelItem.container == Favorites.CONTAINER_HOTSEAT)) { |
| switch (modelItem.itemType) { |
| case Favorites.ITEM_TYPE_APPLICATION: |
| case Favorites.ITEM_TYPE_DEEP_SHORTCUT: |
| case Favorites.ITEM_TYPE_FOLDER: |
| case Favorites.ITEM_TYPE_APP_PAIR: |
| if (!mBgDataModel.workspaceItems.contains(modelItem)) { |
| mBgDataModel.workspaceItems.add(modelItem); |
| } |
| break; |
| default: |
| break; |
| } |
| } else { |
| mBgDataModel.workspaceItems.remove(modelItem); |
| } |
| mVerifier.verifyModel(); |
| } |
| } |
| } |
| |
| private abstract class ModelTask implements Runnable { |
| |
| private final int mLoadId = mBgDataModel.lastLoadId; |
| |
| @Override |
| public final void run() { |
| if (mLoadId != mModel.getLastLoadId()) { |
| Log.d(TAG, "Model changed before the task could execute"); |
| return; |
| } |
| runImpl(); |
| } |
| |
| public final void executeOnModelThread() { |
| MODEL_EXECUTOR.execute(this); |
| } |
| |
| public abstract void runImpl(); |
| } |
| |
| private ModelTask newModelTask(Runnable r) { |
| return new ModelTask() { |
| @Override |
| public void runImpl() { |
| r.run(); |
| } |
| }; |
| } |
| |
| /** |
| * Utility class to verify model updates are propagated properly to the callback. |
| */ |
| public class ModelVerifier { |
| |
| final int startId; |
| |
| ModelVerifier() { |
| startId = mBgDataModel.lastBindId; |
| } |
| |
| void verifyModel() { |
| if (!mVerifyChanges || !mModel.hasCallbacks()) { |
| return; |
| } |
| |
| int executeId = mBgDataModel.lastBindId; |
| |
| mUiExecutor.post(() -> { |
| int currentId = mBgDataModel.lastBindId; |
| if (currentId > executeId) { |
| // Model was already bound after job was executed. |
| return; |
| } |
| if (executeId == startId) { |
| // Bound model has not changed during the job |
| return; |
| } |
| |
| // Bound model was changed between submitting the job and executing the job |
| mModel.rebindCallbacks(); |
| }); |
| } |
| } |
| } |