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