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