/* * Copyright (C) 2021 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.car.settings.sound; import android.car.drivingstate.CarUxRestrictions; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.Cursor; import android.database.CursorWrapper; import android.media.AudioAttributes; import android.media.Ringtone; import android.media.RingtoneManager; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.UserHandle; import android.provider.MediaStore; import android.util.TypedValue; import android.view.View; import androidx.annotation.VisibleForTesting; import androidx.preference.PreferenceGroup; import androidx.preference.TwoStatePreference; import com.android.car.settings.R; import com.android.car.settings.common.FragmentController; import com.android.car.settings.common.Logger; import com.android.car.settings.common.PreferenceController; import com.android.car.ui.preference.CarUiRadioButtonPreference; import java.util.regex.Pattern; /** A {@link PreferenceController} to help pick a default ringtone. */ public class RingtonePickerPreferenceController extends PreferenceController { private static final Logger LOG = new Logger(RingtonePickerPreferenceController.class); private static final String SOUND_NAME_RES_PREFIX = "sound_name_"; @VisibleForTesting static final String COLUMN_LABEL = MediaStore.Audio.Media.TITLE; @VisibleForTesting static final int SILENT_ITEM_POS = -1; private static final int UNKNOWN_POS = -2; private final Context mUserContext; private RingtoneManager mRingtoneManager; private LocalizedCursor mCursor; private Handler mHandler; /** See {@link RingtoneManager} for valid values. */ private int mRingtoneType; private boolean mHasSilentItem; private int mCurrentlySelectedPos = UNKNOWN_POS; private TwoStatePreference mCurrentlySelectedPreference; private Ringtone mCurrentRingtone; private int mAttributesFlags = 0; private Bundle mArgs; public RingtonePickerPreferenceController(Context context, String preferenceKey, FragmentController fragmentController, CarUxRestrictions uxRestrictions) { super(context, preferenceKey, fragmentController, uxRestrictions); mUserContext = createPackageContextAsUser(getContext(), UserHandle.myUserId()); mRingtoneManager = new RingtoneManager(getContext(), /* includeParentRingtones= */ true); mHandler = new Handler(Looper.getMainLooper()); mArgs = new Bundle(); } /** Arguments used to configure this preference controller. */ public void setArguments(Bundle args) { mArgs = args; } /** * Returns the position of the currently checked preference. Returns 0 if no such element * exists. */ public int getCurrentlySelectedPreferencePos() { int count = getPreference().getPreferenceCount(); for (int i = 0; i < count; i++) { TwoStatePreference pref = (TwoStatePreference) getPreference().getPreference(i); if (pref.isChecked()) { return i; } } return 0; } /** Saves the currently selected ringtone. */ public void saveRingtone() { RingtoneManager.setActualDefaultRingtoneUri(mUserContext, mRingtoneType, getCurrentlySelectedRingtoneUri()); } @Override protected Class getPreferenceType() { return PreferenceGroup.class; } @Override protected void onCreateInternal() { mRingtoneType = mArgs.getInt(RingtoneManager.EXTRA_RINGTONE_TYPE, /* defaultValue= */ -1); mHasSilentItem = mArgs.getBoolean(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, /* defaultValue= */ true); mAttributesFlags |= mArgs.getInt(RingtoneManager.EXTRA_RINGTONE_AUDIO_ATTRIBUTES_FLAGS, /* defaultValue= */ 0); mRingtoneManager.setType(mRingtoneType); mCursor = new LocalizedCursor(mRingtoneManager.getCursor(), getContext().getResources(), COLUMN_LABEL); } @Override protected void onStartInternal() { populateRingtones(getPreference()); clearSelection(); Uri currentRingtoneUri = RingtoneManager.getActualDefaultRingtoneUri(mUserContext, mRingtoneType); initSelection(currentRingtoneUri); } @Override protected void onStopInternal() { stopAnyPlayingRingtone(); clearSelection(); } private void populateRingtones(PreferenceGroup preference) { preference.removeAll(); // Keep at the front of the list, if requested. if (mHasSilentItem) { String label = getContext().getResources().getString( com.android.internal.R.string.ringtone_silent); int pos = SILENT_ITEM_POS; preference.addPreference(createRingtonePreference(label, pos)); } int pos = 0; mCursor.moveToFirst(); while (!mCursor.isAfterLast()) { String label = mCursor.getString(mCursor.getColumnIndex(COLUMN_LABEL)); preference.addPreference(createRingtonePreference(label, pos)); mCursor.moveToNext(); pos++; } } private TwoStatePreference createRingtonePreference(String title, int key) { CarUiRadioButtonPreference preference = new CarUiRadioButtonPreference(getContext()); preference.setTitle(title); preference.setKey(Integer.toString(key)); preference.setChecked(false); preference.setViewId(View.NO_ID); preference.setOnPreferenceClickListener(pref -> { updateCurrentSelection((TwoStatePreference) pref); playCurrentlySelectedRingtone(); return true; }); return preference; } private void updateCurrentSelection(TwoStatePreference preference) { int selectedPos = Integer.parseInt(preference.getKey()); if (mCurrentlySelectedPos != selectedPos) { if (mCurrentlySelectedPreference != null) { mCurrentlySelectedPreference.setChecked(false); mCurrentlySelectedPreference.setViewId(View.NO_ID); } } mCurrentlySelectedPreference = preference; mCurrentlySelectedPos = selectedPos; mCurrentlySelectedPreference.setChecked(true); mCurrentlySelectedPreference.setViewId(R.id.ringtone_picker_selected_id); } private void initSelection(Uri uri) { if (uri == null) { mCurrentlySelectedPos = SILENT_ITEM_POS; } else { mCurrentlySelectedPos = mRingtoneManager.getRingtonePosition(uri); } int count = getPreference().getPreferenceCount(); for (int i = 0; i < count; i++) { TwoStatePreference pref = (TwoStatePreference) getPreference().getPreference(i); int pos = Integer.parseInt(pref.getKey()); if (mCurrentlySelectedPos == pos) { mCurrentlySelectedPreference = pref; pref.setChecked(true); pref.setViewId(R.id.ringtone_picker_selected_id); } } } private void clearSelection() { int count = getPreference().getPreferenceCount(); for (int i = 0; i < count; i++) { TwoStatePreference pref = (TwoStatePreference) getPreference().getPreference(i); pref.setChecked(false); pref.setViewId(View.NO_ID); } mCurrentlySelectedPreference = null; mCurrentlySelectedPos = UNKNOWN_POS; } private void playCurrentlySelectedRingtone() { mHandler.removeCallbacks(this::run); mHandler.post(this::run); } private void run() { stopAnyPlayingRingtone(); if (mCurrentlySelectedPos == SILENT_ITEM_POS) { return; } if (mCurrentlySelectedPos >= 0) { mCurrentRingtone = mRingtoneManager.getRingtone(mCurrentlySelectedPos); } if (mCurrentRingtone != null) { if (mAttributesFlags != 0) { mCurrentRingtone.setAudioAttributes( new AudioAttributes.Builder(mCurrentRingtone.getAudioAttributes()) .setFlags(mAttributesFlags) .build()); } mCurrentRingtone.play(); } } private void stopAnyPlayingRingtone() { mHandler.removeCallbacks(this::run); if (mCurrentRingtone != null && mCurrentRingtone.isPlaying()) { mCurrentRingtone.stop(); } if (mRingtoneManager != null) { mRingtoneManager.stopPreviousRingtone(); } } private Uri getCurrentlySelectedRingtoneUri() { if (mCurrentlySelectedPos >= 0) { return mRingtoneManager.getRingtoneUri(mCurrentlySelectedPos); } else if (mCurrentlySelectedPos == SILENT_ITEM_POS) { // Use a null Uri for the 'Silent' item. return null; } else { LOG.e("Requesting ringtone URI for unknown position: " + mCurrentlySelectedPos); return null; } } /** * Returns a context created from the given context for the given user, or null if it fails. */ private Context createPackageContextAsUser(Context context, int userId) { try { return context.createPackageContextAsUser( context.getPackageName(), /* flags= */ 0, UserHandle.of(userId)); } catch (PackageManager.NameNotFoundException e) { LOG.e("Failed to create user context", e); } return null; } /** * A copy of the localized cursor provided in * {@link com.android.soundpicker.RingtonePickerActivity}. */ private static class LocalizedCursor extends CursorWrapper { final int mTitleIndex; final Resources mResources; final Pattern mSanitizePattern; String mNamePrefix; LocalizedCursor(Cursor cursor, Resources resources, String columnLabel) { super(cursor); mTitleIndex = mCursor.getColumnIndex(columnLabel); mResources = resources; mSanitizePattern = Pattern.compile("[^a-zA-Z0-9]"); if (mTitleIndex == -1) { LOG.e("No index for column " + columnLabel); mNamePrefix = null; } else { try { // Build the prefix for the name of the resource to look up. // Format is: "ResourcePackageName::ResourceTypeName/" // (The type name is expected to be "string" but let's not hardcode it). // Here we use an existing resource "ringtone_title" which is // always expected to be found. mNamePrefix = String.format("%s:%s/%s", mResources.getResourcePackageName(R.string.ringtone_title), mResources.getResourceTypeName(R.string.ringtone_title), SOUND_NAME_RES_PREFIX); } catch (Resources.NotFoundException e) { mNamePrefix = null; } } } /** * Process resource name to generate a valid resource name. * * @return a non-null String */ private String sanitize(String input) { if (input == null) { return ""; } return mSanitizePattern.matcher(input).replaceAll("_").toLowerCase(); } @Override public String getString(int columnIndex) { final String defaultName = mCursor.getString(columnIndex); if ((columnIndex != mTitleIndex) || (mNamePrefix == null)) { return defaultName; } TypedValue value = new TypedValue(); try { // The name currently in the database is used to derive a name to match // against resource names in this package. mResources.getValue(mNamePrefix + sanitize(defaultName), value, false); } catch (Resources.NotFoundException e) { // No localized string, use the default string. return defaultName; } if ((value != null) && (value.type == TypedValue.TYPE_STRING)) { LOG.d(String.format("Replacing name %s with %s", defaultName, value.string.toString())); return value.string.toString(); } else { LOG.e("Invalid value when looking up localized name, using " + defaultName); return defaultName; } } } @VisibleForTesting void setRingtoneManager(RingtoneManager ringtoneManager) { mRingtoneManager = ringtoneManager; } }