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