1 /* 2 * Copyright (C) 2023 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.bluetooth; 18 19 import static android.app.slice.Slice.HINT_PERMISSION_REQUEST; 20 import static android.app.slice.Slice.HINT_TITLE; 21 import static android.app.slice.SliceItem.FORMAT_ACTION; 22 import static android.app.slice.SliceItem.FORMAT_IMAGE; 23 import static android.app.slice.SliceItem.FORMAT_SLICE; 24 import static android.app.slice.SliceItem.FORMAT_TEXT; 25 26 import android.app.PendingIntent; 27 import android.content.Context; 28 import android.content.Intent; 29 import android.net.Uri; 30 import android.text.TextUtils; 31 import android.util.Log; 32 33 import androidx.annotation.Nullable; 34 import androidx.annotation.VisibleForTesting; 35 import androidx.core.graphics.drawable.IconCompat; 36 import androidx.lifecycle.LiveData; 37 import androidx.lifecycle.Observer; 38 import androidx.preference.Preference; 39 import androidx.preference.PreferenceCategory; 40 import androidx.preference.PreferenceScreen; 41 import androidx.slice.Slice; 42 import androidx.slice.SliceItem; 43 import androidx.slice.builders.ListBuilder; 44 import androidx.slice.builders.SliceAction; 45 import androidx.slice.widget.SliceLiveData; 46 47 import com.android.settings.R; 48 import com.android.settings.core.BasePreferenceController; 49 import com.android.settingslib.core.lifecycle.LifecycleObserver; 50 import com.android.settingslib.core.lifecycle.events.OnStart; 51 import com.android.settingslib.core.lifecycle.events.OnStop; 52 53 import java.util.ArrayList; 54 import java.util.List; 55 import java.util.Optional; 56 57 /** 58 * The blocking preference with slice controller will make whole page invisible for a certain time 59 * until {@link Slice} is fully loaded. 60 */ 61 public class BlockingPrefWithSliceController extends BasePreferenceController implements 62 LifecycleObserver, OnStart, OnStop, Observer<Slice>, BasePreferenceController.UiBlocker { 63 private static final String TAG = "BlockingPrefWithSliceController"; 64 65 private static final String PREFIX_KEY = "slice_preference_item_"; 66 67 @VisibleForTesting 68 LiveData<Slice> mLiveData; 69 private Uri mUri; 70 @VisibleForTesting 71 PreferenceCategory mPreferenceCategory; 72 private List<Preference> mCurrentPreferencesList = new ArrayList<>(); 73 @VisibleForTesting 74 String mSliceIntentAction = ""; 75 @VisibleForTesting 76 String mSlicePendingIntentAction = ""; 77 @VisibleForTesting 78 String mExtraIntent = ""; 79 @VisibleForTesting 80 String mExtraPendingIntent = ""; 81 BlockingPrefWithSliceController(Context context, String preferenceKey)82 public BlockingPrefWithSliceController(Context context, String preferenceKey) { 83 super(context, preferenceKey); 84 } 85 86 @Override displayPreference(PreferenceScreen screen)87 public void displayPreference(PreferenceScreen screen) { 88 super.displayPreference(screen); 89 mPreferenceCategory = screen.findPreference(getPreferenceKey()); 90 mSliceIntentAction = mContext.getResources().getString( 91 R.string.config_bt_slice_intent_action); 92 mSlicePendingIntentAction = mContext.getResources().getString( 93 R.string.config_bt_slice_pending_intent_action); 94 mExtraIntent = mContext.getResources().getString(R.string.config_bt_slice_extra_intent); 95 mExtraPendingIntent = mContext.getResources().getString( 96 R.string.config_bt_slice_extra_pending_intent); 97 } 98 99 @Override getAvailabilityStatus()100 public int getAvailabilityStatus() { 101 return mUri != null ? AVAILABLE : UNSUPPORTED_ON_DEVICE; 102 } 103 setSliceUri(Uri uri)104 public void setSliceUri(Uri uri) { 105 mUri = uri; 106 mLiveData = SliceLiveData.fromUri(mContext, mUri, (int type, Throwable source) -> { 107 Log.w(TAG, "Slice may be null. uri = " + uri + ", error = " + type); 108 }); 109 110 //TODO(b/120803703): figure out why we need to remove observer first 111 mLiveData.removeObserver(this); 112 } 113 114 @Override onStart()115 public void onStart() { 116 if (mLiveData == null) { 117 return; 118 } 119 120 try { 121 mLiveData.observeForever(this); 122 } catch (SecurityException e) { 123 Log.w(TAG, "observeForever - no permission"); 124 } 125 } 126 127 @Override onStop()128 public void onStop() { 129 if (mLiveData == null) { 130 return; 131 } 132 133 try { 134 mLiveData.removeObserver(this); 135 } catch (SecurityException e) { 136 Log.w(TAG, "removeObserver - no permission"); 137 } 138 } 139 140 @Override onChanged(Slice slice)141 public void onChanged(Slice slice) { 142 updatePreferenceFromSlice(slice); 143 if (mUiBlockListener != null) { 144 mUiBlockListener.onBlockerWorkFinished(this); 145 } 146 } 147 148 @VisibleForTesting updatePreferenceFromSlice(Slice slice)149 void updatePreferenceFromSlice(Slice slice) { 150 if (TextUtils.isEmpty(mSliceIntentAction) 151 || TextUtils.isEmpty(mExtraIntent) 152 || TextUtils.isEmpty(mSlicePendingIntentAction) 153 || TextUtils.isEmpty(mExtraPendingIntent)) { 154 Log.d(TAG, "No configs"); 155 return; 156 } 157 if (slice == null || slice.hasHint(HINT_PERMISSION_REQUEST)) { 158 Log.d(TAG, "Current slice: " + slice); 159 removePreferenceListFromPreferenceCategory(); 160 return; 161 } 162 updatePreferenceListAndPreferenceCategory(parseSliceToPreferenceList(slice)); 163 } 164 parseSliceToPreferenceList(Slice slice)165 private List<Preference> parseSliceToPreferenceList(Slice slice) { 166 List<Preference> preferenceItemsList = new ArrayList<>(); 167 List<SliceItem> items = slice.getItems(); 168 int orderLevel = 0; 169 for (SliceItem sliceItem : items) { 170 // Parse the slice 171 if (sliceItem.getFormat().equals(FORMAT_SLICE)) { 172 Optional<CharSequence> title = extractTitleFromSlice(sliceItem.getSlice()); 173 Optional<CharSequence> subtitle = extractSubtitleFromSlice(sliceItem.getSlice()); 174 Optional<SliceAction> action = extractActionFromSlice(sliceItem.getSlice()); 175 // Create preference 176 Optional<Preference> preferenceItem = createPreferenceItem(title, subtitle, action, 177 orderLevel); 178 if (preferenceItem.isPresent()) { 179 orderLevel++; 180 preferenceItemsList.add(preferenceItem.get()); 181 } 182 } 183 } 184 return preferenceItemsList; 185 } 186 createPreferenceItem(Optional<CharSequence> title, Optional<CharSequence> subtitle, Optional<SliceAction> sliceAction, int orderLevel)187 private Optional<Preference> createPreferenceItem(Optional<CharSequence> title, 188 Optional<CharSequence> subtitle, Optional<SliceAction> sliceAction, int orderLevel) { 189 Log.d(TAG, "Title: " + title.orElse("no title") 190 + ", Subtitle: " + subtitle.orElse("no Subtitle") 191 + ", Action: " + sliceAction.orElse(null)); 192 if (!title.isPresent()) { 193 return Optional.empty(); 194 } 195 String key = PREFIX_KEY + title.get(); 196 Preference preference = mPreferenceCategory.findPreference(key); 197 if (preference == null) { 198 preference = new Preference(mContext); 199 preference.setKey(key); 200 mPreferenceCategory.addPreference(preference); 201 } 202 preference.setTitle(title.get()); 203 preference.setOrder(orderLevel); 204 if (subtitle.isPresent()) { 205 preference.setSummary(subtitle.get()); 206 } 207 if (sliceAction.isPresent()) { 208 // To support the settings' 2 panel feature, here can't use the slice's 209 // PendingIntent.send(). Since the PendingIntent.send() always take NEW_TASK flag. 210 // Therefore, transfer the slice's PendingIntent to Intent and start it 211 // without NEW_TASK. 212 preference.setIcon(sliceAction.get().getIcon().loadDrawable(mContext)); 213 Intent intentFromSliceAction = sliceAction.get().getAction().getIntent(); 214 Intent expectedActivityIntent = null; 215 Log.d(TAG, "SliceAction: intent's Action:" + intentFromSliceAction.getAction()); 216 if (intentFromSliceAction.getAction().equals(mSliceIntentAction)) { 217 expectedActivityIntent = intentFromSliceAction 218 .getParcelableExtra(mExtraIntent, Intent.class); 219 } else if (intentFromSliceAction.getAction().equals( 220 mSlicePendingIntentAction)) { 221 PendingIntent pendingIntent = intentFromSliceAction 222 .getParcelableExtra(mExtraPendingIntent, PendingIntent.class); 223 expectedActivityIntent = 224 pendingIntent != null ? pendingIntent.getIntent() : null; 225 } else { 226 expectedActivityIntent = intentFromSliceAction; 227 } 228 if (expectedActivityIntent != null && expectedActivityIntent.resolveActivity( 229 mContext.getPackageManager()) != null) { 230 Log.d(TAG, "setIntent: ActivityIntent" + expectedActivityIntent); 231 // Since UI needs to support the Settings' 2 panel feature, the intent can't use the 232 // FLAG_ACTIVITY_NEW_TASK. The above intent may have the FLAG_ACTIVITY_NEW_TASK 233 // flag, so removes it before startActivity(preference.setIntent). 234 expectedActivityIntent.removeFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 235 preference.setIntent(expectedActivityIntent); 236 } else { 237 Log.d(TAG, "setIntent: Intent is null"); 238 preference.setSelectable(false); 239 } 240 } 241 242 return Optional.of(preference); 243 } 244 removePreferenceListFromPreferenceCategory()245 private void removePreferenceListFromPreferenceCategory() { 246 mCurrentPreferencesList.stream() 247 .forEach(p -> mPreferenceCategory.removePreference(p)); 248 mCurrentPreferencesList.clear(); 249 } 250 updatePreferenceListAndPreferenceCategory(List<Preference> newPreferenceList)251 private void updatePreferenceListAndPreferenceCategory(List<Preference> newPreferenceList) { 252 List<Preference> removedItemList = new ArrayList<>(mCurrentPreferencesList); 253 for (Preference item : mCurrentPreferencesList) { 254 if (newPreferenceList.stream().anyMatch(p -> item.compareTo(p) == 0)) { 255 removedItemList.remove(item); 256 } 257 } 258 removedItemList.stream() 259 .forEach(p -> mPreferenceCategory.removePreference(p)); 260 mCurrentPreferencesList = newPreferenceList; 261 } 262 extractTitleFromSlice(Slice slice)263 private Optional<CharSequence> extractTitleFromSlice(Slice slice) { 264 return extractTextFromSlice(slice, HINT_TITLE); 265 } 266 extractSubtitleFromSlice(Slice slice)267 private Optional<CharSequence> extractSubtitleFromSlice(Slice slice) { 268 // For subtitle items, there isn't a hint available. 269 return extractTextFromSlice(slice, /* hint= */ null); 270 } 271 extractTextFromSlice(Slice slice, @Nullable String hint)272 private Optional<CharSequence> extractTextFromSlice(Slice slice, @Nullable String hint) { 273 for (SliceItem item : slice.getItems()) { 274 if (item.getFormat().equals(FORMAT_TEXT) 275 && ((TextUtils.isEmpty(hint) && item.getHints().isEmpty()) 276 || (!TextUtils.isEmpty(hint) && item.hasHint(hint)))) { 277 return Optional.ofNullable(item.getText()); 278 } 279 } 280 return Optional.empty(); 281 } 282 extractActionFromSlice(Slice slice)283 private Optional<SliceAction> extractActionFromSlice(Slice slice) { 284 for (SliceItem item : slice.getItems()) { 285 if (item.getFormat().equals(FORMAT_SLICE)) { 286 if (item.hasHint(HINT_TITLE)) { 287 Optional<SliceAction> result = extractActionFromSlice(item.getSlice()); 288 if (result.isPresent()) { 289 return result; 290 } 291 } 292 continue; 293 } 294 295 if (item.getFormat().equals(FORMAT_ACTION)) { 296 Optional<IconCompat> icon = extractIconFromSlice(item.getSlice()); 297 Optional<CharSequence> title = extractTitleFromSlice(item.getSlice()); 298 if (icon.isPresent()) { 299 return Optional.of( 300 SliceAction.create( 301 item.getAction(), 302 icon.get(), 303 ListBuilder.ICON_IMAGE, 304 title.orElse(/* other= */ ""))); 305 } 306 } 307 } 308 return Optional.empty(); 309 } 310 extractIconFromSlice(Slice slice)311 private Optional<IconCompat> extractIconFromSlice(Slice slice) { 312 for (SliceItem item : slice.getItems()) { 313 if (item.getFormat().equals(FORMAT_IMAGE)) { 314 return Optional.of(item.getIcon()); 315 } 316 } 317 return Optional.empty(); 318 } 319 } 320