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