| /* |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License |
| */ |
| |
| package com.android.settings.slices; |
| |
| import static android.Manifest.permission.READ_SEARCH_INDEXABLES; |
| import static android.app.slice.Slice.HINT_PARTIAL; |
| |
| import android.app.PendingIntent; |
| import android.app.settings.SettingsEnums; |
| import android.app.slice.SliceManager; |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.pm.PackageManager; |
| import android.net.Uri; |
| import android.os.Binder; |
| import android.os.StrictMode; |
| import android.os.UserManager; |
| import android.provider.Settings; |
| import android.provider.SettingsSlicesContract; |
| import android.text.TextUtils; |
| import android.util.ArrayMap; |
| import android.util.KeyValueListParser; |
| import android.util.Log; |
| import android.util.Pair; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.collection.ArraySet; |
| import androidx.slice.Slice; |
| import androidx.slice.SliceProvider; |
| |
| import com.android.settings.R; |
| import com.android.settings.Utils; |
| import com.android.settings.bluetooth.BluetoothSliceBuilder; |
| import com.android.settings.core.BasePreferenceController; |
| import com.android.settings.notification.VolumeSeekBarPreferenceController; |
| import com.android.settings.notification.zen.ZenModeSliceBuilder; |
| import com.android.settings.overlay.FeatureFactory; |
| import com.android.settingslib.SliceBroadcastRelay; |
| import com.android.settingslib.utils.ThreadUtils; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.WeakHashMap; |
| import java.util.stream.Collectors; |
| |
| /** |
| * A {@link SliceProvider} for Settings to enabled inline results in system apps. |
| * |
| * <p>{@link SettingsSliceProvider} accepts a {@link Uri} with {@link #SLICE_AUTHORITY} and a |
| * {@code String} key based on the setting intended to be changed. This provider builds a |
| * {@link Slice} and responds to Slice actions through the database defined by |
| * {@link SlicesDatabaseHelper}, whose data is written by {@link SlicesIndexer}. |
| * |
| * <p>When a {@link Slice} is requested, we start loading {@link SliceData} in the background and |
| * return an stub {@link Slice} with the correct {@link Uri} immediately. In the background, the |
| * data corresponding to the key in the {@link Uri} is read by {@link SlicesDatabaseAccessor}, and |
| * the entire row is converted into a {@link SliceData}. Once complete, it is stored in |
| * {@link #mSliceWeakDataCache}, and then an update sent via the Slice framework to the Slice. |
| * The {@link Slice} displayed by the Slice-presenter will re-query this Slice-provider and find |
| * the {@link SliceData} cached to build the full {@link Slice}. |
| * |
| * <p>When an action is taken on that {@link Slice}, we receive the action in |
| * {@link SliceBroadcastReceiver}, and use the |
| * {@link com.android.settings.core.BasePreferenceController} indexed as |
| * {@link SlicesDatabaseHelper.IndexColumns#CONTROLLER} to manipulate the setting. |
| */ |
| public class SettingsSliceProvider extends SliceProvider { |
| |
| private static final String TAG = "SettingsSliceProvider"; |
| |
| /** |
| * Authority for Settings slices not officially supported by the platform, but extensible for |
| * OEMs. |
| */ |
| public static final String SLICE_AUTHORITY = "com.android.settings.slices"; |
| |
| /** |
| * Action passed for changes to Toggle Slices. |
| */ |
| public static final String ACTION_TOGGLE_CHANGED = |
| "com.android.settings.slice.action.TOGGLE_CHANGED"; |
| |
| /** |
| * Action passed for changes to Slider Slices. |
| */ |
| public static final String ACTION_SLIDER_CHANGED = |
| "com.android.settings.slice.action.SLIDER_CHANGED"; |
| |
| /** |
| * Intent Extra passed for the key identifying the Setting Slice. |
| */ |
| public static final String EXTRA_SLICE_KEY = "com.android.settings.slice.extra.key"; |
| |
| /** |
| * A list of custom slice uris that are supported publicly. This is a subset of slices defined |
| * in {@link CustomSliceRegistry}. Things here are exposed publicly so all clients with proper |
| * permission can use them. |
| */ |
| private static final List<Uri> PUBLICLY_SUPPORTED_CUSTOM_SLICE_URIS = |
| Arrays.asList( |
| CustomSliceRegistry.BLUETOOTH_URI, |
| CustomSliceRegistry.FLASHLIGHT_SLICE_URI, |
| CustomSliceRegistry.LOCATION_SLICE_URI, |
| CustomSliceRegistry.MOBILE_DATA_SLICE_URI, |
| CustomSliceRegistry.WIFI_CALLING_URI, |
| CustomSliceRegistry.WIFI_SLICE_URI, |
| CustomSliceRegistry.ZEN_MODE_SLICE_URI |
| ); |
| |
| private static final KeyValueListParser KEY_VALUE_LIST_PARSER = new KeyValueListParser(','); |
| |
| @VisibleForTesting |
| SlicesDatabaseAccessor mSlicesDatabaseAccessor; |
| |
| @VisibleForTesting |
| Map<Uri, SliceData> mSliceWeakDataCache; |
| |
| @VisibleForTesting |
| final Map<Uri, SliceBackgroundWorker> mPinnedWorkers = new ArrayMap<>(); |
| |
| private Boolean mNightMode; |
| private boolean mFirstSlicePinned; |
| private boolean mFirstSliceBound; |
| |
| public SettingsSliceProvider() { |
| super(READ_SEARCH_INDEXABLES); |
| Log.d(TAG, "init"); |
| } |
| |
| @Override |
| public boolean onCreateSliceProvider() { |
| Log.d(TAG, "onCreateSliceProvider"); |
| mSlicesDatabaseAccessor = new SlicesDatabaseAccessor(getContext()); |
| mSliceWeakDataCache = new WeakHashMap<>(); |
| return true; |
| } |
| |
| @Override |
| public void onSlicePinned(Uri sliceUri) { |
| if (!mFirstSlicePinned) { |
| Log.d(TAG, "onSlicePinned: " + sliceUri); |
| mFirstSlicePinned = true; |
| } |
| FeatureFactory.getFeatureFactory().getMetricsFeatureProvider() |
| .action(SettingsEnums.PAGE_UNKNOWN, |
| SettingsEnums.ACTION_SETTINGS_SLICE_REQUESTED, |
| SettingsEnums.PAGE_UNKNOWN, |
| sliceUri.getLastPathSegment(), |
| 0); |
| |
| if (CustomSliceRegistry.isValidUri(sliceUri)) { |
| final Context context = getContext(); |
| final CustomSliceable sliceable = FeatureFactory.getFeatureFactory() |
| .getSlicesFeatureProvider().getSliceableFromUri(context, sliceUri); |
| final IntentFilter filter = sliceable.getIntentFilter(); |
| if (filter != null) { |
| registerIntentToUri(filter, sliceUri); |
| } |
| ThreadUtils.postOnMainThread(() -> startBackgroundWorker(sliceable, sliceUri)); |
| return; |
| } |
| |
| if (CustomSliceRegistry.ZEN_MODE_SLICE_URI.equals(sliceUri)) { |
| registerIntentToUri(ZenModeSliceBuilder.INTENT_FILTER, sliceUri); |
| return; |
| } else if (CustomSliceRegistry.BLUETOOTH_URI.equals(sliceUri)) { |
| registerIntentToUri(BluetoothSliceBuilder.INTENT_FILTER, sliceUri); |
| return; |
| } |
| |
| // Start warming the slice, we expect someone will want it soon. |
| loadSliceInBackground(sliceUri); |
| } |
| |
| @Override |
| public void onSliceUnpinned(Uri sliceUri) { |
| mSliceWeakDataCache.remove(sliceUri); |
| final Context context = getContext(); |
| if (!VolumeSliceHelper.unregisterUri(context, sliceUri)) { |
| SliceBroadcastRelay.unregisterReceivers(context, sliceUri); |
| } |
| ThreadUtils.postOnMainThread(() -> stopBackgroundWorker(sliceUri)); |
| } |
| |
| @Override |
| public Slice onBindSlice(Uri sliceUri) { |
| if (!mFirstSliceBound) { |
| Log.d(TAG, "onBindSlice start: " + sliceUri); |
| } |
| final StrictMode.ThreadPolicy oldPolicy = StrictMode.getThreadPolicy(); |
| try { |
| if (!ThreadUtils.isMainThread()) { |
| StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() |
| .permitAll() |
| .build()); |
| } |
| final Set<String> blockedKeys = getBlockedKeys(); |
| final String key = sliceUri.getLastPathSegment(); |
| if (blockedKeys.contains(key)) { |
| Log.e(TAG, "Requested blocked slice with Uri: " + sliceUri); |
| return null; |
| } |
| |
| final boolean nightMode = Utils.isNightMode(getContext()); |
| if (mNightMode == null) { |
| mNightMode = nightMode; |
| getContext().setTheme(com.android.settingslib.widget.theme.R.style.Theme_SettingsBase); |
| } else if (mNightMode != nightMode) { |
| Log.d(TAG, "Night mode changed, reload theme"); |
| mNightMode = nightMode; |
| getContext().getTheme().rebase(); |
| } |
| |
| // Checking if some semi-sensitive slices are requested by a guest user. If so, will |
| // return an empty slice. |
| final UserManager userManager = getContext().getSystemService(UserManager.class); |
| if (userManager.isGuestUser() && RestrictedSliceUtils.isGuestRestricted(sliceUri)) { |
| Log.i(TAG, "Guest user access denied."); |
| return null; |
| } |
| |
| // Before adding a slice to {@link CustomSliceManager}, please get approval |
| // from the Settings team. |
| if (CustomSliceRegistry.isValidUri(sliceUri)) { |
| final Context context = getContext(); |
| return FeatureFactory.getFeatureFactory() |
| .getSlicesFeatureProvider().getSliceableFromUri(context, sliceUri) |
| .getSlice(); |
| } |
| |
| if (CustomSliceRegistry.WIFI_CALLING_URI.equals(sliceUri)) { |
| return FeatureFactory.getFeatureFactory() |
| .getSlicesFeatureProvider() |
| .getNewWifiCallingSliceHelper(getContext()) |
| .createWifiCallingSlice(sliceUri); |
| } else if (CustomSliceRegistry.ZEN_MODE_SLICE_URI.equals(sliceUri)) { |
| return ZenModeSliceBuilder.getSlice(getContext()); |
| } else if (CustomSliceRegistry.BLUETOOTH_URI.equals(sliceUri)) { |
| return BluetoothSliceBuilder.getSlice(getContext()); |
| } else if (CustomSliceRegistry.ENHANCED_4G_SLICE_URI.equals(sliceUri)) { |
| return FeatureFactory.getFeatureFactory() |
| .getSlicesFeatureProvider() |
| .getNewEnhanced4gLteSliceHelper(getContext()) |
| .createEnhanced4gLteSlice(sliceUri); |
| } else if (CustomSliceRegistry.WIFI_CALLING_PREFERENCE_URI.equals(sliceUri)) { |
| return FeatureFactory.getFeatureFactory() |
| .getSlicesFeatureProvider() |
| .getNewWifiCallingSliceHelper(getContext()) |
| .createWifiCallingPreferenceSlice(sliceUri); |
| } |
| |
| final SliceData cachedSliceData = mSliceWeakDataCache.get(sliceUri); |
| if (cachedSliceData == null) { |
| loadSliceInBackground(sliceUri); |
| return getSliceStub(sliceUri); |
| } |
| return SliceBuilderUtils.buildSlice(getContext(), cachedSliceData); |
| } finally { |
| StrictMode.setThreadPolicy(oldPolicy); |
| if (!mFirstSliceBound) { |
| Log.v(TAG, "onBindSlice end"); |
| mFirstSliceBound = true; |
| } |
| } |
| } |
| |
| /** |
| * Get a list of all valid Uris based on the keys indexed in the Slices database. |
| * <p> |
| * This will return a list of {@link Uri uris} depending on {@param uri}, following: |
| * 1. Authority & Full Path -> Only {@param uri}. It is only a prefix for itself. |
| * 2. Authority & No path -> A list of authority/action/$KEY$, where |
| * {@code $KEY$} is a list of all Slice-enabled keys for the authority. |
| * 3. Authority & action path -> A list of authority/action/$KEY$, where |
| * {@code $KEY$} is a list of all Slice-enabled keys for the authority. |
| * 4. Empty authority & path -> A list of Uris with all keys for both supported authorities. |
| * 5. Else -> Empty list. |
| * <p> |
| * Note that the authority will stay consistent with {@param uri}, and the list of valid Slice |
| * keys depends on if the authority is {@link SettingsSlicesContract#AUTHORITY} or |
| * {@link #SLICE_AUTHORITY}. |
| * |
| * @param uri The uri to look for descendants under. |
| * @returns all valid Settings uris for which {@param uri} is a prefix. |
| */ |
| @Override |
| public Collection<Uri> onGetSliceDescendants(Uri uri) { |
| final List<Uri> descendants = new ArrayList<>(); |
| Uri finalUri = uri; |
| |
| if (isPrivateSlicesNeeded(finalUri)) { |
| descendants.addAll( |
| mSlicesDatabaseAccessor.getSliceUris(finalUri.getAuthority(), |
| false /* isPublicSlice */)); |
| Log.d(TAG, "provide " + descendants.size() + " non-public slices"); |
| finalUri = new Uri.Builder() |
| .scheme(ContentResolver.SCHEME_CONTENT) |
| .authority(finalUri.getAuthority()) |
| .build(); |
| } |
| |
| final Pair<Boolean, String> pathData = SliceBuilderUtils.getPathData(finalUri); |
| |
| if (pathData != null) { |
| // Uri has a full path and will not have any descendants. |
| descendants.add(finalUri); |
| return descendants; |
| } |
| |
| final String authority = finalUri.getAuthority(); |
| final String path = finalUri.getPath(); |
| final boolean isPathEmpty = path.isEmpty(); |
| |
| // Path is anything but empty, "action", or "intent". Return empty list. |
| if (!isPathEmpty |
| && !TextUtils.equals(path, "/" + SettingsSlicesContract.PATH_SETTING_ACTION) |
| && !TextUtils.equals(path, "/" + SettingsSlicesContract.PATH_SETTING_INTENT)) { |
| // Invalid path prefix, there are no valid Uri descendants. |
| return descendants; |
| } |
| |
| // Add all descendants from db with matching authority. |
| descendants.addAll(mSlicesDatabaseAccessor.getSliceUris(authority, true /*isPublicSlice*/)); |
| |
| if (isPathEmpty && TextUtils.isEmpty(authority)) { |
| // No path nor authority. Return all possible Uris by adding all special slice uri |
| descendants.addAll(PUBLICLY_SUPPORTED_CUSTOM_SLICE_URIS); |
| } else { |
| // Can assume authority belongs to the provider. Return all Uris for the authority. |
| final List<Uri> customSlices = PUBLICLY_SUPPORTED_CUSTOM_SLICE_URIS.stream() |
| .filter(sliceUri -> TextUtils.equals(authority, sliceUri.getAuthority())) |
| .collect(Collectors.toList()); |
| descendants.addAll(customSlices); |
| } |
| grantAllowlistedPackagePermissions(getContext(), descendants); |
| return descendants; |
| } |
| |
| @Nullable |
| @Override |
| public PendingIntent onCreatePermissionRequest(@NonNull Uri sliceUri, |
| @NonNull String callingPackage) { |
| final Intent settingsIntent = new Intent(Settings.ACTION_SETTINGS) |
| .setPackage(Utils.SETTINGS_PACKAGE_NAME); |
| final PendingIntent noOpIntent = PendingIntent.getActivity(getContext(), |
| 0 /* requestCode */, settingsIntent, PendingIntent.FLAG_IMMUTABLE); |
| return noOpIntent; |
| } |
| |
| @VisibleForTesting |
| static void grantAllowlistedPackagePermissions(Context context, List<Uri> descendants) { |
| if (descendants == null) { |
| Log.d(TAG, "No descendants to grant permission with, skipping."); |
| } |
| final String[] allowlistPackages = |
| context.getResources().getStringArray(R.array.slice_allowlist_package_names); |
| if (allowlistPackages == null || allowlistPackages.length == 0) { |
| Log.d(TAG, "No packages to allowlist, skipping."); |
| return; |
| } else { |
| Log.d(TAG, String.format( |
| "Allowlisting %d uris to %d pkgs.", |
| descendants.size(), allowlistPackages.length)); |
| } |
| final SliceManager sliceManager = context.getSystemService(SliceManager.class); |
| for (Uri descendant : descendants) { |
| for (String toPackage : allowlistPackages) { |
| sliceManager.grantSlicePermission(toPackage, descendant); |
| } |
| } |
| } |
| |
| @Override |
| public void shutdown() { |
| ThreadUtils.postOnMainThread(() -> { |
| SliceBackgroundWorker.shutdown(); |
| }); |
| } |
| |
| @VisibleForTesting |
| void loadSlice(Uri uri) { |
| if (mSliceWeakDataCache.containsKey(uri)) { |
| Log.d(TAG, uri + " loaded from cache"); |
| return; |
| } |
| long startBuildTime = System.currentTimeMillis(); |
| |
| final SliceData sliceData; |
| try { |
| sliceData = mSlicesDatabaseAccessor.getSliceDataFromUri(uri); |
| } catch (IllegalStateException e) { |
| Log.d(TAG, "Could not create slicedata for uri: " + uri, e); |
| return; |
| } |
| |
| final BasePreferenceController controller = SliceBuilderUtils.getPreferenceController( |
| getContext(), sliceData); |
| |
| final IntentFilter filter = controller.getIntentFilter(); |
| if (filter != null) { |
| if (controller instanceof VolumeSeekBarPreferenceController) { |
| // Register volume slices to a broadcast relay to reduce unnecessary UI updates |
| VolumeSliceHelper.registerIntentToUri(getContext(), filter, uri, |
| ((VolumeSeekBarPreferenceController) controller).getAudioStream()); |
| } else { |
| registerIntentToUri(filter, uri); |
| } |
| } |
| |
| ThreadUtils.postOnMainThread(() -> startBackgroundWorker(controller, uri)); |
| |
| mSliceWeakDataCache.put(uri, sliceData); |
| getContext().getContentResolver().notifyChange(uri, null /* content observer */); |
| |
| Log.d(TAG, "Built slice (" + uri + ") in: " + |
| (System.currentTimeMillis() - startBuildTime)); |
| } |
| |
| @VisibleForTesting |
| void loadSliceInBackground(Uri uri) { |
| ThreadUtils.postOnBackgroundThread(() -> loadSlice(uri)); |
| } |
| |
| @VisibleForTesting |
| /** |
| * Registers an IntentFilter in SysUI to notify changes to {@param sliceUri} when broadcasts to |
| * {@param intentFilter} happen. |
| */ |
| void registerIntentToUri(IntentFilter intentFilter, Uri sliceUri) { |
| SliceBroadcastRelay.registerReceiver(getContext(), sliceUri, SliceRelayReceiver.class, |
| intentFilter); |
| } |
| |
| @VisibleForTesting |
| Set<String> getBlockedKeys() { |
| final String value = Settings.Global.getString(getContext().getContentResolver(), |
| Settings.Global.BLOCKED_SLICES); |
| final Set<String> set = new ArraySet<>(); |
| |
| try { |
| KEY_VALUE_LIST_PARSER.setString(value); |
| } catch (IllegalArgumentException e) { |
| Log.e(TAG, "Bad Settings Slices Allowlist flags", e); |
| return set; |
| } |
| |
| final String[] parsedValues = parseStringArray(value); |
| Collections.addAll(set, parsedValues); |
| return set; |
| } |
| |
| @VisibleForTesting |
| boolean isPrivateSlicesNeeded(Uri uri) { |
| final Context context = getContext(); |
| final String queryUri = context.getString(R.string.config_non_public_slice_query_uri); |
| |
| if (!TextUtils.isEmpty(queryUri) && TextUtils.equals(uri.toString(), queryUri)) { |
| // check if the calling package is eligible for private slices |
| final int callingUid = Binder.getCallingUid(); |
| final boolean hasPermission = |
| context.checkPermission( |
| android.Manifest.permission.READ_SEARCH_INDEXABLES, |
| Binder.getCallingPid(), |
| callingUid) |
| == PackageManager.PERMISSION_GRANTED; |
| final String[] packages = context.getPackageManager().getPackagesForUid(callingUid); |
| final String callingPackage = |
| packages != null && packages.length > 0 ? packages[0] : null; |
| return hasPermission |
| && TextUtils.equals( |
| callingPackage, |
| context.getString(R.string.config_settingsintelligence_package_name)); |
| } |
| return false; |
| } |
| |
| private void startBackgroundWorker(Sliceable sliceable, Uri uri) { |
| final Class workerClass = sliceable.getBackgroundWorkerClass(); |
| if (workerClass == null) { |
| return; |
| } |
| |
| if (mPinnedWorkers.containsKey(uri)) { |
| return; |
| } |
| |
| Log.d(TAG, "Starting background worker for: " + uri); |
| final SliceBackgroundWorker worker = SliceBackgroundWorker.getInstance( |
| getContext(), sliceable, uri); |
| mPinnedWorkers.put(uri, worker); |
| worker.pin(); |
| } |
| |
| private void stopBackgroundWorker(Uri uri) { |
| final SliceBackgroundWorker worker = mPinnedWorkers.get(uri); |
| if (worker != null) { |
| Log.d(TAG, "Stopping background worker for: " + uri); |
| worker.unpin(); |
| mPinnedWorkers.remove(uri); |
| } |
| } |
| |
| /** |
| * @return an empty {@link Slice} with {@param uri} to be used as a stub while the real |
| * {@link SliceData} is loaded from {@link SlicesDatabaseHelper.Tables#TABLE_SLICES_INDEX}. |
| */ |
| private static Slice getSliceStub(Uri uri) { |
| // TODO: Switch back to ListBuilder when slice loading states are fixed. |
| return new Slice.Builder(uri).addHints(HINT_PARTIAL).build(); |
| } |
| |
| private static String[] parseStringArray(String value) { |
| if (value != null) { |
| String[] parts = value.split(":"); |
| if (parts.length > 0) { |
| return parts; |
| } |
| } |
| return new String[0]; |
| } |
| } |