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.intelligence.ContextualCardProto.ContextualCard.Category.STICKY_VALUE;
20 import static com.android.settings.slices.CustomSliceRegistry.BLUETOOTH_DEVICES_SLICE_URI;
21 
22 import android.app.settings.SettingsEnums;
23 import android.content.Context;
24 import android.database.ContentObserver;
25 import android.database.Cursor;
26 import android.net.Uri;
27 import android.os.Handler;
28 import android.os.Looper;
29 import android.provider.Settings;
30 import android.util.Log;
31 
32 import androidx.annotation.NonNull;
33 import androidx.annotation.VisibleForTesting;
34 
35 import com.android.settings.R;
36 import com.android.settings.homepage.contextualcards.logging.ContextualCardLogUtils;
37 import com.android.settings.overlay.FeatureFactory;
38 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
39 import com.android.settingslib.utils.AsyncLoaderCompat;
40 
41 import java.util.ArrayList;
42 import java.util.List;
43 import java.util.concurrent.ExecutorService;
44 import java.util.concurrent.Executors;
45 import java.util.concurrent.Future;
46 import java.util.concurrent.TimeUnit;
47 import java.util.stream.Collectors;
48 
49 public class ContextualCardLoader extends AsyncLoaderCompat<List<ContextualCard>> {
50 
51     @VisibleForTesting
52     static final int DEFAULT_CARD_COUNT = 3;
53     @VisibleForTesting
54     static final String CONTEXTUAL_CARD_COUNT = "contextual_card_count";
55     static final int CARD_CONTENT_LOADER_ID = 1;
56 
57     private static final String TAG = "ContextualCardLoader";
58     private static final long ELIGIBILITY_CHECKER_TIMEOUT_MS = 400;
59 
60     private final ContentObserver mObserver = new ContentObserver(
61             new Handler(Looper.getMainLooper())) {
62         @Override
63         public void onChange(boolean selfChange, Uri uri) {
64             if (isStarted()) {
65                 mNotifyUri = uri;
66                 forceLoad();
67             }
68         }
69     };
70 
71     @VisibleForTesting
72     Uri mNotifyUri;
73 
74     private final Context mContext;
75 
ContextualCardLoader(Context context)76     ContextualCardLoader(Context context) {
77         super(context);
78         mContext = context.getApplicationContext();
79     }
80 
81     @Override
onStartLoading()82     protected void onStartLoading() {
83         super.onStartLoading();
84         mNotifyUri = null;
85         mContext.getContentResolver().registerContentObserver(CardContentProvider.REFRESH_CARD_URI,
86                 false /*notifyForDescendants*/, mObserver);
87         mContext.getContentResolver().registerContentObserver(CardContentProvider.DELETE_CARD_URI,
88                 false /*notifyForDescendants*/, mObserver);
89     }
90 
91     @Override
onStopLoading()92     protected void onStopLoading() {
93         super.onStopLoading();
94         mContext.getContentResolver().unregisterContentObserver(mObserver);
95     }
96 
97     @Override
onDiscardResult(List<ContextualCard> result)98     protected void onDiscardResult(List<ContextualCard> result) {
99 
100     }
101 
102     @NonNull
103     @Override
loadInBackground()104     public List<ContextualCard> loadInBackground() {
105         final List<ContextualCard> result = new ArrayList<>();
106         if (mContext.getResources().getBoolean(R.bool.config_use_legacy_suggestion)) {
107             Log.d(TAG, "Skipping - in legacy suggestion mode");
108             return result;
109         }
110         try (Cursor cursor = getContextualCardsFromProvider()) {
111             if (cursor.getCount() > 0) {
112                 for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
113                     final ContextualCard card = new ContextualCard(cursor);
114                     if (isLargeCard(card)) {
115                         result.add(card.mutate().setIsLargeCard(true).build());
116                     } else {
117                         result.add(card);
118                     }
119                 }
120             }
121         }
122         return getDisplayableCards(result);
123     }
124 
125     // Get final displayed cards and log what cards will be displayed/hidden
126     @VisibleForTesting
getDisplayableCards(List<ContextualCard> candidates)127     List<ContextualCard> getDisplayableCards(List<ContextualCard> candidates) {
128         final List<ContextualCard> eligibleCards = filterEligibleCards(candidates);
129         final List<ContextualCard> stickyCards = new ArrayList<>();
130         final List<ContextualCard> visibleCards = new ArrayList<>();
131         final List<ContextualCard> hiddenCards = new ArrayList<>();
132 
133         final int maxCardCount = getCardCount();
134         eligibleCards.forEach(card -> {
135             if (card.getCategory() != STICKY_VALUE) {
136                 return;
137             }
138             if (stickyCards.size() < maxCardCount) {
139                 stickyCards.add(card);
140             } else {
141                 hiddenCards.add(card);
142             }
143         });
144 
145         final int nonStickyCardCount = maxCardCount - stickyCards.size();
146         eligibleCards.forEach(card -> {
147             if (card.getCategory() == STICKY_VALUE) {
148                 return;
149             }
150             if (visibleCards.size() < nonStickyCardCount) {
151                 visibleCards.add(card);
152             } else {
153                 hiddenCards.add(card);
154             }
155         });
156         visibleCards.addAll(stickyCards);
157 
158         if (!CardContentProvider.DELETE_CARD_URI.equals(mNotifyUri)) {
159             final MetricsFeatureProvider metricsFeatureProvider =
160                     FeatureFactory.getFeatureFactory().getMetricsFeatureProvider();
161 
162             metricsFeatureProvider.action(mContext,
163                     SettingsEnums.ACTION_CONTEXTUAL_CARD_NOT_SHOW,
164                     ContextualCardLogUtils.buildCardListLog(hiddenCards));
165         }
166         return visibleCards;
167     }
168 
169     @VisibleForTesting
getCardCount()170     int getCardCount() {
171         // Return the card count if Settings.Global has KEY_CONTEXTUAL_CARD_COUNT key,
172         // otherwise return the default one.
173         return Settings.Global.getInt(mContext.getContentResolver(),
174                 CONTEXTUAL_CARD_COUNT, DEFAULT_CARD_COUNT);
175     }
176 
177     @VisibleForTesting
getContextualCardsFromProvider()178     Cursor getContextualCardsFromProvider() {
179         final ContextualCardFeatureProvider cardFeatureProvider =
180                 FeatureFactory.getFeatureFactory().getContextualCardFeatureProvider(mContext);
181         return cardFeatureProvider.getContextualCards();
182     }
183 
184     @VisibleForTesting
filterEligibleCards(List<ContextualCard> candidates)185     List<ContextualCard> filterEligibleCards(List<ContextualCard> candidates) {
186         if (candidates.isEmpty()) {
187             return candidates;
188         }
189 
190         final ExecutorService executor = Executors.newFixedThreadPool(candidates.size());
191         final List<ContextualCard> cards = new ArrayList<>();
192         List<Future<ContextualCard>> eligibleCards = new ArrayList<>();
193 
194         final List<EligibleCardChecker> checkers = candidates.stream()
195                 .map(card -> new EligibleCardChecker(mContext, card))
196                 .collect(Collectors.toList());
197         try {
198             eligibleCards = executor.invokeAll(checkers, ELIGIBILITY_CHECKER_TIMEOUT_MS,
199                     TimeUnit.MILLISECONDS);
200         } catch (InterruptedException e) {
201             Log.w(TAG, "Failed to get eligible states for all cards", e);
202         }
203         executor.shutdown();
204 
205         // Collect future and eligible cards
206         for (int i = 0; i < eligibleCards.size(); i++) {
207             final Future<ContextualCard> cardFuture = eligibleCards.get(i);
208             if (cardFuture.isCancelled()) {
209                 Log.w(TAG, "Timeout getting eligible state for card: "
210                         + candidates.get(i).getSliceUri());
211                 continue;
212             }
213 
214             try {
215                 final ContextualCard card = cardFuture.get();
216                 if (card != null) {
217                     cards.add(card);
218                 }
219             } catch (Exception e) {
220                 Log.w(TAG, "Failed to get eligible state for card", e);
221             }
222         }
223         return cards;
224     }
225 
isLargeCard(ContextualCard card)226     private boolean isLargeCard(ContextualCard card) {
227         return card.getSliceUri().equals(BLUETOOTH_DEVICES_SLICE_URI);
228     }
229 
230     public interface CardContentLoaderListener {
onFinishCardLoading(List<ContextualCard> contextualCards)231         void onFinishCardLoading(List<ContextualCard> contextualCards);
232     }
233 }
234