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