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