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