1 /*
2  * Copyright (C) 2018 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.settings.homepage.contextualcards;
18 
19 import static com.android.settings.homepage.contextualcards.ContextualCardLoader.CARD_CONTENT_LOADER_ID;
20 import static com.android.settings.intelligence.ContextualCardProto.ContextualCard.Category.STICKY_VALUE;
21 import static com.android.settings.intelligence.ContextualCardProto.ContextualCard.Category.SUGGESTION_VALUE;
22 
23 import static java.util.stream.Collectors.groupingBy;
24 
25 import android.app.settings.SettingsEnums;
26 import android.content.Context;
27 import android.os.Bundle;
28 import android.provider.Settings;
29 import android.text.format.DateUtils;
30 import android.util.ArrayMap;
31 import android.util.FeatureFlagUtils;
32 import android.util.Log;
33 import android.widget.BaseAdapter;
34 
35 import androidx.annotation.NonNull;
36 import androidx.annotation.Nullable;
37 import androidx.annotation.VisibleForTesting;
38 import androidx.loader.app.LoaderManager;
39 import androidx.loader.content.Loader;
40 
41 import com.android.settings.R;
42 import com.android.settings.core.FeatureFlags;
43 import com.android.settings.homepage.contextualcards.conditional.ConditionalCardController;
44 import com.android.settings.homepage.contextualcards.logging.ContextualCardLogUtils;
45 import com.android.settings.homepage.contextualcards.slices.SliceContextualCardRenderer;
46 import com.android.settings.overlay.FeatureFactory;
47 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
48 import com.android.settingslib.core.lifecycle.Lifecycle;
49 import com.android.settingslib.core.lifecycle.LifecycleObserver;
50 import com.android.settingslib.core.lifecycle.events.OnSaveInstanceState;
51 import com.android.settingslib.core.lifecycle.events.OnStart;
52 import com.android.settingslib.core.lifecycle.events.OnStop;
53 
54 import java.util.ArrayList;
55 import java.util.List;
56 import java.util.Map;
57 import java.util.Set;
58 import java.util.TreeSet;
59 import java.util.stream.Collectors;
60 
61 /**
62  * This is a centralized manager of multiple {@link ContextualCardController}.
63  *
64  * {@link ContextualCardManager} first loads data from {@link ContextualCardLoader} and gets back a
65  * list of {@link ContextualCard}. All subclasses of {@link ContextualCardController} are loaded
66  * here, which will then trigger the {@link ContextualCardController} to load its data and listen to
67  * corresponding changes. When every single {@link ContextualCardController} updates its data, the
68  * data will be passed here, then going through some sorting mechanisms. The
69  * {@link ContextualCardController} will end up building a list of {@link ContextualCard} for
70  * {@link ContextualCardsAdapter} and {@link BaseAdapter#notifyDataSetChanged()} will be called to
71  * get the page refreshed.
72  */
73 public class ContextualCardManager implements ContextualCardLoader.CardContentLoaderListener,
74         ContextualCardUpdateListener, LifecycleObserver, OnSaveInstanceState {
75 
76     @VisibleForTesting
77     static final long CARD_CONTENT_LOADER_TIMEOUT_MS = DateUtils.SECOND_IN_MILLIS;
78     @VisibleForTesting
79     static final String KEY_GLOBAL_CARD_LOADER_TIMEOUT = "global_card_loader_timeout_key";
80     @VisibleForTesting
81     static final String KEY_CONTEXTUAL_CARDS = "key_contextual_cards";
82 
83     private static final String TAG = "ContextualCardManager";
84 
85     private final Context mContext;
86     private final Lifecycle mLifecycle;
87     private final List<LifecycleObserver> mLifecycleObservers;
88     private ContextualCardUpdateListener mListener;
89 
90     @VisibleForTesting
91     final ControllerRendererPool mControllerRendererPool;
92     @VisibleForTesting
93     final List<ContextualCard> mContextualCards;
94     @VisibleForTesting
95     long mStartTime;
96     @VisibleForTesting
97     boolean mIsFirstLaunch;
98     @VisibleForTesting
99     List<String> mSavedCards;
100 
ContextualCardManager(Context context, Lifecycle lifecycle, Bundle savedInstanceState)101     public ContextualCardManager(Context context, Lifecycle lifecycle, Bundle savedInstanceState) {
102         mContext = context;
103         mLifecycle = lifecycle;
104         mContextualCards = new ArrayList<>();
105         mLifecycleObservers = new ArrayList<>();
106         mControllerRendererPool = new ControllerRendererPool();
107         mLifecycle.addObserver(this);
108         if (savedInstanceState == null) {
109             mIsFirstLaunch = true;
110             mSavedCards = null;
111         } else {
112             mSavedCards = savedInstanceState.getStringArrayList(KEY_CONTEXTUAL_CARDS);
113         }
114         // for data provided by Settings
115         for (@ContextualCard.CardType int cardType : getSettingsCards()) {
116             setupController(cardType);
117         }
118     }
119 
loadContextualCards(LoaderManager loaderManager, boolean restartLoaderNeeded)120     void loadContextualCards(LoaderManager loaderManager, boolean restartLoaderNeeded) {
121         if (mContext.getResources().getBoolean(R.bool.config_use_legacy_suggestion)) {
122             Log.w(TAG, "Legacy suggestion contextual card enabled, skipping contextual cards.");
123             return;
124         }
125         mStartTime = System.currentTimeMillis();
126         final CardContentLoaderCallbacks cardContentLoaderCallbacks =
127                 new CardContentLoaderCallbacks(mContext);
128         cardContentLoaderCallbacks.setListener(this);
129         if (!restartLoaderNeeded) {
130             // Use the cached data when navigating back to the first page and upon screen rotation.
131             loaderManager.initLoader(CARD_CONTENT_LOADER_ID, null /* bundle */,
132                     cardContentLoaderCallbacks);
133         } else {
134             // Reload all cards when navigating back after pressing home key, recent app key, or
135             // turn off screen.
136             mIsFirstLaunch = true;
137             loaderManager.restartLoader(CARD_CONTENT_LOADER_ID, null /* bundle */,
138                     cardContentLoaderCallbacks);
139         }
140     }
141 
loadCardControllers()142     private void loadCardControllers() {
143         for (ContextualCard card : mContextualCards) {
144             setupController(card.getCardType());
145         }
146     }
147 
148     @VisibleForTesting
getSettingsCards()149     int[] getSettingsCards() {
150         if (!FeatureFlagUtils.isEnabled(mContext, FeatureFlags.CONDITIONAL_CARDS)) {
151             return new int[] {ContextualCard.CardType.LEGACY_SUGGESTION};
152         }
153         return new int[]
154                 {ContextualCard.CardType.CONDITIONAL, ContextualCard.CardType.LEGACY_SUGGESTION};
155     }
156 
157     @VisibleForTesting
setupController(@ontextualCard.CardType int cardType)158     void setupController(@ContextualCard.CardType int cardType) {
159         final ContextualCardController controller = mControllerRendererPool.getController(mContext,
160                 cardType);
161         if (controller == null) {
162             Log.w(TAG, "Cannot find ContextualCardController for type " + cardType);
163             return;
164         }
165         controller.setCardUpdateListener(this);
166         if (controller instanceof LifecycleObserver && !mLifecycleObservers.contains(controller)) {
167             mLifecycleObservers.add((LifecycleObserver) controller);
168             mLifecycle.addObserver((LifecycleObserver) controller);
169         }
170     }
171 
172     @VisibleForTesting
sortCards(List<ContextualCard> cards)173     List<ContextualCard> sortCards(List<ContextualCard> cards) {
174         // take mContextualCards as the source and do the ranking based on the rule.
175         final List<ContextualCard> result = cards.stream()
176                 .sorted((c1, c2) -> Double.compare(c2.getRankingScore(), c1.getRankingScore()))
177                 .collect(Collectors.toList());
178         final List<ContextualCard> stickyCards = result.stream()
179                 .filter(c -> c.getCategory() == STICKY_VALUE)
180                 .collect(Collectors.toList());
181         // make sticky cards be at the tail end.
182         result.removeAll(stickyCards);
183         result.addAll(stickyCards);
184         return result;
185     }
186 
187     @Override
onContextualCardUpdated(Map<Integer, List<ContextualCard>> updateList)188     public void onContextualCardUpdated(Map<Integer, List<ContextualCard>> updateList) {
189         final Set<Integer> cardTypes = updateList.keySet();
190         // Remove the existing data that matches the certain cardType before inserting new data.
191         List<ContextualCard> cardsToKeep;
192 
193         // We are not sure how many card types will be in the database, so when the list coming
194         // from the database is empty (e.g. no eligible cards/cards are dismissed), we cannot
195         // assign a specific card type for its map which is sending here. Thus, we assume that
196         // except Conditional cards, all other cards are from the database. So when the map sent
197         // here is empty, we only keep Conditional cards.
198         if (cardTypes.isEmpty()) {
199             final Set<Integer> conditionalCardTypes = new TreeSet<Integer>() {{
200                 add(ContextualCard.CardType.CONDITIONAL);
201                 add(ContextualCard.CardType.CONDITIONAL_HEADER);
202                 add(ContextualCard.CardType.CONDITIONAL_FOOTER);
203             }};
204             cardsToKeep = mContextualCards.stream()
205                     .filter(card -> conditionalCardTypes.contains(card.getCardType()))
206                     .collect(Collectors.toList());
207         } else {
208             cardsToKeep = mContextualCards.stream()
209                     .filter(card -> !cardTypes.contains(card.getCardType()))
210                     .collect(Collectors.toList());
211         }
212 
213         final List<ContextualCard> allCards = new ArrayList<>();
214         allCards.addAll(cardsToKeep);
215         allCards.addAll(
216                 updateList.values().stream().flatMap(List::stream).collect(Collectors.toList()));
217 
218         //replace with the new data
219         mContextualCards.clear();
220         final List<ContextualCard> sortedCards = sortCards(allCards);
221         mContextualCards.addAll(getCardsWithViewType(sortedCards));
222 
223         loadCardControllers();
224 
225         if (mListener != null) {
226             final Map<Integer, List<ContextualCard>> cardsToUpdate = new ArrayMap<>();
227             cardsToUpdate.put(ContextualCard.CardType.DEFAULT, mContextualCards);
228             mListener.onContextualCardUpdated(cardsToUpdate);
229         }
230     }
231 
232     @Override
onFinishCardLoading(List<ContextualCard> cards)233     public void onFinishCardLoading(List<ContextualCard> cards) {
234         final long loadTime = System.currentTimeMillis() - mStartTime;
235         Log.d(TAG, "Total loading time = " + loadTime);
236 
237         final List<ContextualCard> cardsToKeep = getCardsToKeep(cards);
238 
239         final MetricsFeatureProvider metricsFeatureProvider =
240                 FeatureFactory.getFactory(mContext).getMetricsFeatureProvider();
241 
242         //navigate back to the homepage, screen rotate or after card dismissal
243         if (!mIsFirstLaunch) {
244             onContextualCardUpdated(cardsToKeep.stream()
245                     .collect(groupingBy(ContextualCard::getCardType)));
246             metricsFeatureProvider.action(mContext,
247                     SettingsEnums.ACTION_CONTEXTUAL_CARD_SHOW,
248                     ContextualCardLogUtils.buildCardListLog(cardsToKeep));
249             return;
250         }
251 
252         final long timeoutLimit = getCardLoaderTimeout();
253         if (loadTime <= timeoutLimit) {
254             onContextualCardUpdated(cards.stream()
255                     .collect(groupingBy(ContextualCard::getCardType)));
256             metricsFeatureProvider.action(mContext,
257                     SettingsEnums.ACTION_CONTEXTUAL_CARD_SHOW,
258                     ContextualCardLogUtils.buildCardListLog(cards));
259         } else {
260             // log timeout occurrence
261             metricsFeatureProvider.action(SettingsEnums.PAGE_UNKNOWN,
262                     SettingsEnums.ACTION_CONTEXTUAL_CARD_LOAD_TIMEOUT,
263                     SettingsEnums.SETTINGS_HOMEPAGE,
264                     null /* key */, (int) loadTime /* value */);
265         }
266         //only log homepage display upon a fresh launch
267         final long totalTime = System.currentTimeMillis() - mStartTime;
268         metricsFeatureProvider.action(mContext,
269                 SettingsEnums.ACTION_CONTEXTUAL_HOME_SHOW, (int) totalTime);
270 
271         mIsFirstLaunch = false;
272     }
273 
274     @Override
onSaveInstanceState(Bundle outState)275     public void onSaveInstanceState(Bundle outState) {
276         final ArrayList<String> cards = mContextualCards.stream()
277                 .map(ContextualCard::getName)
278                 .collect(Collectors.toCollection(ArrayList::new));
279 
280         outState.putStringArrayList(KEY_CONTEXTUAL_CARDS, cards);
281     }
282 
onWindowFocusChanged(boolean hasWindowFocus)283     public void onWindowFocusChanged(boolean hasWindowFocus) {
284         // Duplicate a list to avoid java.util.ConcurrentModificationException.
285         final List<ContextualCard> cards = new ArrayList<>(mContextualCards);
286         boolean hasConditionController = false;
287         for (ContextualCard card : cards) {
288             final ContextualCardController controller = getControllerRendererPool()
289                     .getController(mContext, card.getCardType());
290             if (controller instanceof ConditionalCardController) {
291                 hasConditionController = true;
292             }
293             if (hasWindowFocus && controller instanceof OnStart) {
294                 ((OnStart) controller).onStart();
295             }
296             if (!hasWindowFocus && controller instanceof OnStop) {
297                 ((OnStop) controller).onStop();
298             }
299         }
300         // Conditional cards will always be refreshed whether or not there are conditional cards
301         // in the homepage.
302         if (!hasConditionController) {
303             final ContextualCardController controller = getControllerRendererPool()
304                     .getController(mContext, ContextualCard.CardType.CONDITIONAL);
305             if (hasWindowFocus && controller instanceof OnStart) {
306                 ((OnStart) controller).onStart();
307             }
308             if (!hasWindowFocus && controller instanceof OnStop) {
309                 ((OnStop) controller).onStop();
310             }
311         }
312     }
313 
getControllerRendererPool()314     public ControllerRendererPool getControllerRendererPool() {
315         return mControllerRendererPool;
316     }
317 
setListener(ContextualCardUpdateListener listener)318     void setListener(ContextualCardUpdateListener listener) {
319         mListener = listener;
320     }
321 
322     @VisibleForTesting
getCardsWithViewType(List<ContextualCard> cards)323     List<ContextualCard> getCardsWithViewType(List<ContextualCard> cards) {
324         if (cards.isEmpty()) {
325             return cards;
326         }
327 
328         final List<ContextualCard> result = getCardsWithStickyViewType(cards);
329         return getCardsWithSuggestionViewType(result);
330     }
331 
332     @VisibleForTesting
getCardLoaderTimeout()333     long getCardLoaderTimeout() {
334         // Return the timeout limit if Settings.Global has the KEY_GLOBAL_CARD_LOADER_TIMEOUT key,
335         // else return default timeout.
336         return Settings.Global.getLong(mContext.getContentResolver(),
337                 KEY_GLOBAL_CARD_LOADER_TIMEOUT, CARD_CONTENT_LOADER_TIMEOUT_MS);
338     }
339 
getCardsWithSuggestionViewType(List<ContextualCard> cards)340     private List<ContextualCard> getCardsWithSuggestionViewType(List<ContextualCard> cards) {
341         // Shows as half cards if 2 suggestion type of cards are next to each other.
342         // Shows as full card if 1 suggestion type of card lives alone.
343         final List<ContextualCard> result = new ArrayList<>(cards);
344         for (int index = 1; index < result.size(); index++) {
345             final ContextualCard previous = result.get(index - 1);
346             final ContextualCard current = result.get(index);
347             if (current.getCategory() == SUGGESTION_VALUE
348                     && previous.getCategory() == SUGGESTION_VALUE) {
349                 result.set(index - 1, previous.mutate().setViewType(
350                         SliceContextualCardRenderer.VIEW_TYPE_HALF_WIDTH).build());
351                 result.set(index, current.mutate().setViewType(
352                         SliceContextualCardRenderer.VIEW_TYPE_HALF_WIDTH).build());
353                 index++;
354             }
355         }
356         return result;
357     }
358 
getCardsWithStickyViewType(List<ContextualCard> cards)359     private List<ContextualCard> getCardsWithStickyViewType(List<ContextualCard> cards) {
360         final List<ContextualCard> result = new ArrayList<>(cards);
361         for (int index = 0; index < result.size(); index++) {
362             final ContextualCard card = cards.get(index);
363             if (card.getCategory() == STICKY_VALUE) {
364                 result.set(index, card.mutate().setViewType(
365                         SliceContextualCardRenderer.VIEW_TYPE_STICKY).build());
366             }
367         }
368         return result;
369     }
370 
371     @VisibleForTesting
getCardsToKeep(List<ContextualCard> cards)372     List<ContextualCard> getCardsToKeep(List<ContextualCard> cards) {
373         if (mSavedCards != null) {
374             //screen rotate
375             final List<ContextualCard> cardsToKeep = cards.stream()
376                     .filter(card -> mSavedCards.contains(card.getName()))
377                     .collect(Collectors.toList());
378             mSavedCards = null;
379             return cardsToKeep;
380         } else {
381             //navigate back to the homepage or after dismissing a card
382             return cards.stream()
383                     .filter(card -> mContextualCards.contains(card))
384                     .collect(Collectors.toList());
385         }
386     }
387 
388     static class CardContentLoaderCallbacks implements
389             LoaderManager.LoaderCallbacks<List<ContextualCard>> {
390 
391         private Context mContext;
392         private ContextualCardLoader.CardContentLoaderListener mListener;
393 
CardContentLoaderCallbacks(Context context)394         CardContentLoaderCallbacks(Context context) {
395             mContext = context.getApplicationContext();
396         }
397 
setListener(ContextualCardLoader.CardContentLoaderListener listener)398         protected void setListener(ContextualCardLoader.CardContentLoaderListener listener) {
399             mListener = listener;
400         }
401 
402         @NonNull
403         @Override
onCreateLoader(int id, @Nullable Bundle bundle)404         public Loader<List<ContextualCard>> onCreateLoader(int id, @Nullable Bundle bundle) {
405             if (id == CARD_CONTENT_LOADER_ID) {
406                 return new ContextualCardLoader(mContext);
407             } else {
408                 throw new IllegalArgumentException("Unknown loader id: " + id);
409             }
410         }
411 
412         @Override
onLoadFinished(@onNull Loader<List<ContextualCard>> loader, List<ContextualCard> contextualCards)413         public void onLoadFinished(@NonNull Loader<List<ContextualCard>> loader,
414                 List<ContextualCard> contextualCards) {
415             if (mListener != null) {
416                 mListener.onFinishCardLoading(contextualCards);
417             }
418         }
419 
420         @Override
onLoaderReset(@onNull Loader<List<ContextualCard>> loader)421         public void onLoaderReset(@NonNull Loader<List<ContextualCard>> loader) {
422 
423         }
424     }
425 }
426