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