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