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