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