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