/* * Copyright (C) 2018 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.settings.homepage.contextualcards; import static com.android.settings.homepage.contextualcards.ContextualCardLoader.CARD_CONTENT_LOADER_ID; import static com.android.settings.intelligence.ContextualCardProto.ContextualCard.Category.STICKY_VALUE; import static com.android.settings.intelligence.ContextualCardProto.ContextualCard.Category.SUGGESTION_VALUE; import static java.util.stream.Collectors.groupingBy; import android.app.settings.SettingsEnums; import android.content.Context; import android.os.Bundle; import android.provider.Settings; import android.text.format.DateUtils; import android.util.ArrayMap; import android.util.FeatureFlagUtils; import android.util.Log; import android.widget.BaseAdapter; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; import com.android.settings.R; import com.android.settings.core.FeatureFlags; import com.android.settings.homepage.contextualcards.conditional.ConditionalCardController; import com.android.settings.homepage.contextualcards.logging.ContextualCardLogUtils; import com.android.settings.homepage.contextualcards.slices.SliceContextualCardRenderer; import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.core.lifecycle.Lifecycle; import com.android.settingslib.core.lifecycle.LifecycleObserver; import com.android.settingslib.core.lifecycle.events.OnSaveInstanceState; import com.android.settingslib.core.lifecycle.events.OnStart; import com.android.settingslib.core.lifecycle.events.OnStop; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; /** * This is a centralized manager of multiple {@link ContextualCardController}. * * {@link ContextualCardManager} first loads data from {@link ContextualCardLoader} and gets back a * list of {@link ContextualCard}. All subclasses of {@link ContextualCardController} are loaded * here, which will then trigger the {@link ContextualCardController} to load its data and listen to * corresponding changes. When every single {@link ContextualCardController} updates its data, the * data will be passed here, then going through some sorting mechanisms. The * {@link ContextualCardController} will end up building a list of {@link ContextualCard} for * {@link ContextualCardsAdapter} and {@link BaseAdapter#notifyDataSetChanged()} will be called to * get the page refreshed. */ public class ContextualCardManager implements ContextualCardLoader.CardContentLoaderListener, ContextualCardUpdateListener, LifecycleObserver, OnSaveInstanceState { @VisibleForTesting static final long CARD_CONTENT_LOADER_TIMEOUT_MS = DateUtils.SECOND_IN_MILLIS; @VisibleForTesting static final String KEY_GLOBAL_CARD_LOADER_TIMEOUT = "global_card_loader_timeout_key"; @VisibleForTesting static final String KEY_CONTEXTUAL_CARDS = "key_contextual_cards"; private static final String TAG = "ContextualCardManager"; private final Context mContext; private final Lifecycle mLifecycle; private final List<LifecycleObserver> mLifecycleObservers; private ContextualCardUpdateListener mListener; @VisibleForTesting final ControllerRendererPool mControllerRendererPool; @VisibleForTesting final List<ContextualCard> mContextualCards; @VisibleForTesting long mStartTime; @VisibleForTesting boolean mIsFirstLaunch; @VisibleForTesting List<String> mSavedCards; public ContextualCardManager(Context context, Lifecycle lifecycle, Bundle savedInstanceState) { mContext = context; mLifecycle = lifecycle; mContextualCards = new ArrayList<>(); mLifecycleObservers = new ArrayList<>(); mControllerRendererPool = new ControllerRendererPool(); mLifecycle.addObserver(this); if (savedInstanceState == null) { mIsFirstLaunch = true; mSavedCards = null; } else { mSavedCards = savedInstanceState.getStringArrayList(KEY_CONTEXTUAL_CARDS); } // for data provided by Settings for (@ContextualCard.CardType int cardType : getSettingsCards()) { setupController(cardType); } } void loadContextualCards(LoaderManager loaderManager, boolean restartLoaderNeeded) { if (mContext.getResources().getBoolean(R.bool.config_use_legacy_suggestion)) { Log.w(TAG, "Legacy suggestion contextual card enabled, skipping contextual cards."); return; } mStartTime = System.currentTimeMillis(); final CardContentLoaderCallbacks cardContentLoaderCallbacks = new CardContentLoaderCallbacks(mContext); cardContentLoaderCallbacks.setListener(this); if (!restartLoaderNeeded) { // Use the cached data when navigating back to the first page and upon screen rotation. loaderManager.initLoader(CARD_CONTENT_LOADER_ID, null /* bundle */, cardContentLoaderCallbacks); } else { // Reload all cards when navigating back after pressing home key, recent app key, or // turn off screen. mIsFirstLaunch = true; loaderManager.restartLoader(CARD_CONTENT_LOADER_ID, null /* bundle */, cardContentLoaderCallbacks); } } private void loadCardControllers() { for (ContextualCard card : mContextualCards) { setupController(card.getCardType()); } } @VisibleForTesting int[] getSettingsCards() { if (!FeatureFlagUtils.isEnabled(mContext, FeatureFlags.CONDITIONAL_CARDS)) { return new int[] {ContextualCard.CardType.LEGACY_SUGGESTION}; } return new int[] {ContextualCard.CardType.CONDITIONAL, ContextualCard.CardType.LEGACY_SUGGESTION}; } @VisibleForTesting void setupController(@ContextualCard.CardType int cardType) { final ContextualCardController controller = mControllerRendererPool.getController(mContext, cardType); if (controller == null) { Log.w(TAG, "Cannot find ContextualCardController for type " + cardType); return; } controller.setCardUpdateListener(this); if (controller instanceof LifecycleObserver && !mLifecycleObservers.contains(controller)) { mLifecycleObservers.add((LifecycleObserver) controller); mLifecycle.addObserver((LifecycleObserver) controller); } } @VisibleForTesting List<ContextualCard> sortCards(List<ContextualCard> cards) { // take mContextualCards as the source and do the ranking based on the rule. final List<ContextualCard> result = cards.stream() .sorted((c1, c2) -> Double.compare(c2.getRankingScore(), c1.getRankingScore())) .collect(Collectors.toList()); final List<ContextualCard> stickyCards = result.stream() .filter(c -> c.getCategory() == STICKY_VALUE) .collect(Collectors.toList()); // make sticky cards be at the tail end. result.removeAll(stickyCards); result.addAll(stickyCards); return result; } @Override public void onContextualCardUpdated(Map<Integer, List<ContextualCard>> updateList) { final Set<Integer> cardTypes = updateList.keySet(); // Remove the existing data that matches the certain cardType before inserting new data. List<ContextualCard> cardsToKeep; // We are not sure how many card types will be in the database, so when the list coming // from the database is empty (e.g. no eligible cards/cards are dismissed), we cannot // assign a specific card type for its map which is sending here. Thus, we assume that // except Conditional cards, all other cards are from the database. So when the map sent // here is empty, we only keep Conditional cards. if (cardTypes.isEmpty()) { final Set<Integer> conditionalCardTypes = Set.of( ContextualCard.CardType.CONDITIONAL, ContextualCard.CardType.CONDITIONAL_HEADER, ContextualCard.CardType.CONDITIONAL_FOOTER); cardsToKeep = mContextualCards.stream() .filter(card -> conditionalCardTypes.contains(card.getCardType())) .collect(Collectors.toList()); } else { cardsToKeep = mContextualCards.stream() .filter(card -> !cardTypes.contains(card.getCardType())) .collect(Collectors.toList()); } final List<ContextualCard> allCards = new ArrayList<>(); allCards.addAll(cardsToKeep); allCards.addAll( updateList.values().stream().flatMap(List::stream).collect(Collectors.toList())); //replace with the new data mContextualCards.clear(); final List<ContextualCard> sortedCards = sortCards(allCards); mContextualCards.addAll(getCardsWithViewType(sortedCards)); loadCardControllers(); if (mListener != null) { final Map<Integer, List<ContextualCard>> cardsToUpdate = new ArrayMap<>(); cardsToUpdate.put(ContextualCard.CardType.DEFAULT, mContextualCards); mListener.onContextualCardUpdated(cardsToUpdate); } } @Override public void onFinishCardLoading(List<ContextualCard> cards) { final long loadTime = System.currentTimeMillis() - mStartTime; Log.d(TAG, "Total loading time = " + loadTime); final List<ContextualCard> cardsToKeep = getCardsToKeep(cards); final MetricsFeatureProvider metricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); //navigate back to the homepage, screen rotate or after card dismissal if (!mIsFirstLaunch) { onContextualCardUpdated(cardsToKeep.stream() .collect(groupingBy(ContextualCard::getCardType))); metricsFeatureProvider.action(mContext, SettingsEnums.ACTION_CONTEXTUAL_CARD_SHOW, ContextualCardLogUtils.buildCardListLog(cardsToKeep)); return; } final long timeoutLimit = getCardLoaderTimeout(); if (loadTime <= timeoutLimit) { onContextualCardUpdated(cards.stream() .collect(groupingBy(ContextualCard::getCardType))); metricsFeatureProvider.action(mContext, SettingsEnums.ACTION_CONTEXTUAL_CARD_SHOW, ContextualCardLogUtils.buildCardListLog(cards)); } else { // log timeout occurrence metricsFeatureProvider.action(SettingsEnums.PAGE_UNKNOWN, SettingsEnums.ACTION_CONTEXTUAL_CARD_LOAD_TIMEOUT, SettingsEnums.SETTINGS_HOMEPAGE, null /* key */, (int) loadTime /* value */); } //only log homepage display upon a fresh launch final long totalTime = System.currentTimeMillis() - mStartTime; metricsFeatureProvider.action(mContext, SettingsEnums.ACTION_CONTEXTUAL_HOME_SHOW, (int) totalTime); mIsFirstLaunch = false; } @Override public void onSaveInstanceState(Bundle outState) { final ArrayList<String> cards = mContextualCards.stream() .map(ContextualCard::getName) .collect(Collectors.toCollection(ArrayList::new)); outState.putStringArrayList(KEY_CONTEXTUAL_CARDS, cards); } public void onWindowFocusChanged(boolean hasWindowFocus) { // Duplicate a list to avoid java.util.ConcurrentModificationException. final List<ContextualCard> cards = new ArrayList<>(mContextualCards); boolean hasConditionController = false; for (ContextualCard card : cards) { final ContextualCardController controller = getControllerRendererPool() .getController(mContext, card.getCardType()); if (controller instanceof ConditionalCardController) { hasConditionController = true; } if (hasWindowFocus && controller instanceof OnStart) { ((OnStart) controller).onStart(); } if (!hasWindowFocus && controller instanceof OnStop) { ((OnStop) controller).onStop(); } } // Conditional cards will always be refreshed whether or not there are conditional cards // in the homepage. if (!hasConditionController) { final ContextualCardController controller = getControllerRendererPool() .getController(mContext, ContextualCard.CardType.CONDITIONAL); if (hasWindowFocus && controller instanceof OnStart) { ((OnStart) controller).onStart(); } if (!hasWindowFocus && controller instanceof OnStop) { ((OnStop) controller).onStop(); } } } public ControllerRendererPool getControllerRendererPool() { return mControllerRendererPool; } void setListener(ContextualCardUpdateListener listener) { mListener = listener; } @VisibleForTesting List<ContextualCard> getCardsWithViewType(List<ContextualCard> cards) { if (cards.isEmpty()) { return cards; } final List<ContextualCard> result = getCardsWithStickyViewType(cards); return getCardsWithSuggestionViewType(result); } @VisibleForTesting long getCardLoaderTimeout() { // Return the timeout limit if Settings.Global has the KEY_GLOBAL_CARD_LOADER_TIMEOUT key, // else return default timeout. return Settings.Global.getLong(mContext.getContentResolver(), KEY_GLOBAL_CARD_LOADER_TIMEOUT, CARD_CONTENT_LOADER_TIMEOUT_MS); } private List<ContextualCard> getCardsWithSuggestionViewType(List<ContextualCard> cards) { // Shows as half cards if 2 suggestion type of cards are next to each other. // Shows as full card if 1 suggestion type of card lives alone. final List<ContextualCard> result = new ArrayList<>(cards); for (int index = 1; index < result.size(); index++) { final ContextualCard previous = result.get(index - 1); final ContextualCard current = result.get(index); if (current.getCategory() == SUGGESTION_VALUE && previous.getCategory() == SUGGESTION_VALUE) { result.set(index - 1, previous.mutate().setViewType( SliceContextualCardRenderer.VIEW_TYPE_HALF_WIDTH).build()); result.set(index, current.mutate().setViewType( SliceContextualCardRenderer.VIEW_TYPE_HALF_WIDTH).build()); index++; } } return result; } private List<ContextualCard> getCardsWithStickyViewType(List<ContextualCard> cards) { final List<ContextualCard> result = new ArrayList<>(cards); for (int index = 0; index < result.size(); index++) { final ContextualCard card = cards.get(index); if (card.getCategory() == STICKY_VALUE) { result.set(index, card.mutate().setViewType( SliceContextualCardRenderer.VIEW_TYPE_STICKY).build()); } } return result; } @VisibleForTesting List<ContextualCard> getCardsToKeep(List<ContextualCard> cards) { if (mSavedCards != null) { //screen rotate final List<ContextualCard> cardsToKeep = cards.stream() .filter(card -> mSavedCards.contains(card.getName())) .collect(Collectors.toList()); mSavedCards = null; return cardsToKeep; } else { //navigate back to the homepage or after dismissing a card return cards.stream() .filter(card -> mContextualCards.contains(card)) .collect(Collectors.toList()); } } static class CardContentLoaderCallbacks implements LoaderManager.LoaderCallbacks<List<ContextualCard>> { private Context mContext; private ContextualCardLoader.CardContentLoaderListener mListener; CardContentLoaderCallbacks(Context context) { mContext = context.getApplicationContext(); } protected void setListener(ContextualCardLoader.CardContentLoaderListener listener) { mListener = listener; } @NonNull @Override public Loader<List<ContextualCard>> onCreateLoader(int id, @Nullable Bundle bundle) { if (id == CARD_CONTENT_LOADER_ID) { return new ContextualCardLoader(mContext); } else { throw new IllegalArgumentException("Unknown loader id: " + id); } } @Override public void onLoadFinished(@NonNull Loader<List<ContextualCard>> loader, List<ContextualCard> contextualCards) { if (mListener != null) { mListener.onFinishCardLoading(contextualCards); } } @Override public void onLoaderReset(@NonNull Loader<List<ContextualCard>> loader) { } } }