1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.launcher3.model;
18 
19 import static com.android.launcher3.LauncherSettings.Favorites.TABLE_NAME;
20 import static com.android.launcher3.provider.LauncherDbUtils.itemIdMatch;
21 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
22 
23 import android.content.ContentValues;
24 import android.content.Context;
25 import android.text.TextUtils;
26 import android.util.Log;
27 
28 import androidx.annotation.NonNull;
29 import androidx.annotation.Nullable;
30 
31 import com.android.launcher3.LauncherModel;
32 import com.android.launcher3.LauncherModel.CallbackTask;
33 import com.android.launcher3.LauncherSettings.Favorites;
34 import com.android.launcher3.Utilities;
35 import com.android.launcher3.celllayout.CellPosMapper;
36 import com.android.launcher3.celllayout.CellPosMapper.CellPos;
37 import com.android.launcher3.config.FeatureFlags;
38 import com.android.launcher3.logging.FileLog;
39 import com.android.launcher3.model.BgDataModel.Callbacks;
40 import com.android.launcher3.model.data.CollectionInfo;
41 import com.android.launcher3.model.data.ItemInfo;
42 import com.android.launcher3.model.data.LauncherAppWidgetInfo;
43 import com.android.launcher3.model.data.WorkspaceItemInfo;
44 import com.android.launcher3.provider.LauncherDbUtils.SQLiteTransaction;
45 import com.android.launcher3.util.ContentWriter;
46 import com.android.launcher3.util.Executors;
47 import com.android.launcher3.util.ItemInfoMatcher;
48 import com.android.launcher3.util.LooperExecutor;
49 import com.android.launcher3.widget.LauncherWidgetHolder;
50 
51 import java.util.ArrayList;
52 import java.util.Arrays;
53 import java.util.Collection;
54 import java.util.Collections;
55 import java.util.List;
56 import java.util.function.Predicate;
57 import java.util.function.Supplier;
58 import java.util.stream.Collectors;
59 import java.util.stream.StreamSupport;
60 
61 /**
62  * Class for handling model updates.
63  */
64 public class ModelWriter {
65 
66     private static final String TAG = "ModelWriter";
67 
68     private final Context mContext;
69     private final LauncherModel mModel;
70     private final BgDataModel mBgDataModel;
71     private final LooperExecutor mUiExecutor;
72 
73     @Nullable
74     private final Callbacks mOwner;
75 
76     private final boolean mVerifyChanges;
77 
78     // Keep track of delete operations that occur when an Undo option is present; we may not commit.
79     private final List<ModelTask> mDeleteRunnables = new ArrayList<>();
80     private boolean mPreparingToUndo;
81     private final CellPosMapper mCellPosMapper;
82 
ModelWriter(Context context, LauncherModel model, BgDataModel dataModel, boolean verifyChanges, CellPosMapper cellPosMapper, @Nullable Callbacks owner)83     public ModelWriter(Context context, LauncherModel model, BgDataModel dataModel,
84             boolean verifyChanges, CellPosMapper cellPosMapper, @Nullable Callbacks owner) {
85         mContext = context;
86         mModel = model;
87         mBgDataModel = dataModel;
88         mVerifyChanges = verifyChanges;
89         mOwner = owner;
90         mCellPosMapper = cellPosMapper;
91         mUiExecutor = Executors.MAIN_EXECUTOR;
92     }
93 
updateItemInfoProps( ItemInfo item, int container, int screenId, int cellX, int cellY)94     private void updateItemInfoProps(
95             ItemInfo item, int container, int screenId, int cellX, int cellY) {
96         CellPos modelPos = mCellPosMapper.mapPresenterToModel(cellX, cellY, screenId, container);
97 
98         item.container = container;
99         item.cellX = modelPos.cellX;
100         item.cellY = modelPos.cellY;
101         item.screenId = modelPos.screenId;
102 
103     }
104 
105     /**
106      * Adds an item to the DB if it was not created previously, or move it to a new
107      * <container, screen, cellX, cellY>
108      */
addOrMoveItemInDatabase(ItemInfo item, int container, int screenId, int cellX, int cellY)109     public void addOrMoveItemInDatabase(ItemInfo item,
110             int container, int screenId, int cellX, int cellY) {
111         if (item.id == ItemInfo.NO_ID) {
112             // From all apps
113             addItemToDatabase(item, container, screenId, cellX, cellY);
114         } else {
115             // From somewhere else
116             moveItemInDatabase(item, container, screenId, cellX, cellY);
117         }
118     }
119 
checkItemInfoLocked(int itemId, ItemInfo item, StackTraceElement[] stackTrace)120     private void checkItemInfoLocked(int itemId, ItemInfo item, StackTraceElement[] stackTrace) {
121         ItemInfo modelItem = mBgDataModel.itemsIdMap.get(itemId);
122         if (modelItem != null && item != modelItem) {
123             // check all the data is consistent
124             if (!Utilities.IS_DEBUG_DEVICE && !FeatureFlags.IS_STUDIO_BUILD
125                     && modelItem instanceof WorkspaceItemInfo
126                     && item instanceof WorkspaceItemInfo) {
127                 if (modelItem.title.toString().equals(item.title.toString()) &&
128                         modelItem.getIntent().filterEquals(item.getIntent()) &&
129                         modelItem.id == item.id &&
130                         modelItem.itemType == item.itemType &&
131                         modelItem.container == item.container &&
132                         modelItem.screenId == item.screenId &&
133                         modelItem.cellX == item.cellX &&
134                         modelItem.cellY == item.cellY &&
135                         modelItem.spanX == item.spanX &&
136                         modelItem.spanY == item.spanY) {
137                     // For all intents and purposes, this is the same object
138                     return;
139                 }
140             }
141 
142             // the modelItem needs to match up perfectly with item if our model is
143             // to be consistent with the database-- for now, just require
144             // modelItem == item or the equality check above
145             String msg = "item: " + ((item != null) ? item.toString() : "null") +
146                     "modelItem: " +
147                     ((modelItem != null) ? modelItem.toString() : "null") +
148                     "Error: ItemInfo passed to checkItemInfo doesn't match original";
149             RuntimeException e = new RuntimeException(msg);
150             if (stackTrace != null) {
151                 e.setStackTrace(stackTrace);
152             }
153             throw e;
154         }
155     }
156 
157     /**
158      * Move an item in the DB to a new <container, screen, cellX, cellY>
159      */
moveItemInDatabase(final ItemInfo item, int container, int screenId, int cellX, int cellY)160     public void moveItemInDatabase(final ItemInfo item,
161             int container, int screenId, int cellX, int cellY) {
162         updateItemInfoProps(item, container, screenId, cellX, cellY);
163         notifyItemModified(item);
164 
165         enqueueDeleteRunnable(new UpdateItemRunnable(item, () ->
166                 new ContentWriter(mContext)
167                         .put(Favorites.CONTAINER, item.container)
168                         .put(Favorites.CELLX, item.cellX)
169                         .put(Favorites.CELLY, item.cellY)
170                         .put(Favorites.RANK, item.rank)
171                         .put(Favorites.SCREEN, item.screenId)));
172     }
173 
174     /**
175      * Move items in the DB to a new <container, screen, cellX, cellY>. We assume that the
176      * cellX, cellY have already been updated on the ItemInfos.
177      */
moveItemsInDatabase(final ArrayList<ItemInfo> items, int container, int screen)178     public void moveItemsInDatabase(final ArrayList<ItemInfo> items, int container, int screen) {
179         ArrayList<ContentValues> contentValues = new ArrayList<>();
180         int count = items.size();
181         notifyOtherCallbacks(c -> c.bindItemsModified(items));
182 
183         for (int i = 0; i < count; i++) {
184             ItemInfo item = items.get(i);
185             updateItemInfoProps(item, container, screen, item.cellX, item.cellY);
186 
187             final ContentValues values = new ContentValues();
188             values.put(Favorites.CONTAINER, item.container);
189             values.put(Favorites.CELLX, item.cellX);
190             values.put(Favorites.CELLY, item.cellY);
191             values.put(Favorites.RANK, item.rank);
192             values.put(Favorites.SCREEN, item.screenId);
193 
194             contentValues.add(values);
195         }
196         enqueueDeleteRunnable(new UpdateItemsRunnable(items, contentValues));
197     }
198 
199     /**
200      * Move and/or resize item in the DB to a new <container, screen, cellX, cellY, spanX, spanY>
201      */
modifyItemInDatabase(final ItemInfo item, int container, int screenId, int cellX, int cellY, int spanX, int spanY)202     public void modifyItemInDatabase(final ItemInfo item,
203             int container, int screenId, int cellX, int cellY, int spanX, int spanY) {
204         updateItemInfoProps(item, container, screenId, cellX, cellY);
205         item.spanX = spanX;
206         item.spanY = spanY;
207         notifyItemModified(item);
208         new UpdateItemRunnable(item, () ->
209                 new ContentWriter(mContext)
210                         .put(Favorites.CONTAINER, item.container)
211                         .put(Favorites.CELLX, item.cellX)
212                         .put(Favorites.CELLY, item.cellY)
213                         .put(Favorites.RANK, item.rank)
214                         .put(Favorites.SPANX, item.spanX)
215                         .put(Favorites.SPANY, item.spanY)
216                         .put(Favorites.SCREEN, item.screenId))
217                 .executeOnModelThread();
218     }
219 
220     /**
221      * Update an item to the database in a specified container.
222      */
updateItemInDatabase(ItemInfo item)223     public void updateItemInDatabase(ItemInfo item) {
224         notifyItemModified(item);
225         new UpdateItemRunnable(item, () -> {
226             ContentWriter writer = new ContentWriter(mContext);
227             item.onAddToDatabase(writer);
228             return writer;
229         }).executeOnModelThread();
230     }
231 
notifyItemModified(ItemInfo item)232     private void notifyItemModified(ItemInfo item) {
233         notifyOtherCallbacks(c -> c.bindItemsModified(Collections.singletonList(item)));
234     }
235 
236     /**
237      * Add an item to the database in a specified container. Sets the container, screen, cellX and
238      * cellY fields of the item. Also assigns an ID to the item.
239      */
addItemToDatabase(final ItemInfo item, int container, int screenId, int cellX, int cellY)240     public void addItemToDatabase(final ItemInfo item,
241             int container, int screenId, int cellX, int cellY) {
242         updateItemInfoProps(item, container, screenId, cellX, cellY);
243 
244         item.id = mModel.getModelDbController().generateNewItemId();
245         notifyOtherCallbacks(c -> c.bindItems(Collections.singletonList(item), false));
246 
247         ModelVerifier verifier = new ModelVerifier();
248         final StackTraceElement[] stackTrace = new Throwable().getStackTrace();
249         newModelTask(() -> {
250             // Write the item on background thread, as some properties might have been updated in
251             // the background.
252             final ContentWriter writer = new ContentWriter(mContext);
253             item.onAddToDatabase(writer);
254             writer.put(Favorites._ID, item.id);
255 
256             mModel.getModelDbController().insert(Favorites.TABLE_NAME, writer.getValues(mContext));
257             synchronized (mBgDataModel) {
258                 checkItemInfoLocked(item.id, item, stackTrace);
259                 mBgDataModel.addItem(mContext, item, true);
260                 verifier.verifyModel();
261             }
262         }).executeOnModelThread();
263     }
264 
265     /**
266      * Removes the specified item from the database
267      */
deleteItemFromDatabase(ItemInfo item, @Nullable final String reason)268     public void deleteItemFromDatabase(ItemInfo item, @Nullable final String reason) {
269         deleteItemsFromDatabase(Arrays.asList(item), reason);
270     }
271 
272     /**
273      * Removes all the items from the database matching {@param matcher}.
274      */
deleteItemsFromDatabase(@onNull final Predicate<ItemInfo> matcher, @Nullable final String reason)275     public void deleteItemsFromDatabase(@NonNull final Predicate<ItemInfo> matcher,
276             @Nullable final String reason) {
277         deleteItemsFromDatabase(StreamSupport.stream(mBgDataModel.itemsIdMap.spliterator(), false)
278                 .filter(matcher).collect(Collectors.toList()), reason);
279     }
280 
281     /**
282      * Removes the specified items from the database
283      */
deleteItemsFromDatabase(final Collection<? extends ItemInfo> items, @Nullable final String reason)284     public void deleteItemsFromDatabase(final Collection<? extends ItemInfo> items,
285             @Nullable final String reason) {
286         ModelVerifier verifier = new ModelVerifier();
287         FileLog.d(TAG, "removing items from db " + items.stream().map(
288                 (item) -> item.getTargetComponent() == null ? ""
289                         : item.getTargetComponent().getPackageName()).collect(
290                 Collectors.joining(","))
291                 + ". Reason: [" + (TextUtils.isEmpty(reason) ? "unknown" : reason) + "]");
292         notifyDelete(items);
293         enqueueDeleteRunnable(newModelTask(() -> {
294             for (ItemInfo item : items) {
295                 mModel.getModelDbController().delete(TABLE_NAME, itemIdMatch(item.id), null);
296                 mBgDataModel.removeItem(mContext, item);
297                 verifier.verifyModel();
298             }
299         }));
300     }
301 
302     /**
303      * Remove the specified folder and all its contents from the database.
304      */
deleteCollectionAndContentsFromDatabase(final CollectionInfo info)305     public void deleteCollectionAndContentsFromDatabase(final CollectionInfo info) {
306         ModelVerifier verifier = new ModelVerifier();
307         notifyDelete(Collections.singleton(info));
308 
309         enqueueDeleteRunnable(newModelTask(() -> {
310             mModel.getModelDbController().delete(Favorites.TABLE_NAME,
311                     Favorites.CONTAINER + "=" + info.id, null);
312             mBgDataModel.removeItem(mContext, info.getContents());
313             info.getContents().clear();
314 
315             mModel.getModelDbController().delete(Favorites.TABLE_NAME,
316                     Favorites._ID + "=" + info.id, null);
317             mBgDataModel.removeItem(mContext, info);
318             verifier.verifyModel();
319         }));
320     }
321 
322     /**
323      * Deletes the widget info and the widget id.
324      */
deleteWidgetInfo(final LauncherAppWidgetInfo info, LauncherWidgetHolder holder, @Nullable final String reason)325     public void deleteWidgetInfo(final LauncherAppWidgetInfo info, LauncherWidgetHolder holder,
326             @Nullable final String reason) {
327         notifyDelete(Collections.singleton(info));
328         if (holder != null && !info.isCustomWidget() && info.isWidgetIdAllocated()) {
329             // Deleting an app widget ID is a void call but writes to disk before returning
330             // to the caller...
331             enqueueDeleteRunnable(newModelTask(() -> holder.deleteAppWidgetId(info.appWidgetId)));
332         }
333         deleteItemFromDatabase(info, reason);
334     }
335 
notifyDelete(Collection<? extends ItemInfo> items)336     private void notifyDelete(Collection<? extends ItemInfo> items) {
337         notifyOtherCallbacks(c -> c.bindWorkspaceComponentsRemoved(ItemInfoMatcher.ofItems(items)));
338     }
339 
340     /**
341      * Delete operations tracked using {@link #enqueueDeleteRunnable} will only be called
342      * if {@link #commitDelete} is called. Note that one of {@link #commitDelete()} or
343      * {@link #abortDelete} MUST be called after this method, or else all delete
344      * operations will remain uncommitted indefinitely.
345      */
prepareToUndoDelete()346     public void prepareToUndoDelete() {
347         if (!mPreparingToUndo) {
348             if (!mDeleteRunnables.isEmpty() && FeatureFlags.IS_STUDIO_BUILD) {
349                 throw new IllegalStateException("There are still uncommitted delete operations!");
350             }
351             mDeleteRunnables.clear();
352             mPreparingToUndo = true;
353         }
354     }
355 
356     /**
357      * If {@link #prepareToUndoDelete} has been called, we store the Runnable to be run when
358      * {@link #commitDelete()} is called (or abandoned if {@link #abortDelete} is called).
359      * Otherwise, we run the Runnable immediately.
360      */
enqueueDeleteRunnable(ModelTask r)361     private void enqueueDeleteRunnable(ModelTask r) {
362         if (mPreparingToUndo) {
363             mDeleteRunnables.add(r);
364         } else {
365             r.executeOnModelThread();
366         }
367     }
368 
commitDelete()369     public void commitDelete() {
370         mPreparingToUndo = false;
371         mDeleteRunnables.forEach(ModelTask::executeOnModelThread);
372         mDeleteRunnables.clear();
373     }
374 
375     /**
376      * Aborts a previous delete operation pending commit
377      */
abortDelete()378     public void abortDelete() {
379         mPreparingToUndo = false;
380         mDeleteRunnables.clear();
381         // We do a full reload here instead of just a rebind because Folders change their internal
382         // state when dragging an item out, which clobbers the rebind unless we load from the DB.
383         mModel.forceReload();
384     }
385 
notifyOtherCallbacks(CallbackTask task)386     private void notifyOtherCallbacks(CallbackTask task) {
387         if (mOwner == null) {
388             // If the call is happening from a model, it will take care of updating the callbacks
389             return;
390         }
391         mUiExecutor.execute(() -> {
392             for (Callbacks c : mModel.getCallbacks()) {
393                 if (c != mOwner) {
394                     task.execute(c);
395                 }
396             }
397         });
398     }
399 
400     private class UpdateItemRunnable extends UpdateItemBaseRunnable {
401         private final ItemInfo mItem;
402         private final Supplier<ContentWriter> mWriter;
403         private final int mItemId;
404 
UpdateItemRunnable(ItemInfo item, Supplier<ContentWriter> writer)405         UpdateItemRunnable(ItemInfo item, Supplier<ContentWriter> writer) {
406             mItem = item;
407             mWriter = writer;
408             mItemId = item.id;
409         }
410 
411         @Override
runImpl()412         public void runImpl() {
413             mModel.getModelDbController().update(
414                     TABLE_NAME, mWriter.get().getValues(mContext), itemIdMatch(mItemId), null);
415             updateItemArrays(mItem, mItemId);
416         }
417     }
418 
419     private class UpdateItemsRunnable extends UpdateItemBaseRunnable {
420         private final ArrayList<ContentValues> mValues;
421         private final ArrayList<ItemInfo> mItems;
422 
UpdateItemsRunnable(ArrayList<ItemInfo> items, ArrayList<ContentValues> values)423         UpdateItemsRunnable(ArrayList<ItemInfo> items, ArrayList<ContentValues> values) {
424             mValues = values;
425             mItems = items;
426         }
427 
428         @Override
runImpl()429         public void runImpl() {
430             try (SQLiteTransaction t = mModel.getModelDbController().newTransaction()) {
431                 int count = mItems.size();
432                 for (int i = 0; i < count; i++) {
433                     ItemInfo item = mItems.get(i);
434                     final int itemId = item.id;
435                     mModel.getModelDbController().update(
436                             TABLE_NAME, mValues.get(i), itemIdMatch(itemId), null);
437                     updateItemArrays(item, itemId);
438                 }
439                 t.commit();
440             } catch (Exception e) {
441                 e.printStackTrace();
442             }
443         }
444     }
445 
446     private abstract class UpdateItemBaseRunnable extends ModelTask {
447         private final StackTraceElement[] mStackTrace;
448         private final ModelVerifier mVerifier = new ModelVerifier();
449 
UpdateItemBaseRunnable()450         UpdateItemBaseRunnable() {
451             mStackTrace = new Throwable().getStackTrace();
452         }
453 
updateItemArrays(ItemInfo item, int itemId)454         protected void updateItemArrays(ItemInfo item, int itemId) {
455             // Lock on mBgLock *after* the db operation
456             synchronized (mBgDataModel) {
457                 checkItemInfoLocked(itemId, item, mStackTrace);
458 
459                 if (item.container != Favorites.CONTAINER_DESKTOP &&
460                         item.container != Favorites.CONTAINER_HOTSEAT) {
461                     // Item is in a collection, make sure this collection exists
462                     if (!mBgDataModel.collections.containsKey(item.container)) {
463                         // An items container is being set to a that of an item which is not in
464                         // the list of Folders.
465                         String msg = "item: " + item + " container being set to: " +
466                                 item.container + ", not in the list of collections";
467                         Log.e(TAG, msg);
468                     }
469                 }
470 
471                 // Items are added/removed from the corresponding FolderInfo elsewhere, such
472                 // as in Workspace.onDrop. Here, we just add/remove them from the list of items
473                 // that are on the desktop, as appropriate
474                 ItemInfo modelItem = mBgDataModel.itemsIdMap.get(itemId);
475                 if (modelItem != null &&
476                         (modelItem.container == Favorites.CONTAINER_DESKTOP ||
477                                 modelItem.container == Favorites.CONTAINER_HOTSEAT)) {
478                     switch (modelItem.itemType) {
479                         case Favorites.ITEM_TYPE_APPLICATION:
480                         case Favorites.ITEM_TYPE_DEEP_SHORTCUT:
481                         case Favorites.ITEM_TYPE_FOLDER:
482                         case Favorites.ITEM_TYPE_APP_PAIR:
483                             if (!mBgDataModel.workspaceItems.contains(modelItem)) {
484                                 mBgDataModel.workspaceItems.add(modelItem);
485                             }
486                             break;
487                         default:
488                             break;
489                     }
490                 } else {
491                     mBgDataModel.workspaceItems.remove(modelItem);
492                 }
493                 mVerifier.verifyModel();
494             }
495         }
496     }
497 
498     private abstract class ModelTask implements Runnable {
499 
500         private final int mLoadId = mBgDataModel.lastLoadId;
501 
502         @Override
run()503         public final void run() {
504             if (mLoadId != mModel.getLastLoadId()) {
505                 Log.d(TAG, "Model changed before the task could execute");
506                 return;
507             }
508             runImpl();
509         }
510 
executeOnModelThread()511         public final void executeOnModelThread() {
512             MODEL_EXECUTOR.execute(this);
513         }
514 
runImpl()515         public abstract void runImpl();
516     }
517 
newModelTask(Runnable r)518     private ModelTask newModelTask(Runnable r) {
519         return new ModelTask() {
520             @Override
521             public void runImpl() {
522                 r.run();
523             }
524         };
525     }
526 
527     /**
528      * Utility class to verify model updates are propagated properly to the callback.
529      */
530     public class ModelVerifier {
531 
532         final int startId;
533 
534         ModelVerifier() {
535             startId = mBgDataModel.lastBindId;
536         }
537 
538         void verifyModel() {
539             if (!mVerifyChanges || !mModel.hasCallbacks()) {
540                 return;
541             }
542 
543             int executeId = mBgDataModel.lastBindId;
544 
545             mUiExecutor.post(() -> {
546                 int currentId = mBgDataModel.lastBindId;
547                 if (currentId > executeId) {
548                     // Model was already bound after job was executed.
549                     return;
550                 }
551                 if (executeId == startId) {
552                     // Bound model has not changed during the job
553                     return;
554                 }
555 
556                 // Bound model was changed between submitting the job and executing the job
557                 mModel.rebindCallbacks();
558             });
559         }
560     }
561 }
562