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