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