1 /*
2  * Copyright (C) 2017 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.slices;
18 
19 import static android.provider.Settings.EXTRA_SETTINGS_EMBEDDED_DEEP_LINK_HIGHLIGHT_MENU_KEY;
20 
21 import static com.android.settings.SettingsActivity.EXTRA_IS_FROM_SLICE;
22 import static com.android.settings.core.BasePreferenceController.DISABLED_DEPENDENT_SETTING;
23 import static com.android.settings.slices.SettingsSliceProvider.EXTRA_SLICE_KEY;
24 
25 import android.annotation.ColorInt;
26 import android.app.PendingIntent;
27 import android.content.Context;
28 import android.content.Intent;
29 import android.net.Uri;
30 import android.os.Bundle;
31 import android.os.UserHandle;
32 import android.provider.SettingsSlicesContract;
33 import android.text.TextUtils;
34 import android.util.ArraySet;
35 import android.util.Log;
36 import android.util.Pair;
37 
38 import androidx.annotation.VisibleForTesting;
39 import androidx.core.graphics.drawable.IconCompat;
40 import androidx.slice.Slice;
41 import androidx.slice.builders.ListBuilder;
42 import androidx.slice.builders.ListBuilder.InputRangeBuilder;
43 import androidx.slice.builders.ListBuilder.RowBuilder;
44 import androidx.slice.builders.SliceAction;
45 
46 import com.android.settings.R;
47 import com.android.settings.SettingsActivity;
48 import com.android.settings.SubSettings;
49 import com.android.settings.Utils;
50 import com.android.settings.core.BasePreferenceController;
51 import com.android.settings.core.SliderPreferenceController;
52 import com.android.settings.core.SubSettingLauncher;
53 import com.android.settings.core.TogglePreferenceController;
54 import com.android.settingslib.RestrictedLockUtils;
55 import com.android.settingslib.RestrictedLockUtilsInternal;
56 import com.android.settingslib.core.AbstractPreferenceController;
57 
58 import java.util.Arrays;
59 import java.util.List;
60 import java.util.Set;
61 import java.util.stream.Collectors;
62 
63 
64 /**
65  * Utility class to build Slices objects and Preference Controllers based on the Database managed
66  * by {@link SlicesDatabaseHelper}
67  */
68 public class SliceBuilderUtils {
69 
70     private static final String TAG = "SliceBuilder";
71 
72     /**
73      * Build a Slice from {@link SliceData}.
74      *
75      * @return a {@link Slice} based on the data provided by {@param sliceData}.
76      * Will build an {@link Intent} based Slice unless the Preference Controller name in
77      * {@param sliceData} is an inline controller.
78      */
buildSlice(Context context, SliceData sliceData)79     public static Slice buildSlice(Context context, SliceData sliceData) {
80         Log.d(TAG, "Creating slice for: " + sliceData.getPreferenceController());
81         final BasePreferenceController controller = getPreferenceController(context, sliceData);
82 
83         if (!controller.isAvailable()) {
84             // Cannot guarantee setting page is accessible, let the presenter handle error case.
85             return null;
86         }
87 
88         if (controller.getAvailabilityStatus() == DISABLED_DEPENDENT_SETTING) {
89             return buildUnavailableSlice(context, sliceData);
90         }
91 
92         String userRestriction = sliceData.getUserRestriction();
93         if (!TextUtils.isEmpty(userRestriction)) {
94             RestrictedLockUtils.EnforcedAdmin admin =
95                     RestrictedLockUtilsInternal.checkIfRestrictionEnforced(context,
96                             userRestriction, UserHandle.myUserId());
97             if (admin != null) {
98                 return buildIntentSlice(context, sliceData, controller);
99             }
100         }
101 
102         switch (sliceData.getSliceType()) {
103             case SliceData.SliceType.INTENT:
104                 return buildIntentSlice(context, sliceData, controller);
105             case SliceData.SliceType.SWITCH:
106                 return buildToggleSlice(context, sliceData, controller);
107             case SliceData.SliceType.SLIDER:
108                 return buildSliderSlice(context, sliceData, controller);
109             default:
110                 throw new IllegalArgumentException(
111                         "Slice type passed was invalid: " + sliceData.getSliceType());
112         }
113     }
114 
115     /**
116      * Splits the Settings Slice Uri path into its two expected components:
117      * - intent/action
118      * - key
119      * <p>
120      * Examples of valid paths are:
121      * - /intent/wifi
122      * - /intent/bluetooth
123      * - /action/wifi
124      * - /action/accessibility/servicename
125      *
126      * @param uri of the Slice. Follows pattern outlined in {@link SettingsSliceProvider}.
127      * @return Pair whose first element {@code true} if the path is prepended with "intent", and
128      * second is a key.
129      */
getPathData(Uri uri)130     public static Pair<Boolean, String> getPathData(Uri uri) {
131         final String path = uri.getPath();
132         final String[] split = path.split("/", 3);
133 
134         // Split should be: [{}, SLICE_TYPE, KEY].
135         // Example: "/action/wifi" -> [{}, "action", "wifi"]
136         //          "/action/longer/path" -> [{}, "action", "longer/path"]
137         if (split.length != 3) {
138             return null;
139         }
140 
141         final boolean isIntent = TextUtils.equals(SettingsSlicesContract.PATH_SETTING_INTENT,
142                 split[1]);
143 
144         return new Pair<>(isIntent, split[2]);
145     }
146 
147     /**
148      * Looks at the controller classname in in {@link SliceData} from {@param sliceData}
149      * and attempts to build an {@link AbstractPreferenceController}.
150      */
getPreferenceController(Context context, SliceData sliceData)151     public static BasePreferenceController getPreferenceController(Context context,
152             SliceData sliceData) {
153         return getPreferenceController(context, sliceData.getPreferenceController(),
154                 sliceData.getKey());
155     }
156 
157     /**
158      * @return {@link PendingIntent} for a non-primary {@link SliceAction}.
159      */
getActionIntent(Context context, String action, SliceData data)160     public static PendingIntent getActionIntent(Context context, String action, SliceData data) {
161         final Intent intent = new Intent(action)
162                 .setData(data.getUri())
163                 .setClass(context, SliceBroadcastReceiver.class)
164                 .putExtra(EXTRA_SLICE_KEY, data.getKey());
165         return PendingIntent.getBroadcast(context, 0 /* requestCode */, intent,
166                 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
167     }
168 
169     /**
170      * @return {@link PendingIntent} for the primary {@link SliceAction}.
171      */
getContentPendingIntent(Context context, SliceData sliceData)172     public static PendingIntent getContentPendingIntent(Context context, SliceData sliceData) {
173         final Intent intent = getContentIntent(context, sliceData);
174         return PendingIntent.getActivity(context, 0 /* requestCode */, intent,
175                 PendingIntent.FLAG_IMMUTABLE);
176     }
177 
178     /**
179      * @return the summary text for a {@link Slice} built for {@param sliceData}.
180      */
getSubtitleText(Context context, BasePreferenceController controller, SliceData sliceData)181     public static CharSequence getSubtitleText(Context context,
182             BasePreferenceController controller, SliceData sliceData) {
183 
184         // Priority 1 : User prefers showing the dynamic summary in slice view rather than static
185         // summary. Note it doesn't require a valid summary - so we can force some slices to have
186         // empty summaries (ex: volume).
187         if (controller.useDynamicSliceSummary()) {
188             return controller.getSummary();
189         }
190 
191         // Priority 2: Show summary from slice data.
192         CharSequence summaryText = sliceData.getSummary();
193         if (isValidSummary(context, summaryText)) {
194             return summaryText;
195         }
196 
197         // Priority 3: Show screen title.
198         summaryText = sliceData.getScreenTitle();
199         if (isValidSummary(context, summaryText) && !TextUtils.equals(summaryText,
200                 sliceData.getTitle())) {
201             return summaryText;
202         }
203 
204         // Priority 4: Show empty text.
205         return "";
206     }
207 
buildSearchResultPageIntent(Context context, String className, String key, String screenTitle, int sourceMetricsCategory, int highlightMenuRes)208     public static Intent buildSearchResultPageIntent(Context context, String className, String key,
209             String screenTitle, int sourceMetricsCategory, int highlightMenuRes) {
210         final Bundle args = new Bundle();
211         String highlightMenuKey = null;
212         if (highlightMenuRes != 0) {
213             highlightMenuKey = context.getString(highlightMenuRes);
214             if (TextUtils.isEmpty(highlightMenuKey)) {
215                 Log.w(TAG, "Invalid menu key res from: " + screenTitle);
216             }
217         }
218         args.putString(SettingsActivity.EXTRA_FRAGMENT_ARG_KEY, key);
219         final Intent searchDestination = new SubSettingLauncher(context)
220                 .setDestination(className)
221                 .setArguments(args)
222                 .setTitleText(screenTitle)
223                 .setSourceMetricsCategory(sourceMetricsCategory)
224                 .toIntent();
225         searchDestination
226                 .putExtra(SettingsActivity.EXTRA_FRAGMENT_ARG_KEY, key)
227                 .putExtra(EXTRA_IS_FROM_SLICE, true)
228                 .putExtra(EXTRA_SETTINGS_EMBEDDED_DEEP_LINK_HIGHLIGHT_MENU_KEY, highlightMenuKey)
229                 .setAction("com.android.settings.SEARCH_RESULT_TRAMPOLINE")
230                 .setComponent(null);
231         searchDestination.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
232 
233         return searchDestination;
234     }
235 
236     /**
237      * Build a search result page intent for {@link CustomSliceable}
238      */
buildSearchResultPageIntent(Context context, String className, String key, String screenTitle, int sourceMetricsCategory, CustomSliceable sliceable)239     public static Intent buildSearchResultPageIntent(Context context, String className, String key,
240             String screenTitle, int sourceMetricsCategory, CustomSliceable sliceable) {
241         return buildSearchResultPageIntent(context, className, key, screenTitle,
242                 sourceMetricsCategory, sliceable.getSliceHighlightMenuRes());
243     }
244 
getContentIntent(Context context, SliceData sliceData)245     public static Intent getContentIntent(Context context, SliceData sliceData) {
246         final Uri contentUri = new Uri.Builder().appendPath(sliceData.getKey()).build();
247         final String screenTitle = TextUtils.isEmpty(sliceData.getScreenTitle()) ? null
248                 : sliceData.getScreenTitle().toString();
249         final Intent intent = buildSearchResultPageIntent(context,
250                 sliceData.getFragmentClassName(), sliceData.getKey(),
251                 screenTitle, 0 /* TODO */, sliceData.getHighlightMenuRes());
252         intent.setClassName(context.getPackageName(), SubSettings.class.getName());
253         intent.setData(contentUri);
254         return intent;
255     }
256 
buildToggleSlice(Context context, SliceData sliceData, BasePreferenceController controller)257     private static Slice buildToggleSlice(Context context, SliceData sliceData,
258             BasePreferenceController controller) {
259         final PendingIntent contentIntent = getContentPendingIntent(context, sliceData);
260         final IconCompat icon = getSafeIcon(context, sliceData);
261         final CharSequence subtitleText = getSubtitleText(context, controller, sliceData);
262         @ColorInt final int color = Utils.getColorAccentDefaultColor(context);
263         final TogglePreferenceController toggleController =
264                 (TogglePreferenceController) controller;
265         final SliceAction sliceAction = getToggleAction(context, sliceData,
266                 toggleController.isChecked());
267         final Set<String> keywords = buildSliceKeywords(sliceData);
268         final RowBuilder rowBuilder = new RowBuilder()
269                 .setTitle(sliceData.getTitle())
270                 .setPrimaryAction(
271                         SliceAction.createDeeplink(contentIntent, icon,
272                                 ListBuilder.ICON_IMAGE, sliceData.getTitle()))
273                 .addEndItem(sliceAction);
274         if (!Utils.isSettingsIntelligence(context)) {
275             rowBuilder.setSubtitle(subtitleText);
276         }
277 
278         return new ListBuilder(context, sliceData.getUri(), ListBuilder.INFINITY)
279                 .setAccentColor(color)
280                 .addRow(rowBuilder)
281                 .setKeywords(keywords)
282                 .build();
283     }
284 
buildIntentSlice(Context context, SliceData sliceData, BasePreferenceController controller)285     private static Slice buildIntentSlice(Context context, SliceData sliceData,
286             BasePreferenceController controller) {
287         final PendingIntent contentIntent = getContentPendingIntent(context, sliceData);
288         final IconCompat icon = getSafeIcon(context, sliceData);
289         final CharSequence subtitleText = getSubtitleText(context, controller, sliceData);
290         @ColorInt final int color = Utils.getColorAccentDefaultColor(context);
291         final Set<String> keywords = buildSliceKeywords(sliceData);
292         final RowBuilder rowBuilder = new RowBuilder()
293                 .setTitle(sliceData.getTitle())
294                 .setPrimaryAction(
295                         SliceAction.createDeeplink(contentIntent, icon,
296                                 ListBuilder.ICON_IMAGE,
297                                 sliceData.getTitle()));
298         if (!Utils.isSettingsIntelligence(context)) {
299             rowBuilder.setSubtitle(subtitleText);
300         }
301 
302         return new ListBuilder(context, sliceData.getUri(), ListBuilder.INFINITY)
303                 .setAccentColor(color)
304                 .addRow(rowBuilder)
305                 .setKeywords(keywords)
306                 .build();
307     }
308 
buildSliderSlice(Context context, SliceData sliceData, BasePreferenceController controller)309     private static Slice buildSliderSlice(Context context, SliceData sliceData,
310             BasePreferenceController controller) {
311         final SliderPreferenceController sliderController = (SliderPreferenceController) controller;
312         if (sliderController.getMax() <= sliderController.getMin()) {
313             Log.e(TAG, "Invalid sliderController: " + sliderController.getPreferenceKey());
314             return null;
315         }
316         final PendingIntent actionIntent = getSliderAction(context, sliceData);
317         final PendingIntent contentIntent = getContentPendingIntent(context, sliceData);
318         final IconCompat icon = getSafeIcon(context, sliceData);
319         @ColorInt int color = Utils.getColorAccentDefaultColor(context);
320         final CharSequence subtitleText = getSubtitleText(context, controller, sliceData);
321         final SliceAction primaryAction = SliceAction.createDeeplink(contentIntent, icon,
322                 ListBuilder.ICON_IMAGE, sliceData.getTitle());
323         final Set<String> keywords = buildSliceKeywords(sliceData);
324 
325         int cur = sliderController.getSliderPosition();
326         if (cur < sliderController.getMin()) {
327             cur = sliderController.getMin();
328         }
329         if (cur > sliderController.getMax()) {
330             cur = sliderController.getMax();
331         }
332         final InputRangeBuilder inputRangeBuilder = new InputRangeBuilder()
333                 .setTitle(sliceData.getTitle())
334                 .setPrimaryAction(primaryAction)
335                 .setMax(sliderController.getMax())
336                 .setMin(sliderController.getMin())
337                 .setValue(cur)
338                 .setInputAction(actionIntent);
339         if (sliceData.getIconResource() != 0) {
340             inputRangeBuilder.setTitleItem(icon, ListBuilder.ICON_IMAGE);
341             color = CustomSliceable.COLOR_NOT_TINTED;
342         }
343         if (!Utils.isSettingsIntelligence(context)) {
344             inputRangeBuilder.setSubtitle(subtitleText);
345         }
346 
347         SliceAction endItemAction = sliderController.getSliceEndItem(context);
348         if (endItemAction != null) {
349             inputRangeBuilder.addEndItem(endItemAction);
350         }
351 
352         return new ListBuilder(context, sliceData.getUri(), ListBuilder.INFINITY)
353                 .setAccentColor(color)
354                 .addInputRange(inputRangeBuilder)
355                 .setKeywords(keywords)
356                 .build();
357     }
358 
getPreferenceController(Context context, String controllerClassName, String controllerKey)359     static BasePreferenceController getPreferenceController(Context context,
360             String controllerClassName, String controllerKey) {
361         try {
362             return BasePreferenceController.createInstance(context, controllerClassName);
363         } catch (IllegalStateException e) {
364             // Do nothing
365         }
366 
367         return BasePreferenceController.createInstance(context, controllerClassName, controllerKey);
368     }
369 
getToggleAction(Context context, SliceData sliceData, boolean isChecked)370     private static SliceAction getToggleAction(Context context, SliceData sliceData,
371             boolean isChecked) {
372         PendingIntent actionIntent = getActionIntent(context,
373                 SettingsSliceProvider.ACTION_TOGGLE_CHANGED, sliceData);
374         return SliceAction.createToggle(actionIntent, null, isChecked);
375     }
376 
getSliderAction(Context context, SliceData sliceData)377     private static PendingIntent getSliderAction(Context context, SliceData sliceData) {
378         return getActionIntent(context, SettingsSliceProvider.ACTION_SLIDER_CHANGED, sliceData);
379     }
380 
isValidSummary(Context context, CharSequence summary)381     private static boolean isValidSummary(Context context, CharSequence summary) {
382         if (summary == null || TextUtils.isEmpty(summary.toString().trim())) {
383             return false;
384         }
385 
386         final CharSequence placeHolder = context.getText(R.string.summary_placeholder);
387         final CharSequence doublePlaceHolder =
388                 context.getText(R.string.summary_two_lines_placeholder);
389 
390         return !(TextUtils.equals(summary, placeHolder)
391                 || TextUtils.equals(summary, doublePlaceHolder));
392     }
393 
buildSliceKeywords(SliceData data)394     private static Set<String> buildSliceKeywords(SliceData data) {
395         final Set<String> keywords = new ArraySet<>();
396 
397         keywords.add(data.getTitle());
398 
399         if (!TextUtils.isEmpty(data.getScreenTitle())
400                 && !TextUtils.equals(data.getTitle(), data.getScreenTitle())) {
401             keywords.add(data.getScreenTitle().toString());
402         }
403 
404         final String keywordString = data.getKeywords();
405         if (keywordString != null) {
406             final String[] keywordArray = keywordString.split(",");
407             final List<String> strippedKeywords = Arrays.stream(keywordArray)
408                     .map(s -> s = s.trim())
409                     .collect(Collectors.toList());
410             keywords.addAll(strippedKeywords);
411         }
412 
413         return keywords;
414     }
415 
buildUnavailableSlice(Context context, SliceData data)416     private static Slice buildUnavailableSlice(Context context, SliceData data) {
417         final String title = data.getTitle();
418         final Set<String> keywords = buildSliceKeywords(data);
419         @ColorInt final int color = Utils.getColorAccentDefaultColor(context);
420 
421         final String customSubtitle = data.getUnavailableSliceSubtitle();
422         final CharSequence subtitle = !TextUtils.isEmpty(customSubtitle) ? customSubtitle
423                 : context.getText(R.string.disabled_dependent_setting_summary);
424         final IconCompat icon = getSafeIcon(context, data);
425         final SliceAction primaryAction = SliceAction.createDeeplink(
426                 getContentPendingIntent(context, data),
427                 icon, ListBuilder.ICON_IMAGE, title);
428         final RowBuilder rowBuilder = new RowBuilder()
429                 .setTitle(title)
430                 .setTitleItem(icon, ListBuilder.ICON_IMAGE)
431                 .setPrimaryAction(primaryAction);
432         if (!Utils.isSettingsIntelligence(context)) {
433             rowBuilder.setSubtitle(subtitle);
434         }
435 
436         return new ListBuilder(context, data.getUri(), ListBuilder.INFINITY)
437                 .setAccentColor(color)
438                 .addRow(rowBuilder)
439                 .setKeywords(keywords)
440                 .build();
441     }
442 
443     @VisibleForTesting
getSafeIcon(Context context, SliceData data)444     static IconCompat getSafeIcon(Context context, SliceData data) {
445         int iconResource = data.getIconResource();
446 
447         if (iconResource == 0) {
448             iconResource = R.drawable.ic_settings_accent;
449         }
450         try {
451             return IconCompat.createWithResource(context, iconResource);
452         } catch (Exception e) {
453             Log.w(TAG, "Falling back to settings icon because there is an error getting slice icon "
454                     + data.getUri(), e);
455             return IconCompat.createWithResource(context, R.drawable.ic_settings_accent);
456         }
457     }
458 }
459