diff options
author | 2023-05-26 13:57:08 +0000 | |
---|---|---|
committer | 2023-06-27 14:09:42 +0000 | |
commit | 6211a67919217390bb0af05003d551cfad733863 (patch) | |
tree | b086ec2a28baf3fbfc4b06f3ada0cd34190128e3 | |
parent | 855cd90c7825e0f6f167d9c060ae8f05363fefe8 (diff) |
Add two tabs to the ringtone picker pop-up view.
We're converting the AlertDialog to a DialogFragment with a ViewPage and two tabs (Sound and Vibration). The Vibration tab will be empty for now, while the Sound tab will include a recyclerview with all available sounds.
Fix: 275541592
Test: com.android.soundpicker.RingtonePickerViewModelTest
Change-Id: Idda019d2ef460785d2b2fd9df35858d4650829fc
18 files changed, 1348 insertions, 600 deletions
diff --git a/packages/SoundPicker/Android.bp b/packages/SoundPicker/Android.bp index c8999fbcd271..1ac9bbbd9f18 100644 --- a/packages/SoundPicker/Android.bp +++ b/packages/SoundPicker/Android.bp @@ -19,6 +19,10 @@ android_library { "androidx.appcompat_appcompat", "hilt_android", "guava", + "androidx.recyclerview_recyclerview", + "androidx-constraintlayout_constraintlayout", + "androidx.viewpager2_viewpager2", + "com.google.android.material_material", ], } diff --git a/packages/SoundPicker/AndroidManifest.xml b/packages/SoundPicker/AndroidManifest.xml index 1f99e75ebc88..934b003c605c 100644 --- a/packages/SoundPicker/AndroidManifest.xml +++ b/packages/SoundPicker/AndroidManifest.xml @@ -34,6 +34,9 @@ <intent-filter> <action android:name="android.intent.action.RINGTONE_PICKER" /> <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.RINGTONE_PICKER_SOUND" /> + <category android:name="android.intent.category.RINGTONE_PICKER_VIBRATION" /> + <category android:name="android.intent.category.RINGTONE_PICKER_RINGTONE" /> </intent-filter> </activity> </application> diff --git a/packages/SoundPicker/res/layout/activity_ringtone_picker.xml b/packages/SoundPicker/res/layout/activity_ringtone_picker.xml index 4eecf89bb481..6fc60801ad3a 100644 --- a/packages/SoundPicker/res/layout/activity_ringtone_picker.xml +++ b/packages/SoundPicker/res/layout/activity_ringtone_picker.xml @@ -1,4 +1,5 @@ -<?xml version="1.0" encoding="utf-8"?><!-- +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2023 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); @@ -15,6 +16,6 @@ --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" />
\ No newline at end of file + android:layout_height="match_parent"/>
\ No newline at end of file diff --git a/packages/SoundPicker/res/layout/add_new_sound_item.xml b/packages/SoundPicker/res/layout/add_new_sound_item.xml index 14421c9a50dc..024b97ef23be 100644 --- a/packages/SoundPicker/res/layout/add_new_sound_item.xml +++ b/packages/SoundPicker/res/layout/add_new_sound_item.xml @@ -16,12 +16,14 @@ --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="fill_parent" - android:layout_height="wrap_content" - android:gravity="center_vertical" - android:background="?android:attr/selectableItemBackground"> + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:background="?android:attr/selectableItemBackground" + android:focusable="true" + android:clickable="true"> -<ImageView + <ImageView android:layout_width="24dp" android:layout_height="24dp" android:layout_alignParentRight="true" @@ -29,9 +31,9 @@ android:scaleType="centerCrop" android:layout_marginRight="24dp" android:layout_marginLeft="24dp" - android:src="@drawable/ic_add" /> + android:src="@drawable/ic_add"/> -<TextView xmlns:android="http://schemas.android.com/apk/res/android" + <TextView android:id="@+id/add_new_sound_text" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -43,5 +45,5 @@ android:gravity="center_vertical" android:paddingEnd="?android:attr/dialogPreferredPadding" android:drawablePadding="20dp" - android:ellipsize="marquee" /> + android:ellipsize="marquee"/> </LinearLayout>
\ No newline at end of file diff --git a/packages/SoundPicker/res/layout/fragment_sound_picker.xml b/packages/SoundPicker/res/layout/fragment_sound_picker.xml new file mode 100644 index 000000000000..787f92ec06d6 --- /dev/null +++ b/packages/SoundPicker/res/layout/fragment_sound_picker.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2023 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. +--> +<androidx.recyclerview.widget.RecyclerView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/recycler_view" + android:layout_width="match_parent" + android:layout_height="match_parent" +/>
\ No newline at end of file diff --git a/packages/SoundPicker/res/layout/fragment_tabbed_dialog.xml b/packages/SoundPicker/res/layout/fragment_tabbed_dialog.xml new file mode 100644 index 000000000000..7efd91191b79 --- /dev/null +++ b/packages/SoundPicker/res/layout/fragment_tabbed_dialog.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2023 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. +--> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <com.google.android.material.tabs.TabLayout + android:id="@+id/tabLayout" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + <androidx.viewpager2.widget.ViewPager2 + android:id="@+id/masterViewPager" + android:paddingTop="12dp" + android:paddingBottom="12dp" + android:layout_width="match_parent" + android:layout_height="match_parent"/> +</LinearLayout>
\ No newline at end of file diff --git a/packages/SoundPicker/res/layout/fragment_vibration_picker.xml b/packages/SoundPicker/res/layout/fragment_vibration_picker.xml new file mode 100644 index 000000000000..34d95aa2e81b --- /dev/null +++ b/packages/SoundPicker/res/layout/fragment_vibration_picker.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2023 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. +--> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <TextView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:text="@string/empty_list" + android:textColor="?android:attr/colorAccent" + android:textAppearance="?android:attr/textAppearanceMedium" + android:gravity="center" + /> + +</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file diff --git a/packages/SoundPicker/res/layout/radio_with_work_badge.xml b/packages/SoundPicker/res/layout/radio_with_work_badge.xml index c8ca231f27a4..36ac93ed630b 100644 --- a/packages/SoundPicker/res/layout/radio_with_work_badge.xml +++ b/packages/SoundPicker/res/layout/radio_with_work_badge.xml @@ -14,12 +14,14 @@ limitations under the License. --> -<com.android.soundpicker.CheckedListItem xmlns:android="http://schemas.android.com/apk/res/android" +<com.android.soundpicker.CheckedListItem + xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="wrap_content" android:gravity="center_vertical" android:background="?android:attr/selectableItemBackground" - > + android:focusable="true" + android:clickable="true"> <CheckedTextView android:id="@+id/checked_text_view" @@ -35,7 +37,7 @@ android:drawablePadding="20dp" android:ellipsize="marquee" android:layout_toLeftOf="@+id/work_icon" - android:maxLines="3" /> + android:maxLines="3"/> <ImageView android:id="@id/work_icon" @@ -44,5 +46,5 @@ android:layout_alignParentRight="true" android:layout_centerVertical="true" android:scaleType="centerCrop" - android:layout_marginRight="20dp" /> + android:layout_marginRight="20dp"/> </com.android.soundpicker.CheckedListItem> diff --git a/packages/SoundPicker/res/values/strings.xml b/packages/SoundPicker/res/values/strings.xml index 04a2c2bb83c3..ab7b95a09028 100644 --- a/packages/SoundPicker/res/values/strings.xml +++ b/packages/SoundPicker/res/values/strings.xml @@ -40,4 +40,8 @@ <!-- Text for the name of the app. [CHAR LIMIT=12] --> <string name="app_label">Sounds</string> + + <string name="empty_list">The list is empty</string> + <string name="sound_page_title">Sound</string> + <string name="vibration_page_title">Vibration</string> </resources> diff --git a/packages/SoundPicker/src/com/android/soundpicker/LocalizedCursor.java b/packages/SoundPicker/src/com/android/soundpicker/LocalizedCursor.java new file mode 100644 index 000000000000..83d04a345f8b --- /dev/null +++ b/packages/SoundPicker/src/com/android/soundpicker/LocalizedCursor.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2023 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.soundpicker; + +import android.content.res.Resources; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.util.Log; +import android.util.TypedValue; + +import androidx.annotation.Nullable; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * A cursor wrapper class mainly used to guarantee getting a ringtone title + */ +final class LocalizedCursor extends CursorWrapper { + + private static final String TAG = "LocalizedCursor"; + private static final String SOUND_NAME_RES_PREFIX = "sound_name_"; + + private final int mTitleIndex; + private final Resources mResources; + private final Pattern mSanitizePattern; + private final 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(TAG, "No index for column " + columnLabel); + mNamePrefix = null; + } else { + mNamePrefix = buildNamePrefix(mResources); + } + } + + /** + * Builds the prefix for the name of the resource to look up. + * The format is: "ResourcePackageName::ResourceTypeName/" (the type name is expected to be + * "string" but let's not hardcode it). + * Here we use an existing resource "notification_sound_default" which is always expected to be + * found. + * + * @param resources Application's resources + * @return the built name prefix, or null if failed to build. + */ + @Nullable + private static String buildNamePrefix(Resources resources) { + try { + return String.format("%s:%s/%s", + resources.getResourcePackageName(R.string.notification_sound_default), + resources.getResourceTypeName(R.string.notification_sound_default), + SOUND_NAME_RES_PREFIX); + } catch (Resources.NotFoundException e) { + Log.e(TAG, "Failed to build the prefix for the name of the resource.", e); + } + + return 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(Locale.ROOT); + } + + @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, + /* resolveRefs= */ false); + } catch (Resources.NotFoundException e) { + Log.d(TAG, "Failed to get localized string. Using default string instead.", e); + return defaultName; + } + if ((value != null) && (value.type == TypedValue.TYPE_STRING)) { + Log.d(TAG, String.format("Replacing name %s with %s", + defaultName, value.string.toString())); + return value.string.toString(); + } else { + Log.e(TAG, "Invalid value when looking up localized name, using " + defaultName); + return defaultName; + } + } +} diff --git a/packages/SoundPicker/src/com/android/soundpicker/RingtoneAdapter.java b/packages/SoundPicker/src/com/android/soundpicker/RingtoneAdapter.java new file mode 100644 index 000000000000..1f33aa2ce4d9 --- /dev/null +++ b/packages/SoundPicker/src/com/android/soundpicker/RingtoneAdapter.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2023 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.soundpicker; + +import static com.android.internal.widget.RecyclerView.NO_ID; + +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.media.RingtoneManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckedTextView; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.RecyclerView; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * The adapter presents a list of ringtones which may include fixed item in the list and an action + * button at the end. + * + * The adapter handles three different types of items: + * <ul> + * <li>FIXED: Fixed items are items added to the top of the list. These items can not be modified + * and their position will never change. + * <li>DYNAMIC: Dynamic items are items from the ringtone manager. These items can be modified + * and their position can change. + * <li>FOOTER: A footer item is an added button to the end of the list. This item can be clicked + * but not selected and its position will never change. + * </ul> + */ +final class RingtoneAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { + + private static final int VIEW_TYPE_FIXED_ITEM = 0; + private static final int VIEW_TYPE_DYNAMIC_ITEM = 1; + private static final int VIEW_TYPE_ADD_RINGTONE_ITEM = 2; + private final Cursor mCursor; + private final List<Integer> mFixedItemTitles; + private final WorkRingtoneProvider mWorkRingtoneProvider; + private final RingtoneSelectionListener mRingtoneSelectionListener; + private final int mRowIDColumn; + private int mSelectedItem = -1; + @StringRes private Integer mAddRingtoneItemTitle; + + /** Listener for ringtone selections. */ + interface RingtoneSelectionListener { + void onRingtoneSelected(int position); + void onAddRingtoneSelected(); + } + /** Provides a mean to detect work ringtones. */ + interface WorkRingtoneProvider { + boolean isWorkRingtone(int position); + + Drawable getWorkIconDrawable(); + } + + RingtoneAdapter(Cursor cursor, RingtoneSelectionListener ringtoneSelectionListener, + WorkRingtoneProvider workRingtoneProvider) { + mCursor = cursor; + mRingtoneSelectionListener = ringtoneSelectionListener; + mWorkRingtoneProvider = workRingtoneProvider; + mFixedItemTitles = new ArrayList<>(); + mRowIDColumn = mCursor != null ? mCursor.getColumnIndex("_id") : -1; + setHasStableIds(true); + } + + void setSelectedItem(int position) { + notifyItemChanged(mSelectedItem); + mSelectedItem = position; + notifyItemChanged(mSelectedItem); + } + + void addTitleForFixedItem(@StringRes int textResId) { + mFixedItemTitles.add(textResId); + notifyItemInserted(mFixedItemTitles.size() - 1); + } + + void addTitleForAddRingtoneItem(@StringRes int textResId) { + mAddRingtoneItemTitle = textResId; + notifyItemInserted(getItemCount() - 1); + } + + @NotNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + if (viewType == VIEW_TYPE_FIXED_ITEM) { + View fixedItemView = inflater.inflate( + com.android.internal.R.layout.select_dialog_singlechoice_material, parent, + false); + + return new FixedItemViewHolder(fixedItemView, mRingtoneSelectionListener); + } + + if (viewType == VIEW_TYPE_ADD_RINGTONE_ITEM) { + View addRingtoneItemView = inflater.inflate(R.layout.add_new_sound_item, parent, false); + + return new AddRingtoneItemViewHolder(addRingtoneItemView, mRingtoneSelectionListener); + } + + View view = inflater.inflate(R.layout.radio_with_work_badge, parent, false); + + return new DynamicItemViewHolder(view, mRingtoneSelectionListener); + } + + @Override + public void onBindViewHolder(@NotNull RecyclerView.ViewHolder holder, int position) { + if (holder instanceof FixedItemViewHolder) { + FixedItemViewHolder viewHolder = (FixedItemViewHolder) holder; + + viewHolder.onBind(mFixedItemTitles.get(position), + /* isChecked= */ position == mSelectedItem); + return; + } + if (holder instanceof AddRingtoneItemViewHolder) { + AddRingtoneItemViewHolder viewHolder = (AddRingtoneItemViewHolder) holder; + + viewHolder.onBind(mAddRingtoneItemTitle); + return; + } + + if (!(holder instanceof DynamicItemViewHolder)) { + throw new IllegalArgumentException("holder type is not supported"); + } + + DynamicItemViewHolder viewHolder = (DynamicItemViewHolder) holder; + int pos = position - mFixedItemTitles.size(); + if (!mCursor.moveToPosition(pos)) { + throw new IllegalStateException("Could not move cursor to position: " + pos); + } + + Drawable workIcon = (mWorkRingtoneProvider != null) + && mWorkRingtoneProvider.isWorkRingtone(position) + ? mWorkRingtoneProvider.getWorkIconDrawable() : null; + + viewHolder.onBind(mCursor.getString(RingtoneManager.TITLE_COLUMN_INDEX), + /* isChecked= */ position == mSelectedItem, workIcon); + } + + @Override + public int getItemViewType(int position) { + if (!mFixedItemTitles.isEmpty() && position < mFixedItemTitles.size()) { + return VIEW_TYPE_FIXED_ITEM; + } + if (mAddRingtoneItemTitle != null && position == getItemCount() - 1) { + return VIEW_TYPE_ADD_RINGTONE_ITEM; + } + + return VIEW_TYPE_DYNAMIC_ITEM; + } + + @Override + public int getItemCount() { + int itemCount = mFixedItemTitles.size() + mCursor.getCount(); + + if (mAddRingtoneItemTitle != null) { + itemCount++; + } + + return itemCount; + } + + @Override + public long getItemId(int position) { + int itemViewType = getItemViewType(position); + if (itemViewType == VIEW_TYPE_FIXED_ITEM) { + // Since the item is a fixed item, then we can use the position as a stable ID + // since the order of the fixed items should never change. + return position; + } + if (itemViewType == VIEW_TYPE_DYNAMIC_ITEM && mCursor != null + && mCursor.moveToPosition(position - mFixedItemTitles.size()) + && mRowIDColumn != -1) { + return mCursor.getLong(mRowIDColumn) + mFixedItemTitles.size(); + } + + // The position is either invalid or the item is the add ringtone item view, so no stable + // ID is returned. Add ringtone item view cannot be selected and only include an action + // buttons. + return NO_ID; + } + + private static class DynamicItemViewHolder extends RecyclerView.ViewHolder { + private final CheckedTextView mTitleTextView; + private final ImageView mWorkIcon; + + DynamicItemViewHolder(View itemView, RingtoneSelectionListener listener) { + super(itemView); + + mTitleTextView = itemView.findViewById(R.id.checked_text_view); + mWorkIcon = itemView.findViewById(R.id.work_icon); + itemView.setOnClickListener(v -> listener.onRingtoneSelected(this.getLayoutPosition())); + } + + void onBind(String title, boolean isChecked, Drawable workIcon) { + Objects.requireNonNull(mTitleTextView); + Objects.requireNonNull(mWorkIcon); + + mTitleTextView.setText(title); + mTitleTextView.setChecked(isChecked); + + if (workIcon == null) { + mWorkIcon.setVisibility(View.GONE); + } else { + mWorkIcon.setImageDrawable(workIcon); + mWorkIcon.setVisibility(View.VISIBLE); + } + } + } + + private static class FixedItemViewHolder extends RecyclerView.ViewHolder { + private final CheckedTextView mTitleTextView; + + FixedItemViewHolder(View itemView, RingtoneSelectionListener listener) { + super(itemView); + + mTitleTextView = (CheckedTextView) itemView; + itemView.setOnClickListener(v -> listener.onRingtoneSelected(this.getLayoutPosition())); + } + + void onBind(@StringRes int title, boolean isChecked) { + Objects.requireNonNull(mTitleTextView); + + mTitleTextView.setText(title); + mTitleTextView.setChecked(isChecked); + } + } + + private static class AddRingtoneItemViewHolder extends RecyclerView.ViewHolder { + private final TextView mTitleTextView; + + AddRingtoneItemViewHolder(View itemView, RingtoneSelectionListener listener) { + super(itemView); + + mTitleTextView = itemView.findViewById(R.id.add_new_sound_text); + itemView.setOnClickListener(v -> listener.onAddRingtoneSelected()); + } + + void onBind(@StringRes int title) { + Objects.requireNonNull(mTitleTextView); + + mTitleTextView.setText(title); + } + } +} diff --git a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java index f591aa54a50e..4d7cf1cba161 100644 --- a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java +++ b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java @@ -16,46 +16,20 @@ package com.android.soundpicker; -import android.content.ContentProvider; -import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; -import android.content.res.Resources; -import android.content.res.Resources.NotFoundException; -import android.database.Cursor; -import android.database.CursorWrapper; import android.media.RingtoneManager; import android.net.Uri; import android.os.Bundle; -import android.os.Environment; -import android.os.Handler; import android.os.UserHandle; -import android.os.UserManager; -import android.provider.MediaStore; import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.CursorAdapter; -import android.widget.ImageView; -import android.widget.ListView; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.ViewModelProvider; -import com.google.common.util.concurrent.FutureCallback; - import dagger.hilt.android.AndroidEntryPoint; -import java.util.regex.Pattern; - /** * The {@link RingtonePickerActivity} allows the user to choose one from all of the * available ringtones. The chosen ringtone's URI will be persisted as a string. @@ -63,106 +37,17 @@ import java.util.regex.Pattern; * @see RingtoneManager#ACTION_RINGTONE_PICKER */ @AndroidEntryPoint(AppCompatActivity.class) -public final class RingtonePickerActivity extends Hilt_RingtonePickerActivity implements - AdapterView.OnItemSelectedListener, Runnable, DialogInterface.OnClickListener, - DialogInterface.OnDismissListener { - - private static final int POS_UNKNOWN = -1; +public final class RingtonePickerActivity extends Hilt_RingtonePickerActivity { private static final String TAG = "RingtonePickerActivity"; - - private static final int DELAY_MS_SELECTION_PLAYED = 300; - - private static final String COLUMN_LABEL = MediaStore.Audio.Media.TITLE; - - private static final String SAVE_CLICKED_POS = "clicked_pos"; - - private static final String SOUND_NAME_RES_PREFIX = "sound_name_"; - - private static final int ADD_FILE_REQUEST_CODE = 300; + // TODO: Use the extra key from RingtoneManager once it's added. + private static final String EXTRA_RINGTONE_PICKER_CATEGORY = "EXTRA_RINGTONE_PICKER_CATEGORY"; + private static final boolean RINGTONE_PICKER_CATEGORY_FEATURE_ENABLED = false; private RingtonePickerViewModel mRingtonePickerViewModel; - private int mType; - - private Cursor mCursor; - private Handler mHandler; - private BadgedRingtoneAdapter mAdapter; - - /** Whether this list has the 'Silent' item. */ - private boolean mHasSilentItem; - - /** The Uri to place a checkmark next to. */ - private Uri mExistingUri; - - /** Whether this list has the 'Default' item. */ - private boolean mHasDefaultItem; - - /** The Uri to play when the 'Default' item is clicked. */ - private Uri mUriForDefaultItem; - - /** Id of the user to which the ringtone picker should list the ringtones */ - private int mPickerUserId; - - /** - * Stable ID for the ringtone that is currently checked (may be -1 if no ringtone is checked). - */ - private long mCheckedItemId = -1; - private int mAttributesFlags; - private boolean mShowOkCancelButtons; - - private AlertDialog mAlertDialog; - - private int mCheckedItem = POS_UNKNOWN; - - private final DialogInterface.OnClickListener mRingtoneClickListener = - new DialogInterface.OnClickListener() { - - /* - * On item clicked - */ - public void onClick(DialogInterface dialog, int which) { - if (which == mCursor.getCount() + mRingtonePickerViewModel.getFixedItemCount()) { - // The "Add new ringtone" item was clicked. Start a file picker intent to select - // only audio files (MIME type "audio/*") - final Intent chooseFile = getMediaFilePickerIntent(); - startActivityForResult(chooseFile, ADD_FILE_REQUEST_CODE); - return; - } - - // Save the position of most recently clicked item - setCheckedItem(which); - - // In the buttonless (watch-only) version, preemptively set our result since we won't - // have another chance to do so before the activity closes. - if (!mShowOkCancelButtons) { - setSuccessResultWithRingtone( - mRingtonePickerViewModel.getCurrentlySelectedRingtoneUri(getCheckedItem(), - mUriForDefaultItem)); - } - - // Play clip - playRingtone(which, 0); - } - - }; - private final FutureCallback<Uri> mAddCustomRingtoneCallback = new FutureCallback<>() { - @Override - public void onSuccess(Uri ringtoneUri) { - requeryForAdapter(); - } - - @Override - public void onFailure(Throwable throwable) { - Log.e(TAG, "Failed to add custom ringtone.", throwable); - // Ringtone was not added, display error Toast - Toast.makeText(RingtonePickerActivity.this.getApplicationContext(), - R.string.unable_to_add_ringtone, Toast.LENGTH_SHORT).show(); - } - }; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -170,300 +55,75 @@ public final class RingtonePickerActivity extends Hilt_RingtonePickerActivity im mRingtonePickerViewModel = new ViewModelProvider(this).get(RingtonePickerViewModel.class); - mHandler = new Handler(); - Intent intent = getIntent(); - mPickerUserId = UserHandle.myUserId(); + /** + * Id of the user to which the ringtone picker should list the ringtones + */ + int pickerUserId = UserHandle.myUserId(); // Get the types of ringtones to show - mType = intent.getIntExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, + int ringtoneType = intent.getIntExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtonePickerViewModel.RINGTONE_TYPE_UNKNOWN); - mRingtonePickerViewModel.initRingtoneManager(mType); - setupCursor(); - /* - * Get whether to show the 'Default' item, and the URI to play when the - * default is clicked - */ - mHasDefaultItem = intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); - mUriForDefaultItem = intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI); - if (mUriForDefaultItem == null) { - mUriForDefaultItem = RingtonePickerViewModel.getDefaultItemUriByType(mType); + // Get whether to show the 'Default' item, and the URI to play when the default is clicked + boolean hasDefaultItem = + intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + // The Uri to play when the 'Default' item is clicked. + Uri uriForDefaultItem = + intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI); + if (uriForDefaultItem == null) { + uriForDefaultItem = RingtonePickerViewModel.getDefaultItemUriByType(ringtoneType); } - // Get whether to show the 'Silent' item - mHasSilentItem = intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + // Get whether this list has the 'Silent' item. + boolean hasSilentItem = + intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); // AudioAttributes flags mAttributesFlags |= intent.getIntExtra( RingtoneManager.EXTRA_RINGTONE_AUDIO_ATTRIBUTES_FLAGS, 0 /*defaultValue == no flags*/); - mShowOkCancelButtons = getResources().getBoolean(R.bool.config_showOkCancelButtons); - - // The volume keys will control the stream that we are choosing a ringtone for - setVolumeControlStream(mRingtonePickerViewModel.getRingtoneStreamType()); + boolean showOkCancelButtons = getResources().getBoolean(R.bool.config_showOkCancelButtons); // Get the URI whose list item should have a checkmark - mExistingUri = intent + Uri existingUri = intent .getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI); - // Create the list of ringtones and hold on to it so we can update later. - mAdapter = new BadgedRingtoneAdapter(this, mCursor, - /* isManagedProfile = */ UserManager.get(this).isManagedProfile(mPickerUserId)); - - AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(this, - android.R.style.ThemeOverlay_Material_Dialog); - alertDialogBuilder - .setSingleChoiceItems(mAdapter, getCheckedItem(), mRingtoneClickListener) - .setOnItemSelectedListener(this) - .setOnDismissListener(this); - if (mShowOkCancelButtons) { - alertDialogBuilder - .setPositiveButton(getString(com.android.internal.R.string.ok), this) - .setNegativeButton(getString(com.android.internal.R.string.cancel), this); + String title = intent.getStringExtra(RingtoneManager.EXTRA_RINGTONE_TITLE); + if (title == null) { + title = getString(RingtonePickerViewModel.getTitleByType(ringtoneType)); } + String ringtonePickerCategory = intent.getStringExtra(EXTRA_RINGTONE_PICKER_CATEGORY); + RingtonePickerViewModel.PickerType pickerType = mapCategoryToPickerType( + ringtonePickerCategory); - String title = intent.getStringExtra(RingtoneManager.EXTRA_RINGTONE_TITLE); - alertDialogBuilder.setTitle( - title != null ? title : getString(RingtonePickerViewModel.getTitleByType(mType))); + mRingtonePickerViewModel.init(new RingtonePickerViewModel.PickerConfig(title, pickerUserId, + ringtoneType, hasDefaultItem, uriForDefaultItem, hasSilentItem, + mAttributesFlags, existingUri, showOkCancelButtons, pickerType)); - mAlertDialog = alertDialogBuilder.show(); - ListView listView = mAlertDialog.getListView(); - if (listView != null) { - // List view needs to gain focus in order for RSB to work. - if (!listView.requestFocus()) { - Log.e(TAG, "Unable to gain focus! RSB may not work properly."); - } - prepareListView(listView); - } - if (savedInstanceState != null) { - setCheckedItem(savedInstanceState.getInt(SAVE_CLICKED_POS, POS_UNKNOWN)); - } - } - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt(SAVE_CLICKED_POS, getCheckedItem()); - } + // The volume keys will control the stream that we are choosing a ringtone for + setVolumeControlStream(mRingtonePickerViewModel.getRingtoneStreamType()); - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); + if (savedInstanceState == null) { + TabbedDialogFragment dialogFragment = new TabbedDialogFragment(); - if (requestCode == ADD_FILE_REQUEST_CODE && resultCode == RESULT_OK) { - mRingtonePickerViewModel.addRingtoneAsync(data.getData(), - mType, - mAddCustomRingtoneCallback, - // Causes the callback to be executed on the main thread. - ContextCompat.getMainExecutor(this.getApplicationContext())); + FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); + Fragment prev = getSupportFragmentManager().findFragmentByTag(TabbedDialogFragment.TAG); + if (prev != null) { + ft.remove(prev); + } + ft.addToBackStack(null); + dialogFragment.show(ft, TabbedDialogFragment.TAG); } } - @Override - public void onDismiss(DialogInterface dialog) { - if (!isChangingConfigurations()) { - finish(); - } - } @Override public void onDestroy() { mRingtonePickerViewModel.cancelPendingAsyncTasks(); - if (mAlertDialog != null && mAlertDialog.isShowing()) { - mAlertDialog.dismiss(); - } - if (mHandler != null) { - mHandler.removeCallbacksAndMessages(null); - } - if (mCursor != null) { - mCursor.close(); - mCursor = null; - } super.onDestroy(); } - private void prepareListView(@NonNull ListView listView) { - // Reset the static item count, as this method can be called multiple times - mRingtonePickerViewModel.resetFixedItemCount(); - - if (mHasDefaultItem) { - int defaultItemPos = addDefaultRingtoneItem(listView); - - if (getCheckedItem() == POS_UNKNOWN && RingtoneManager.isDefault(mExistingUri)) { - setCheckedItem(defaultItemPos); - } - } - - if (mHasSilentItem) { - int silentItemPos = addSilentItem(listView); - - // The 'Silent' item should use a null Uri - if (getCheckedItem() == POS_UNKNOWN && mExistingUri == null) { - setCheckedItem(silentItemPos); - } - } - - if (getCheckedItem() == POS_UNKNOWN) { - setCheckedItem( - getListPosition(mRingtonePickerViewModel.getRingtonePosition(mExistingUri))); - } - - // In the buttonless (watch-only) version, preemptively set our result since we won't - // have another chance to do so before the activity closes. - if (!mShowOkCancelButtons) { - setSuccessResultWithRingtone( - mRingtonePickerViewModel.getCurrentlySelectedRingtoneUri(getCheckedItem(), - mUriForDefaultItem)); - } - // If external storage is available, add a button to install sounds from storage. - if (resolvesMediaFilePicker() - && Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { - addNewSoundItem(listView); - } - - // Enable context menu in ringtone items - registerForContextMenu(listView); - } - - /** - * Re-query RingtoneManager for the most recent set of installed ringtones. May move the - * selected item position to match the new position of the chosen sound. - * - * This should only need to happen after adding or removing a ringtone. - */ - private void requeryForAdapter() { - // Refresh and set a new cursor, closing the old one. - mRingtonePickerViewModel.initRingtoneManager(mType); - setupCursor(); - mAdapter.changeCursor(mCursor); - - // Update checked item location. - int checkedPosition = POS_UNKNOWN; - for (int i = 0; i < mAdapter.getCount(); i++) { - if (mAdapter.getItemId(i) == mCheckedItemId) { - checkedPosition = getListPosition(i); - break; - } - } - if (mHasSilentItem && checkedPosition == POS_UNKNOWN) { - checkedPosition = mRingtonePickerViewModel.getSilentItemPosition(); - } - setCheckedItem(checkedPosition); - } - - /** - * Adds a static item to the top of the list. A static item is one that is not from the - * RingtoneManager. - * - * @param listView The ListView to add to. - * @param textResId The resource ID of the text for the item. - * @return The position of the inserted item. - */ - private int addStaticItem(@NonNull ListView listView, int textResId) { - TextView textView = (TextView) getLayoutInflater().inflate( - com.android.internal.R.layout.select_dialog_singlechoice_material, listView, false); - textView.setText(textResId); - listView.addHeaderView(textView); - mRingtonePickerViewModel.incrementFixedItemCount(); - return listView.getHeaderViewsCount() - 1; - } - - private int addDefaultRingtoneItem(@NonNull ListView listView) { - int defaultRingtoneItemPos = addStaticItem(listView, - RingtonePickerViewModel.getDefaultRingtoneItemTextByType(mType)); - mRingtonePickerViewModel.setDefaultItemPosition(defaultRingtoneItemPos); - return defaultRingtoneItemPos; - } - - private int addSilentItem(@NonNull ListView listView) { - int silentItemPos = addStaticItem(listView, com.android.internal.R.string.ringtone_silent); - mRingtonePickerViewModel.setSilentItemPosition(silentItemPos); - return silentItemPos; - } - - private void addNewSoundItem(@NonNull ListView listView) { - View view = getLayoutInflater().inflate(R.layout.add_new_sound_item, listView, - false /* attachToRoot */); - TextView text = (TextView)view.findViewById(R.id.add_new_sound_text); - - text.setText(RingtonePickerViewModel.getAddNewItemTextByType(mType)); - - listView.addFooterView(view); - } - - private void setupCursor() { - mCursor = new LocalizedCursor( - mRingtonePickerViewModel.getRingtoneCursor(), getResources(), COLUMN_LABEL); - } - - private int getCheckedItem() { - return mCheckedItem; - } - - private void setCheckedItem(int pos) { - mCheckedItem = pos; - ListView listView = mAlertDialog.getListView(); - if (listView != null) { - listView.setItemChecked(pos, true); - listView.smoothScrollToPosition(pos); - } - mCheckedItemId = mAdapter.getItemId( - mRingtonePickerViewModel.itemPositionToRingtonePosition(pos)); - } - - /* - * On click of Ok/Cancel buttons - */ - public void onClick(DialogInterface dialog, int which) { - boolean positiveResult = which == DialogInterface.BUTTON_POSITIVE; - - if (positiveResult) { - setSuccessResultWithRingtone( - mRingtonePickerViewModel.getCurrentlySelectedRingtoneUri(getCheckedItem(), - mUriForDefaultItem)); - } else { - setResult(RESULT_CANCELED); - } - - finish(); - } - - /* - * On item selected via keys - */ - public void onItemSelected(AdapterView parent, View view, int position, long id) { - // footer view - if (position >= mCursor.getCount() + mRingtonePickerViewModel.getFixedItemCount()) { - return; - } - - playRingtone(position, DELAY_MS_SELECTION_PLAYED); - - // In the buttonless (watch-only) version, preemptively set our result since we won't - // have another chance to do so before the activity closes. - if (!mShowOkCancelButtons) { - setSuccessResultWithRingtone( - mRingtonePickerViewModel.getCurrentlySelectedRingtoneUri(getCheckedItem(), - mUriForDefaultItem)); - } - } - - public void onNothingSelected(AdapterView parent) { - } - - private void playRingtone(int position, int delayMs) { - mHandler.removeCallbacks(this); - mRingtonePickerViewModel.setSampleItemPosition(position); - mHandler.postDelayed(this, delayMs); - } - - public void run() { - mRingtonePickerViewModel.playRingtone( - mRingtonePickerViewModel.itemPositionToRingtonePosition( - mRingtonePickerViewModel.getSampleItemPosition()), mUriForDefaultItem, - mAttributesFlags); - } - @Override protected void onStop() { super.onStop(); @@ -476,155 +136,29 @@ public final class RingtonePickerActivity extends Hilt_RingtonePickerActivity im mRingtonePickerViewModel.onPause(isChangingConfigurations()); } - private void setSuccessResultWithRingtone(Uri ringtoneUri) { - setResult(RESULT_OK, - new Intent().putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, ringtoneUri)); - } - - private int getListPosition(int ringtoneManagerPos) { - - // If the manager position is -1 (for not found), return that - if (ringtoneManagerPos < 0) return ringtoneManagerPos; - - return ringtoneManagerPos + mRingtonePickerViewModel.getFixedItemCount(); - } - - private Intent getMediaFilePickerIntent() { - final Intent chooseFile = new Intent(Intent.ACTION_GET_CONTENT); - chooseFile.setType("audio/*"); - chooseFile.putExtra(Intent.EXTRA_MIME_TYPES, - new String[] { "audio/*", "application/ogg" }); - return chooseFile; - } - - private boolean resolvesMediaFilePicker() { - return getMediaFilePickerIntent().resolveActivity(getPackageManager()) != null; - } - - private static class LocalizedCursor extends CursorWrapper { - - final int mTitleIndex; - final Resources mResources; - String mNamePrefix; - final Pattern mSanitizePattern; - - 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(TAG, "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 "notification_sound_default" which is - // always expected to be found. - mNamePrefix = String.format("%s:%s/%s", - mResources.getResourcePackageName(R.string.notification_sound_default), - mResources.getResourceTypeName(R.string.notification_sound_default), - SOUND_NAME_RES_PREFIX); - } catch (NotFoundException e) { - mNamePrefix = null; - } - } - } - - /** - * Process resource name to generate a valid resource name. - * @param input - * @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 (NotFoundException e) { - // no localized string, use the default string - return defaultName; - } - if ((value != null) && (value.type == TypedValue.TYPE_STRING)) { - Log.d(TAG, String.format("Replacing name %s with %s", - defaultName, value.string.toString())); - return value.string.toString(); - } else { - Log.e(TAG, "Invalid value when looking up localized name, using " + defaultName); - return defaultName; - } - } - } - - private class BadgedRingtoneAdapter extends CursorAdapter { - private final boolean mIsManagedProfile; - - public BadgedRingtoneAdapter(Context context, Cursor cursor, boolean isManagedProfile) { - super(context, cursor); - mIsManagedProfile = isManagedProfile; - } - - @Override - public long getItemId(int position) { - if (position < 0) { - return position; - } - return super.getItemId(position); - } - - @Override - public View newView(Context context, Cursor cursor, ViewGroup parent) { - LayoutInflater inflater = LayoutInflater.from(context); - return inflater.inflate(R.layout.radio_with_work_badge, parent, false); - } - - @Override - public void bindView(View view, Context context, Cursor cursor) { - // Set text as the title of the ringtone - ((TextView) view.findViewById(R.id.checked_text_view)) - .setText(cursor.getString(RingtoneManager.TITLE_COLUMN_INDEX)); - - boolean isWorkRingtone = false; - if (mIsManagedProfile) { - /* - * Display the work icon if the ringtone belongs to a work profile. We can tell that - * a ringtone belongs to a work profile if the picker user is a managed profile, the - * ringtone Uri is in external storage, and either the uri has no user id or has the - * id of the picker user - */ - Uri currentUri = mRingtonePickerViewModel.getRingtoneUri(cursor.getPosition()); - int uriUserId = ContentProvider.getUserIdFromUri(currentUri, mPickerUserId); - Uri uriWithoutUserId = ContentProvider.getUriWithoutUserId(currentUri); - - if (uriUserId == mPickerUserId && uriWithoutUserId.toString() - .startsWith(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.toString())) { - isWorkRingtone = true; - } - } - - ImageView workIcon = (ImageView) view.findViewById(R.id.work_icon); - if(isWorkRingtone) { - workIcon.setImageDrawable(getPackageManager().getUserBadgeForDensityNoBackground( - UserHandle.of(mPickerUserId), -1 /* density */)); - workIcon.setVisibility(View.VISIBLE); - } else { - workIcon.setVisibility(View.GONE); - } + /** + * Maps the ringtone picker category to the appropriate PickerType. + * If the category is null or the feature is still not released, then it defaults to sound + * picker. + * + * @param category the ringtone picker category. + * @return the corresponding picker type. + */ + private static RingtonePickerViewModel.PickerType mapCategoryToPickerType(String category) { + if (category == null || !RINGTONE_PICKER_CATEGORY_FEATURE_ENABLED) { + return RingtonePickerViewModel.PickerType.SOUND_PICKER; + } + + switch (category) { + case "android.intent.category.RINGTONE_PICKER_RINGTONE": + return RingtonePickerViewModel.PickerType.RINGTONE_PICKER; + case "android.intent.category.RINGTONE_PICKER_SOUND": + return RingtonePickerViewModel.PickerType.SOUND_PICKER; + case "android.intent.category.RINGTONE_PICKER_VIBRATION": + return RingtonePickerViewModel.PickerType.VIBRATION_PICKER; + default: + Log.w(TAG, "Unrecognized category: " + category + ". Defaulting to sound picker."); + return RingtonePickerViewModel.PickerType.SOUND_PICKER; } } } diff --git a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java index f045dc2f864c..914f16ab41df 100644 --- a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java +++ b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java @@ -27,6 +27,7 @@ import android.media.RingtoneManager; import android.net.Uri; import android.provider.Settings; +import androidx.annotation.NonNull; import androidx.lifecycle.ViewModel; import com.android.internal.annotations.VisibleForTesting; @@ -50,6 +51,7 @@ import javax.inject.Inject; public final class RingtonePickerViewModel extends ViewModel { static final int RINGTONE_TYPE_UNKNOWN = -1; + /** * Keep the currently playing ringtone around when changing orientation, so that it * can be stopped later, after the activity is recreated. @@ -72,16 +74,87 @@ public final class RingtonePickerViewModel extends ViewModel { private int mSampleItemPosition = ITEM_POSITION_UNKNOWN; /** The position in the list of the 'Default' item. */ private int mDefaultItemPosition = ITEM_POSITION_UNKNOWN; - /** The number of static items in the list. */ + /** The number of fixed items in the list. */ private int mFixedItemCount; private ListenableFuture<Uri> mAddCustomRingtoneFuture; private RingtoneManager mRingtoneManager; /** + * Stable ID for the ringtone that is currently selected (may be -1 if no ringtone is selected). + */ + private long mSelectedItemId = -1; + private int mSelectedItemPosition = ITEM_POSITION_UNKNOWN; + + /** * The ringtone that's currently playing. */ private Ringtone mCurrentRingtone; + private PickerConfig mPickerConfig; + + public enum PickerType { + RINGTONE_PICKER, + SOUND_PICKER, + VIBRATION_PICKER + } + + /** + * Holds immutable info on the picker that should be displayed. + */ + static final class PickerConfig { + public final String title; + /** + * Id of the user to which the ringtone picker should list the ringtones. + */ + public final int userId; + /** + * Ringtone type. + */ + public final int ringtoneType; + /** + * Whether this list has the 'Default' item. + */ + public final boolean hasDefaultItem; + /** + * The Uri to play when the 'Default' item is clicked. + */ + public final Uri uriForDefaultItem; + /** + * Whether this list has the 'Silent' item. + */ + public final boolean hasSilentItem; + /** + * AudioAttributes flags. + */ + public final int audioAttributesFlags; + /** + * The Uri to place a checkmark next to. + */ + public final Uri existingUri; + /** + * In the buttonless (watch-only) version we don't show the OK/Cancel buttons. + */ + public final boolean showOkCancelButtons; + + public final PickerType mPickerType; + + PickerConfig(String title, int userId, int ringtoneType, + boolean hasDefaultItem, Uri uriForDefaultItem, boolean hasSilentItem, + int audioAttributesFlags, Uri existingUri, boolean showOkCancelButtons, + PickerType pickerType) { + this.title = title; + this.userId = userId; + this.ringtoneType = ringtoneType; + this.hasDefaultItem = hasDefaultItem; + this.uriForDefaultItem = uriForDefaultItem; + this.hasSilentItem = hasSilentItem; + this.audioAttributesFlags = audioAttributesFlags; + this.existingUri = existingUri; + this.showOkCancelButtons = showOkCancelButtons; + this.mPickerType = pickerType; + } + } + @Inject RingtonePickerViewModel(RingtoneManagerFactory ringtoneManagerFactory, RingtoneFactory ringtoneFactory, @@ -91,6 +164,13 @@ public final class RingtonePickerViewModel extends ViewModel { mListeningExecutorService = listeningExecutorServiceFactory.createSingleThreadExecutor(); } + @NonNull + PickerConfig getPickerConfig() { + return requireNonNull(mPickerConfig, + "PickerConfig was never set. Did you forget to call " + + "RingtonePickerViewModel#init?"); + } + @StringRes static int getTitleByType(int ringtoneType) { switch (ringtoneType) { @@ -138,10 +218,11 @@ public final class RingtonePickerViewModel extends ViewModel { } } - void initRingtoneManager(int type) { + void init(@NonNull PickerConfig pickerConfig) { mRingtoneManager = mRingtoneManagerFactory.create(); - if (type != RINGTONE_TYPE_UNKNOWN) { - mRingtoneManager.setType(type); + mPickerConfig = pickerConfig; + if (pickerConfig.ringtoneType != RINGTONE_TYPE_UNKNOWN) { + mRingtoneManager.setType(pickerConfig.ringtoneType); } } @@ -166,7 +247,7 @@ public final class RingtonePickerViewModel extends ViewModel { */ void cancelPendingAsyncTasks() { if (mAddCustomRingtoneFuture != null && !mAddCustomRingtoneFuture.isDone()) { - mAddCustomRingtoneFuture.cancel(/*mayInterruptIfRunning=*/true); + mAddCustomRingtoneFuture.cancel(/* mayInterruptIfRunning= */ true); } } @@ -180,36 +261,48 @@ public final class RingtonePickerViewModel extends ViewModel { return mRingtoneManager.getCursor(); } - Uri getRingtoneUri(int ringtonePosition) { + Uri getRingtoneUri(int position) { requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); - return mRingtoneManager.getRingtoneUri(ringtonePosition); + return mRingtoneManager.getRingtoneUri(mapListPositionToRingtonePosition(position)); } int getRingtonePosition(Uri uri) { requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); - return mRingtoneManager.getRingtonePosition(uri); + return mapRingtonePositionToListPosition(mRingtoneManager.getRingtonePosition(uri)); } /** - * Returns the position of the item in the list before header views were added. + * Maps the item position in the list, to its equivalent position in the RingtoneManager. * - * @param itemPosition the position of item in the list with any added headers. - * @return position of the item in the list ignoring headers. + * @param itemPosition the position of item in the list. + * @return position of the item in the RingtoneManager. */ - int itemPositionToRingtonePosition(int itemPosition) { + private int mapListPositionToRingtonePosition(int itemPosition) { + // If the manager position is -1 (for not found), then return that. + if (itemPosition < 0) return itemPosition; + return itemPosition - mFixedItemCount; } - int getFixedItemCount() { - return mFixedItemCount; + /** + * Maps the item position in the RingtoneManager, to its equivalent position in the list. + * + * @param itemPosition the position of the item in the RingtoneManager. + * @return position of the item in the list. + */ + private int mapRingtonePositionToListPosition(int itemPosition) { + // If the manager position is -1 (for not found), then return that. + if (itemPosition < 0) return itemPosition; + + return itemPosition + mFixedItemCount; } void resetFixedItemCount() { mFixedItemCount = 0; } - void incrementFixedItemCount() { - mFixedItemCount++; + int incrementAndGetFixedItemCount() { + return mFixedItemCount++; } void setDefaultItemPosition(int defaultItemPosition) { @@ -232,6 +325,22 @@ public final class RingtonePickerViewModel extends ViewModel { mSampleItemPosition = sampleItemPosition; } + public int getSelectedItemPosition() { + return mSelectedItemPosition; + } + + public void setSelectedItemPosition(int selectedItemPosition) { + mSelectedItemPosition = selectedItemPosition; + } + + public void setSelectedItemId(long selectedItemId) { + mSelectedItemId = selectedItemId; + } + + public long getSelectedItemId() { + return mSelectedItemId; + } + void onPause(boolean isChangingConfigurations) { if (!isChangingConfigurations) { stopAnyPlayingRingtone(); @@ -247,19 +356,19 @@ public final class RingtonePickerViewModel extends ViewModel { } @Nullable - Uri getCurrentlySelectedRingtoneUri(int checkedItem, Uri defaultUri) { - if (checkedItem == ITEM_POSITION_UNKNOWN) { - // When the getCheckItem is POS_UNKNOWN, it is not the case we expected. + Uri getCurrentlySelectedRingtoneUri() { + if (mSelectedItemPosition == ITEM_POSITION_UNKNOWN) { + // When the selected item is POS_UNKNOWN, it is not the case we expected. // We return null for this case. return null; - } else if (checkedItem == mDefaultItemPosition) { + } else if (mSelectedItemPosition == mDefaultItemPosition) { // Use the default Uri that they originally gave us. - return defaultUri; - } else if (checkedItem == mSilentItemPosition) { + return mPickerConfig.uriForDefaultItem; + } else if (mSelectedItemPosition == mSilentItemPosition) { // Use a null Uri for the 'Silent' item. return null; } else { - return getRingtoneUri(itemPositionToRingtonePosition(checkedItem)); + return getRingtoneUri(mSelectedItemPosition); } } @@ -280,7 +389,8 @@ public final class RingtonePickerViewModel extends ViewModel { mCurrentRingtone.setStreamType(mRingtoneManager.inferStreamType()); } } else { - mCurrentRingtone = mRingtoneManager.getRingtone(position); + mCurrentRingtone = mRingtoneManager.getRingtone( + mapListPositionToRingtonePosition(position)); } if (mCurrentRingtone != null) { diff --git a/packages/SoundPicker/src/com/android/soundpicker/SoundPickerFragment.java b/packages/SoundPicker/src/com/android/soundpicker/SoundPickerFragment.java new file mode 100644 index 000000000000..e07d1095bed2 --- /dev/null +++ b/packages/SoundPicker/src/com/android/soundpicker/SoundPickerFragment.java @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2023 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.soundpicker; + +import android.app.Activity; +import android.content.ContentProvider; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.UserHandle; +import android.os.UserManager; +import android.provider.MediaStore; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.common.util.concurrent.FutureCallback; + +import dagger.hilt.android.AndroidEntryPoint; + +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * A fragment that will display a picker used to select sound or silent. It also includes the + * ability to add custom sounds. + */ +@AndroidEntryPoint(Fragment.class) +public class SoundPickerFragment extends Hilt_SoundPickerFragment { + + private static final String TAG = "SoundPickerFragment"; + private static final String COLUMN_LABEL = MediaStore.Audio.Media.TITLE; + private static final int POS_UNKNOWN = -1; + + private RingtonePickerViewModel.PickerConfig mPickerConfig; + private boolean mIsManagedProfile; + private RingtonePickerViewModel mRingtonePickerViewModel; + private RingtoneAdapter mRingtoneAdapter; + private RecyclerView mSoundRecyclerView; + + private final RingtoneAdapter.WorkRingtoneProvider mWorkRingtoneProvider = + new RingtoneAdapter.WorkRingtoneProvider() { + private Drawable mWorkIconDrawable; + @Override + public boolean isWorkRingtone(int position) { + if (mIsManagedProfile) { + /* + * Display the w ork icon if the ringtone belongs to a work profile. We + * can tell that a ringtone belongs to a work profile if the picker user + * is a managed profile, the ringtone Uri is in external storage, and + * either the uri has no user id or has the id of the picker user + */ + Uri currentUri = mRingtonePickerViewModel.getRingtoneUri(position); + int uriUserId = ContentProvider.getUserIdFromUri(currentUri, + mPickerConfig.userId); + Uri uriWithoutUserId = ContentProvider.getUriWithoutUserId(currentUri); + + return uriUserId == mPickerConfig.userId + && uriWithoutUserId.toString().startsWith( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.toString()); + } + + return false; + } + + @Override + public Drawable getWorkIconDrawable() { + if (mWorkIconDrawable == null) { + mWorkIconDrawable = requireActivity().getPackageManager() + .getUserBadgeForDensityNoBackground( + UserHandle.of(mPickerConfig.userId), /* density= */ -1); + } + + return mWorkIconDrawable; + } + }; + + private final FutureCallback<Uri> mAddCustomRingtoneCallback = new FutureCallback<>() { + @Override + public void onSuccess(Uri ringtoneUri) { + requeryForAdapter(); + } + + @Override + public void onFailure(Throwable throwable) { + Log.e(TAG, "Failed to add custom ringtone.", throwable); + // Ringtone was not added, display error Toast + Toast.makeText(requireActivity().getApplicationContext(), + R.string.unable_to_add_ringtone, Toast.LENGTH_SHORT).show(); + } + }; + + ActivityResultLauncher<Intent> mActivityResultLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + new ActivityResultCallback<ActivityResult>() { + @Override + public void onActivityResult(ActivityResult result) { + if (result.getResultCode() == Activity.RESULT_OK) { + // There are no request codes + Intent data = result.getData(); + mRingtonePickerViewModel.addRingtoneAsync(data.getData(), + mPickerConfig.ringtoneType, + mAddCustomRingtoneCallback, + // Causes the callback to be executed on the main thread. + ContextCompat.getMainExecutor( + requireActivity().getApplicationContext())); + } + } + }); + + private final RingtoneAdapter.RingtoneSelectionListener mRingtoneSelectionListener = + new RingtoneAdapter.RingtoneSelectionListener() { + @Override + public void onRingtoneSelected(int position) { + SoundPickerFragment.this.setSelectedItem(position); + + // In the buttonless (watch-only) version, preemptively set our result since + // we won't have another chance to do so before the activity closes. + if (!mPickerConfig.showOkCancelButtons) { + setSuccessResultWithRingtone( + mRingtonePickerViewModel.getCurrentlySelectedRingtoneUri()); + } + + // Play clip + playRingtone(position); + } + + @Override + public void onAddRingtoneSelected() { + // The "Add new ringtone" item was clicked. Start a file picker intent to + // select only audio files (MIME type "audio/*") + final Intent chooseFile = getMediaFilePickerIntent(); + mActivityResultLauncher.launch(chooseFile); + } + }; + + public SoundPickerFragment() { + super(R.layout.fragment_sound_picker); + } + + @Override + public void onViewCreated(@NotNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mRingtonePickerViewModel = new ViewModelProvider(requireActivity()).get( + RingtonePickerViewModel.class); + mSoundRecyclerView = view.findViewById(R.id.recycler_view); + Objects.requireNonNull(mSoundRecyclerView); + + mPickerConfig = mRingtonePickerViewModel.getPickerConfig(); + mIsManagedProfile = UserManager.get(requireActivity()).isManagedProfile( + mPickerConfig.userId); + + mRingtoneAdapter = createRingtoneAdapter(); + mSoundRecyclerView.setHasFixedSize(true); + mSoundRecyclerView.setAdapter(mRingtoneAdapter); + mSoundRecyclerView.setLayoutManager(new LinearLayoutManager(requireActivity())); + setSelectedItem(mRingtonePickerViewModel.getSelectedItemPosition()); + prepareRecyclerView(mSoundRecyclerView); + } + + private void prepareRecyclerView(@NonNull RecyclerView recyclerView) { + // Reset the static item count, as this method can be called multiple times + mRingtonePickerViewModel.resetFixedItemCount(); + + if (mPickerConfig.hasDefaultItem) { + int defaultItemPos = addDefaultRingtoneItem(); + + if (getSelectedItem() == POS_UNKNOWN + && RingtoneManager.isDefault(mPickerConfig.existingUri)) { + setSelectedItem(defaultItemPos); + } + } + + if (mPickerConfig.hasSilentItem) { + int silentItemPos = addSilentItem(); + + // The 'Silent' item should use a null Uri + if (getSelectedItem() == POS_UNKNOWN && mPickerConfig.existingUri == null) { + setSelectedItem(silentItemPos); + } + } + + if (getSelectedItem() == POS_UNKNOWN) { + setSelectedItem( + mRingtonePickerViewModel.getRingtonePosition(mPickerConfig.existingUri)); + } + + // In the buttonless (watch-only) version, preemptively set our result since we won't + // have another chance to do so before the activity closes. + if (!mPickerConfig.showOkCancelButtons) { + setSuccessResultWithRingtone( + mRingtonePickerViewModel.getCurrentlySelectedRingtoneUri()); + } + // If external storage is available, add a button to install sounds from storage. + if (resolvesMediaFilePicker() + && Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + addNewSoundItem(); + } + + // Enable context menu in ringtone items + registerForContextMenu(recyclerView); + } + + /** + * Re-query RingtoneManager for the most recent set of installed ringtones. May move the + * selected item position to match the new position of the chosen sound. + * <p> + * This should only need to happen after adding or removing a ringtone. + */ + private void requeryForAdapter() { + // Refresh and set a new cursor, closing the old one. + mRingtonePickerViewModel.init(mPickerConfig); + mRingtoneAdapter = createRingtoneAdapter(); + mSoundRecyclerView.setAdapter(mRingtoneAdapter); + prepareRecyclerView(mSoundRecyclerView); + + // Update selected item location. + int selectedPosition = POS_UNKNOWN; + for (int i = 0; i < mRingtoneAdapter.getItemCount(); i++) { + if (mRingtoneAdapter.getItemId(i) == mRingtonePickerViewModel.getSelectedItemId()) { + selectedPosition = i; + break; + } + } + if (mPickerConfig.hasSilentItem && selectedPosition == POS_UNKNOWN) { + selectedPosition = mRingtonePickerViewModel.getSilentItemPosition(); + } + setSelectedItem(selectedPosition); + } + + private void playRingtone(int position) { + mRingtonePickerViewModel.setSampleItemPosition(position); + mRingtonePickerViewModel.playRingtone(mRingtonePickerViewModel.getSampleItemPosition(), + mPickerConfig.uriForDefaultItem, mPickerConfig.audioAttributesFlags); + } + + private boolean resolvesMediaFilePicker() { + return getMediaFilePickerIntent().resolveActivity(requireActivity().getPackageManager()) + != null; + } + + private Intent getMediaFilePickerIntent() { + final Intent chooseFile = new Intent(Intent.ACTION_GET_CONTENT); + chooseFile.setType("audio/*"); + chooseFile.putExtra(Intent.EXTRA_MIME_TYPES, + new String[]{"audio/*", "application/ogg"}); + return chooseFile; + } + + private void setSuccessResultWithRingtone(Uri ringtoneUri) { + requireActivity().setResult(Activity.RESULT_OK, + new Intent().putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, ringtoneUri)); + } + + private int getSelectedItem() { + return mRingtonePickerViewModel.getSelectedItemPosition(); + } + + private void setSelectedItem(int pos) { + Objects.requireNonNull(mRingtoneAdapter); + mRingtonePickerViewModel.setSelectedItemPosition(pos); + mRingtoneAdapter.setSelectedItem(pos); + mRingtonePickerViewModel.setSelectedItemId(mRingtoneAdapter.getItemId(pos)); + mSoundRecyclerView.scrollToPosition(pos); + } + + /** + * Adds a fixed item to the fixed items list . A fixed item is one that is not from + * the RingtoneManager. + * + * @param textResId The resource ID of the text for the item. + * @return The position of the inserted item. + */ + private int addFixedItem(int textResId) { + mRingtoneAdapter.addTitleForFixedItem(textResId); + return mRingtonePickerViewModel.incrementAndGetFixedItemCount(); + } + + private int addDefaultRingtoneItem() { + int defaultRingtoneItemPos = addFixedItem( + RingtonePickerViewModel.getDefaultRingtoneItemTextByType( + mPickerConfig.ringtoneType)); + mRingtonePickerViewModel.setDefaultItemPosition(defaultRingtoneItemPos); + return defaultRingtoneItemPos; + } + + private int addSilentItem() { + int silentItemPos = addFixedItem(com.android.internal.R.string.ringtone_silent); + mRingtonePickerViewModel.setSilentItemPosition(silentItemPos); + return silentItemPos; + } + + private void addNewSoundItem() { + mRingtoneAdapter.addTitleForAddRingtoneItem( + RingtonePickerViewModel.getAddNewItemTextByType(mPickerConfig.ringtoneType)); + } + + private RingtoneAdapter createRingtoneAdapter() { + LocalizedCursor cursor = new LocalizedCursor( + mRingtonePickerViewModel.getRingtoneCursor(), getResources(), COLUMN_LABEL); + return new RingtoneAdapter(cursor, mRingtoneSelectionListener, mWorkRingtoneProvider); + } +} diff --git a/packages/SoundPicker/src/com/android/soundpicker/TabbedDialogFragment.java b/packages/SoundPicker/src/com/android/soundpicker/TabbedDialogFragment.java new file mode 100644 index 000000000000..63140d21516e --- /dev/null +++ b/packages/SoundPicker/src/com/android/soundpicker/TabbedDialogFragment.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2023 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.soundpicker; + +import static android.app.Activity.RESULT_CANCELED; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import dagger.hilt.android.AndroidEntryPoint; + +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * A dialog fragment with a sound and/or vibration tab based on the picker type. + * <ul> + * <li> Ringtone Pickers will display both sound and vibration tabs. + * <li> Sound Pickers will only display the sound tab. + * <li> Vibration Pickers will only display the vibration tab. + * </ul> + */ +@AndroidEntryPoint(DialogFragment.class) +public class TabbedDialogFragment extends Hilt_TabbedDialogFragment { + + static final String TAG = "TabbedDialogFragment"; + + private RingtonePickerViewModel mRingtonePickerViewModel; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mRingtonePickerViewModel = new ViewModelProvider(requireActivity()).get( + RingtonePickerViewModel.class); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity(), + android.R.style.ThemeOverlay_Material_Dialog) + .setTitle(mRingtonePickerViewModel.getPickerConfig().title); + // Do not show OK/Cancel buttons in the buttonless (watch-only) version. + if (mRingtonePickerViewModel.getPickerConfig().showOkCancelButtons) { + dialogBuilder + .setPositiveButton(getString(com.android.internal.R.string.ok), + (dialog, whichButton) -> { + setSuccessResultWithRingtone( + mRingtonePickerViewModel.getCurrentlySelectedRingtoneUri()); + requireActivity().finish(); + }) + .setNegativeButton(getString(com.android.internal.R.string.cancel), + (dialog, whichButton) -> { + requireActivity().setResult(RESULT_CANCELED); + requireActivity().finish(); + }); + } + + View view = buildTabbedView(requireActivity().getLayoutInflater()); + dialogBuilder.setView(view); + + return dialogBuilder.create(); + } + + @Override + public void onCancel(@NonNull @NotNull DialogInterface dialog) { + super.onCancel(dialog); + if (!requireActivity().isChangingConfigurations()) { + requireActivity().finish(); + } + } + + @Override + public void onDismiss(@NonNull @NotNull DialogInterface dialog) { + super.onDismiss(dialog); + if (!requireActivity().isChangingConfigurations()) { + requireActivity().finish(); + } + } + + private void setSuccessResultWithRingtone(Uri ringtoneUri) { + requireActivity().setResult(Activity.RESULT_OK, + new Intent().putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, ringtoneUri)); + } + + /** + * Inflates the tabbed layout view and adds the required fragments. If there's only one + * fragment to display, then the tab area is hidden. + * @param inflater The LayoutInflater that is used to inflate the tabbed view. + * @return The tabbed view. + */ + private View buildTabbedView(@NonNull LayoutInflater inflater) { + View view = inflater.inflate(R.layout.fragment_tabbed_dialog, null, false); + TabLayout tabLayout = view.findViewById(R.id.tabLayout); + ViewPager2 viewPager = view.findViewById(R.id.masterViewPager); + Objects.requireNonNull(tabLayout); + Objects.requireNonNull(viewPager); + + ViewPagerAdapter adapter = new ViewPagerAdapter(requireActivity()); + addFragments(adapter); + + if (adapter.getItemCount() == 1) { + // Hide the tab area since there's only one fragment to display. + tabLayout.setVisibility(View.GONE); + } + + viewPager.setAdapter(adapter); + new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> tab.setText(adapter.getTitle(position))).attach(); + + return view; + } + + /** + * Adds the appropriate fragments to the adapter based on the PickerType. + * + * @param adapter The adapter to add the fragments to. + */ + private void addFragments(ViewPagerAdapter adapter) { + switch (mRingtonePickerViewModel.getPickerConfig().mPickerType) { + case RINGTONE_PICKER: + adapter.addFragment(getString(R.string.sound_page_title), + new SoundPickerFragment()); + adapter.addFragment(getString(R.string.vibration_page_title), + new VibrationPickerFragment()); + break; + case SOUND_PICKER: + adapter.addFragment(getString(R.string.sound_page_title), + new SoundPickerFragment()); + break; + case VIBRATION_PICKER: + adapter.addFragment(getString(R.string.vibration_page_title), + new VibrationPickerFragment()); + break; + default: + adapter.addFragment(getString(R.string.sound_page_title), + new SoundPickerFragment()); + break; + } + } +} diff --git a/packages/SoundPicker/src/com/android/soundpicker/VibrationPickerFragment.java b/packages/SoundPicker/src/com/android/soundpicker/VibrationPickerFragment.java new file mode 100644 index 000000000000..356b9aee2c16 --- /dev/null +++ b/packages/SoundPicker/src/com/android/soundpicker/VibrationPickerFragment.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 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.soundpicker; + +import androidx.fragment.app.Fragment; + +/** + * A fragment that will display a picker used to select vibration. + */ +public class VibrationPickerFragment extends Fragment { + + public VibrationPickerFragment() { + super(R.layout.fragment_vibration_picker); + } +} diff --git a/packages/SoundPicker/src/com/android/soundpicker/ViewPagerAdapter.java b/packages/SoundPicker/src/com/android/soundpicker/ViewPagerAdapter.java new file mode 100644 index 000000000000..179068e9f20f --- /dev/null +++ b/packages/SoundPicker/src/com/android/soundpicker/ViewPagerAdapter.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023 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.soundpicker; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * An adapter used to populate pages inside a ViewPager. + */ +public class ViewPagerAdapter extends FragmentStateAdapter { + + private final List<Fragment> mFragments = new ArrayList<>(); + private final List<String> mTitles = new ArrayList<>(); + + public ViewPagerAdapter(@NonNull FragmentActivity fragmentActivity) { + super(fragmentActivity); + } + + /** + * Adds a fragment and page title to the adapter. + * @param title the title of the page in the ViewPager. + * @param fragment the fragment that will be inflated on this page. + */ + public void addFragment(String title, Fragment fragment) { + mTitles.add(title); + mFragments.add(fragment); + } + + /** + * Returns the title of the requested page. + * @param position the position of the page in the Viewpager. + * @return The title of the requested page. + */ + public String getTitle(int position) { + return mTitles.get(position); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return Objects.requireNonNull(mFragments.get(position), + "Could not find a fragment using position: " + position); + } + + @Override + public int getItemCount() { + return mFragments.size(); + } +} diff --git a/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java b/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java index 9ef3aa3b245f..659aae8188dd 100644 --- a/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java +++ b/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java @@ -112,7 +112,7 @@ public class RingtonePickerViewModelTest { @Test public void testInitRingtoneManager_whenTypeIsUnknown_createManagerButDoNotSetType() { - mViewModel.initRingtoneManager(RINGTONE_TYPE_UNKNOWN); + mViewModel.init(createPickerConfig(RINGTONE_TYPE_UNKNOWN)); verify(mMockRingtoneManagerFactory).create(); verify(mMockRingtoneManager, never()).setType(anyInt()); @@ -120,7 +120,7 @@ public class RingtonePickerViewModelTest { @Test public void testInitRingtoneManager_whenTypeIsNotUnknown_createManagerAndSetType() { - mViewModel.initRingtoneManager(RingtoneManager.TYPE_NOTIFICATION); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_NOTIFICATION)); verify(mMockRingtoneManagerFactory).create(); verify(mMockRingtoneManager).setType(RingtoneManager.TYPE_NOTIFICATION); @@ -129,14 +129,14 @@ public class RingtonePickerViewModelTest { @Test public void testGetStreamType_returnsTheCorrectStreamType() { when(mMockRingtoneManager.inferStreamType()).thenReturn(AudioManager.STREAM_ALARM); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); assertEquals(mViewModel.getRingtoneStreamType(), AudioManager.STREAM_ALARM); } @Test public void testGetRingtoneCursor_returnsTheCorrectRingtoneCursor() { when(mMockRingtoneManager.getCursor()).thenReturn(mMockCursor); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); assertEquals(mViewModel.getRingtoneCursor(), mMockCursor); } @@ -144,14 +144,14 @@ public class RingtonePickerViewModelTest { public void testGetRingtoneUri_returnsTheCorrectRingtoneUri() { Uri expectedUri = DEFAULT_URI; when(mMockRingtoneManager.getRingtoneUri(anyInt())).thenReturn(expectedUri); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); Uri actualUri = mViewModel.getRingtoneUri(DEFAULT_RINGTONE_POSITION); assertEquals(actualUri, expectedUri); } @Test public void testOnPause_withChangingConfigurationTrue_doNotStopPlayingRingtone() { - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); verifyRingtonePlayCalledAndMockPlayingState(mMockRingtone); @@ -161,7 +161,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnPause_withChangingConfigurationFalse_stopPlayingRingtone() { - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -172,7 +172,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnViewModelRecreated_previousRingtoneCanStillBeStopped() { - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.setSampleItemPosition(RINGTONE_POSITION); Ringtone mockRingtone1 = createMockRingtone(); Ringtone mockRingtone2 = createMockRingtone(); @@ -186,7 +186,7 @@ public class RingtonePickerViewModelTest { verify(mockRingtone1, never()).stop(); mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory, mMockListeningExecutorServiceFactory); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.setSampleItemPosition(RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -197,7 +197,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnStop_withChangingConfigurationTrueAndDefaultRingtonePlaying_saveRingtone() { - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -208,7 +208,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnStop_withChangingConfigurationTrueAndCurrentRingtonePlaying_saveRingtone() { - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.setSampleItemPosition(RINGTONE_POSITION); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -226,7 +226,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnStop_withChangingConfigurationFalse_stopPlayingRingtone() { - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -237,21 +237,24 @@ public class RingtonePickerViewModelTest { @Test public void testGetCurrentlySelectedRingtoneUri_checkedItemIsUnknown_returnsNull() { - Uri uri = mViewModel.getCurrentlySelectedRingtoneUri(POS_UNKNOWN, DEFAULT_URI); + mViewModel.setSelectedItemPosition(POS_UNKNOWN); + Uri uri = mViewModel.getCurrentlySelectedRingtoneUri(); assertNull(uri); } @Test public void testGetCurrentlySelectedRingtoneUri_checkedItemIsDefaultPos_returnsDefaultUri() { Uri expectedUri = DEFAULT_URI; - Uri actualUri = mViewModel.getCurrentlySelectedRingtoneUri(DEFAULT_RINGTONE_POSITION, - expectedUri); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); + mViewModel.setSelectedItemPosition(DEFAULT_RINGTONE_POSITION); + Uri actualUri = mViewModel.getCurrentlySelectedRingtoneUri(); assertEquals(actualUri, expectedUri); } @Test public void testGetCurrentlySelectedRingtoneUri_checkedItemIsSilentPos_returnsNull() { - Uri uri = mViewModel.getCurrentlySelectedRingtoneUri(SILENT_RINGTONE_POSITION, DEFAULT_URI); + mViewModel.setSelectedItemPosition(SILENT_RINGTONE_POSITION); + Uri uri = mViewModel.getCurrentlySelectedRingtoneUri(); assertNull(uri); } @@ -266,7 +269,7 @@ public class RingtonePickerViewModelTest { RingtoneManager.TYPE_NOTIFICATION)).thenReturn(DEFAULT_URI); mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory, mMockListeningExecutorServiceFactory); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.addRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION, mockCallback, mMainThreadExecutor); verify(mockCallback, never()).onFailure(any()); @@ -290,7 +293,7 @@ public class RingtonePickerViewModelTest { RingtoneManager.TYPE_NOTIFICATION)).thenReturn(DEFAULT_URI); mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory, mMockListeningExecutorServiceFactory); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.addRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION, mockCallback1, mMainThreadExecutor); verify(mockCallback1, never()).onFailure(any()); @@ -312,7 +315,7 @@ public class RingtonePickerViewModelTest { when(mMockRingtoneManager.addCustomExternalRingtone(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION)).thenReturn(expectedUri); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.addRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION, mockCallback, mMainThreadExecutor); @@ -330,7 +333,7 @@ public class RingtonePickerViewModelTest { when(mMockRingtoneManager.addCustomExternalRingtone(any(), anyInt())).thenThrow( IOException.class); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.addRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION, mockCallback, mMainThreadExecutor); @@ -342,8 +345,9 @@ public class RingtonePickerViewModelTest { public void testGetCurrentlySelectedRingtoneUri_checkedItemRingtonePos_returnsTheCorrectUri() { Uri expectedUri = DEFAULT_URI; when(mMockRingtoneManager.getRingtoneUri(RINGTONE_POSITION)).thenReturn(expectedUri); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); - Uri actualUri = mViewModel.getCurrentlySelectedRingtoneUri(RINGTONE_POSITION, DEFAULT_URI); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); + mViewModel.setSelectedItemPosition(RINGTONE_POSITION); + Uri actualUri = mViewModel.getCurrentlySelectedRingtoneUri(); verify(mMockRingtoneManager).getRingtoneUri(RINGTONE_POSITION); assertEquals(actualUri, expectedUri); @@ -353,7 +357,7 @@ public class RingtonePickerViewModelTest { public void testPlayRingtone_stopsPreviouslyRunningRingtone() { // Start playing the first ringtone mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone); @@ -369,7 +373,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_samplePosEqualToSilentPos_onlyStopPlayingRingtone() { mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone); @@ -388,7 +392,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_samplePosEqualToDefaultPos_playDefaultRingtone() { mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); when(mMockRingtoneManager.inferStreamType()).thenReturn(AudioManager.STREAM_ALARM); @@ -403,7 +407,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_samplePosNotEqualToDefaultPos_playRingtone() { mViewModel.setSampleItemPosition(RINGTONE_POSITION); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -417,7 +421,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_withNoAttributeFlags_doNotUpdateRingtoneAttributesFlags() { mViewModel.setSampleItemPosition(RINGTONE_POSITION); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, NO_ATTRIBUTES_FLAGS); @@ -430,7 +434,7 @@ public class RingtonePickerViewModelTest { public void testGetRingtonePosition_returnsTheCorrectRingtonePosition() { int expectedPosition = 1; when(mMockRingtoneManager.getRingtonePosition(any())).thenReturn(expectedPosition); - mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE)); int actualPosition = mViewModel.getRingtonePosition(DEFAULT_URI); assertEquals(actualPosition, expectedPosition); @@ -553,4 +557,13 @@ public class RingtonePickerViewModelTest { .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build(); } + + private RingtonePickerViewModel.PickerConfig createPickerConfig(int ringtoneType) { + return new RingtonePickerViewModel.PickerConfig("Phone ringtone", /* userId= */ 1, + ringtoneType, /* hasDefaultItem= */ true, + /* uriForDefaultItem= */ DEFAULT_URI, /* hasSilentItem= */ true, + /* audioAttributesFlags= */0, /* existingUri= */ Uri.parse(""), + /* showOkCancelButtons= */ true, + RingtonePickerViewModel.PickerType.RINGTONE_PICKER); + } } |