1 /* 2 * Copyright (C) 2021 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.car.settings.sound; 18 19 import android.car.drivingstate.CarUxRestrictions; 20 import android.content.Context; 21 import android.content.pm.PackageManager; 22 import android.content.res.Resources; 23 import android.database.Cursor; 24 import android.database.CursorWrapper; 25 import android.media.AudioAttributes; 26 import android.media.Ringtone; 27 import android.media.RingtoneManager; 28 import android.net.Uri; 29 import android.os.Bundle; 30 import android.os.Handler; 31 import android.os.Looper; 32 import android.os.UserHandle; 33 import android.provider.MediaStore; 34 import android.util.TypedValue; 35 import android.view.View; 36 37 import androidx.annotation.VisibleForTesting; 38 import androidx.preference.PreferenceGroup; 39 import androidx.preference.TwoStatePreference; 40 41 import com.android.car.settings.R; 42 import com.android.car.settings.common.FragmentController; 43 import com.android.car.settings.common.Logger; 44 import com.android.car.settings.common.PreferenceController; 45 import com.android.car.ui.preference.CarUiRadioButtonPreference; 46 47 import java.util.regex.Pattern; 48 49 /** A {@link PreferenceController} to help pick a default ringtone. */ 50 public class RingtonePickerPreferenceController extends PreferenceController<PreferenceGroup> { 51 52 private static final Logger LOG = new Logger(RingtonePickerPreferenceController.class); 53 private static final String SOUND_NAME_RES_PREFIX = "sound_name_"; 54 55 @VisibleForTesting 56 static final String COLUMN_LABEL = MediaStore.Audio.Media.TITLE; 57 58 @VisibleForTesting 59 static final int SILENT_ITEM_POS = -1; 60 private static final int UNKNOWN_POS = -2; 61 62 private final Context mUserContext; 63 private RingtoneManager mRingtoneManager; 64 private LocalizedCursor mCursor; 65 private Handler mHandler; 66 67 /** See {@link RingtoneManager} for valid values. */ 68 private int mRingtoneType; 69 private boolean mHasSilentItem; 70 71 private int mCurrentlySelectedPos = UNKNOWN_POS; 72 private TwoStatePreference mCurrentlySelectedPreference; 73 74 private Ringtone mCurrentRingtone; 75 private int mAttributesFlags = 0; 76 private Bundle mArgs; 77 RingtonePickerPreferenceController(Context context, String preferenceKey, FragmentController fragmentController, CarUxRestrictions uxRestrictions)78 public RingtonePickerPreferenceController(Context context, String preferenceKey, 79 FragmentController fragmentController, CarUxRestrictions uxRestrictions) { 80 super(context, preferenceKey, fragmentController, uxRestrictions); 81 82 mUserContext = createPackageContextAsUser(getContext(), UserHandle.myUserId()); 83 mRingtoneManager = new RingtoneManager(getContext(), /* includeParentRingtones= */ true); 84 mHandler = new Handler(Looper.getMainLooper()); 85 mArgs = new Bundle(); 86 } 87 88 /** Arguments used to configure this preference controller. */ setArguments(Bundle args)89 public void setArguments(Bundle args) { 90 mArgs = args; 91 } 92 93 /** 94 * Returns the position of the currently checked preference. Returns 0 if no such element 95 * exists. 96 */ getCurrentlySelectedPreferencePos()97 public int getCurrentlySelectedPreferencePos() { 98 int count = getPreference().getPreferenceCount(); 99 for (int i = 0; i < count; i++) { 100 TwoStatePreference pref = (TwoStatePreference) getPreference().getPreference(i); 101 if (pref.isChecked()) { 102 return i; 103 } 104 } 105 return 0; 106 } 107 108 /** Saves the currently selected ringtone. */ saveRingtone()109 public void saveRingtone() { 110 RingtoneManager.setActualDefaultRingtoneUri(mUserContext, mRingtoneType, 111 getCurrentlySelectedRingtoneUri()); 112 } 113 114 @Override getPreferenceType()115 protected Class<PreferenceGroup> getPreferenceType() { 116 return PreferenceGroup.class; 117 } 118 119 @Override onCreateInternal()120 protected void onCreateInternal() { 121 mRingtoneType = mArgs.getInt(RingtoneManager.EXTRA_RINGTONE_TYPE, /* defaultValue= */ -1); 122 mHasSilentItem = mArgs.getBoolean(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, 123 /* defaultValue= */ true); 124 mAttributesFlags |= mArgs.getInt(RingtoneManager.EXTRA_RINGTONE_AUDIO_ATTRIBUTES_FLAGS, 125 /* defaultValue= */ 0); 126 127 mRingtoneManager.setType(mRingtoneType); 128 mCursor = new LocalizedCursor(mRingtoneManager.getCursor(), getContext().getResources(), 129 COLUMN_LABEL); 130 } 131 132 @Override onStartInternal()133 protected void onStartInternal() { 134 populateRingtones(getPreference()); 135 136 clearSelection(); 137 Uri currentRingtoneUri = 138 RingtoneManager.getActualDefaultRingtoneUri(mUserContext, mRingtoneType); 139 initSelection(currentRingtoneUri); 140 } 141 142 @Override onStopInternal()143 protected void onStopInternal() { 144 stopAnyPlayingRingtone(); 145 clearSelection(); 146 } 147 populateRingtones(PreferenceGroup preference)148 private void populateRingtones(PreferenceGroup preference) { 149 preference.removeAll(); 150 151 // Keep at the front of the list, if requested. 152 if (mHasSilentItem) { 153 String label = getContext().getResources().getString( 154 com.android.internal.R.string.ringtone_silent); 155 int pos = SILENT_ITEM_POS; 156 preference.addPreference(createRingtonePreference(label, pos)); 157 } 158 159 int pos = 0; 160 mCursor.moveToFirst(); 161 while (!mCursor.isAfterLast()) { 162 String label = mCursor.getString(mCursor.getColumnIndex(COLUMN_LABEL)); 163 preference.addPreference(createRingtonePreference(label, pos)); 164 165 mCursor.moveToNext(); 166 pos++; 167 } 168 } 169 createRingtonePreference(String title, int key)170 private TwoStatePreference createRingtonePreference(String title, int key) { 171 CarUiRadioButtonPreference preference = new CarUiRadioButtonPreference(getContext()); 172 preference.setTitle(title); 173 preference.setKey(Integer.toString(key)); 174 preference.setChecked(false); 175 preference.setViewId(View.NO_ID); 176 preference.setOnPreferenceClickListener(pref -> { 177 updateCurrentSelection((TwoStatePreference) pref); 178 playCurrentlySelectedRingtone(); 179 return true; 180 }); 181 return preference; 182 } 183 updateCurrentSelection(TwoStatePreference preference)184 private void updateCurrentSelection(TwoStatePreference preference) { 185 int selectedPos = Integer.parseInt(preference.getKey()); 186 if (mCurrentlySelectedPos != selectedPos) { 187 if (mCurrentlySelectedPreference != null) { 188 mCurrentlySelectedPreference.setChecked(false); 189 mCurrentlySelectedPreference.setViewId(View.NO_ID); 190 } 191 } 192 mCurrentlySelectedPreference = preference; 193 mCurrentlySelectedPos = selectedPos; 194 mCurrentlySelectedPreference.setChecked(true); 195 mCurrentlySelectedPreference.setViewId(R.id.ringtone_picker_selected_id); 196 } 197 initSelection(Uri uri)198 private void initSelection(Uri uri) { 199 if (uri == null) { 200 mCurrentlySelectedPos = SILENT_ITEM_POS; 201 } else { 202 mCurrentlySelectedPos = mRingtoneManager.getRingtonePosition(uri); 203 } 204 int count = getPreference().getPreferenceCount(); 205 for (int i = 0; i < count; i++) { 206 TwoStatePreference pref = (TwoStatePreference) getPreference().getPreference(i); 207 int pos = Integer.parseInt(pref.getKey()); 208 if (mCurrentlySelectedPos == pos) { 209 mCurrentlySelectedPreference = pref; 210 pref.setChecked(true); 211 pref.setViewId(R.id.ringtone_picker_selected_id); 212 } 213 } 214 } 215 clearSelection()216 private void clearSelection() { 217 int count = getPreference().getPreferenceCount(); 218 for (int i = 0; i < count; i++) { 219 TwoStatePreference pref = (TwoStatePreference) getPreference().getPreference(i); 220 pref.setChecked(false); 221 pref.setViewId(View.NO_ID); 222 } 223 224 mCurrentlySelectedPreference = null; 225 mCurrentlySelectedPos = UNKNOWN_POS; 226 } 227 playCurrentlySelectedRingtone()228 private void playCurrentlySelectedRingtone() { 229 mHandler.removeCallbacks(this::run); 230 mHandler.post(this::run); 231 } 232 run()233 private void run() { 234 stopAnyPlayingRingtone(); 235 if (mCurrentlySelectedPos == SILENT_ITEM_POS) { 236 return; 237 } 238 239 if (mCurrentlySelectedPos >= 0) { 240 mCurrentRingtone = mRingtoneManager.getRingtone(mCurrentlySelectedPos); 241 } 242 243 if (mCurrentRingtone != null) { 244 if (mAttributesFlags != 0) { 245 mCurrentRingtone.setAudioAttributes( 246 new AudioAttributes.Builder(mCurrentRingtone.getAudioAttributes()) 247 .setFlags(mAttributesFlags) 248 .build()); 249 } 250 mCurrentRingtone.play(); 251 } 252 } 253 stopAnyPlayingRingtone()254 private void stopAnyPlayingRingtone() { 255 mHandler.removeCallbacks(this::run); 256 257 if (mCurrentRingtone != null && mCurrentRingtone.isPlaying()) { 258 mCurrentRingtone.stop(); 259 } 260 261 if (mRingtoneManager != null) { 262 mRingtoneManager.stopPreviousRingtone(); 263 } 264 } 265 getCurrentlySelectedRingtoneUri()266 private Uri getCurrentlySelectedRingtoneUri() { 267 if (mCurrentlySelectedPos >= 0) { 268 return mRingtoneManager.getRingtoneUri(mCurrentlySelectedPos); 269 } else if (mCurrentlySelectedPos == SILENT_ITEM_POS) { 270 // Use a null Uri for the 'Silent' item. 271 return null; 272 } else { 273 LOG.e("Requesting ringtone URI for unknown position: " + mCurrentlySelectedPos); 274 return null; 275 } 276 } 277 278 /** 279 * Returns a context created from the given context for the given user, or null if it fails. 280 */ createPackageContextAsUser(Context context, int userId)281 private Context createPackageContextAsUser(Context context, int userId) { 282 try { 283 return context.createPackageContextAsUser( 284 context.getPackageName(), /* flags= */ 0, UserHandle.of(userId)); 285 } catch (PackageManager.NameNotFoundException e) { 286 LOG.e("Failed to create user context", e); 287 } 288 return null; 289 } 290 291 /** 292 * A copy of the localized cursor provided in 293 * {@link com.android.soundpicker.RingtonePickerActivity}. 294 */ 295 private static class LocalizedCursor extends CursorWrapper { 296 297 final int mTitleIndex; 298 final Resources mResources; 299 final Pattern mSanitizePattern; 300 String mNamePrefix; 301 LocalizedCursor(Cursor cursor, Resources resources, String columnLabel)302 LocalizedCursor(Cursor cursor, Resources resources, String columnLabel) { 303 super(cursor); 304 mTitleIndex = mCursor.getColumnIndex(columnLabel); 305 mResources = resources; 306 mSanitizePattern = Pattern.compile("[^a-zA-Z0-9]"); 307 if (mTitleIndex == -1) { 308 LOG.e("No index for column " + columnLabel); 309 mNamePrefix = null; 310 } else { 311 try { 312 // Build the prefix for the name of the resource to look up. 313 // Format is: "ResourcePackageName::ResourceTypeName/" 314 // (The type name is expected to be "string" but let's not hardcode it). 315 // Here we use an existing resource "ringtone_title" which is 316 // always expected to be found. 317 mNamePrefix = String.format("%s:%s/%s", 318 mResources.getResourcePackageName(R.string.ringtone_title), 319 mResources.getResourceTypeName(R.string.ringtone_title), 320 SOUND_NAME_RES_PREFIX); 321 } catch (Resources.NotFoundException e) { 322 mNamePrefix = null; 323 } 324 } 325 } 326 327 /** 328 * Process resource name to generate a valid resource name. 329 * 330 * @return a non-null String 331 */ sanitize(String input)332 private String sanitize(String input) { 333 if (input == null) { 334 return ""; 335 } 336 return mSanitizePattern.matcher(input).replaceAll("_").toLowerCase(); 337 } 338 339 @Override getString(int columnIndex)340 public String getString(int columnIndex) { 341 final String defaultName = mCursor.getString(columnIndex); 342 if ((columnIndex != mTitleIndex) || (mNamePrefix == null)) { 343 return defaultName; 344 } 345 TypedValue value = new TypedValue(); 346 try { 347 // The name currently in the database is used to derive a name to match 348 // against resource names in this package. 349 mResources.getValue(mNamePrefix + sanitize(defaultName), value, false); 350 } catch (Resources.NotFoundException e) { 351 // No localized string, use the default string. 352 return defaultName; 353 } 354 if ((value != null) && (value.type == TypedValue.TYPE_STRING)) { 355 LOG.d(String.format("Replacing name %s with %s", 356 defaultName, value.string.toString())); 357 return value.string.toString(); 358 } else { 359 LOG.e("Invalid value when looking up localized name, using " + defaultName); 360 return defaultName; 361 } 362 } 363 } 364 365 @VisibleForTesting setRingtoneManager(RingtoneManager ringtoneManager)366 void setRingtoneManager(RingtoneManager ringtoneManager) { 367 mRingtoneManager = ringtoneManager; 368 } 369 } 370