Add back the legacy SoundPicker and create a separate directory for the new picker.

We're bringing back the legacy SoundPicker and moving the new picker implementation into a separate directory (SoundPicker2). This way the currently released picker won't be using any unreleased (flagged) apis, and we won't leak the new features until they are un-flagged.

Bug: 293846645
Test: N/A
Change-Id: Iaf5780bc0efcb2095c9eba4129a75d06982bd140
diff --git a/packages/SoundPicker2/Android.bp b/packages/SoundPicker2/Android.bp
new file mode 100644
index 0000000..f4d8bf2
--- /dev/null
+++ b/packages/SoundPicker2/Android.bp
@@ -0,0 +1,46 @@
+package {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_library {
+    name: "SoundPicker2Lib",
+    srcs: [
+        "src/**/*.java",
+    ],
+    resource_dirs: [
+        "res",
+    ],
+    static_libs: [
+        "androidx.appcompat_appcompat",
+        "hilt_android",
+        "guava",
+        "androidx.recyclerview_recyclerview",
+        "androidx-constraintlayout_constraintlayout",
+        "androidx.viewpager2_viewpager2",
+        "com.google.android.material_material",
+    ],
+}
+
+android_app {
+    name: "SoundPicker2",
+    defaults: ["platform_app_defaults"],
+    manifest: "AndroidManifest.xml",
+    static_libs: ["SoundPicker2Lib"],
+    platform_apis: true,
+    certificate: "media",
+    privileged: true,
+
+    optimize: {
+        enabled: true,
+        optimize: true,
+        shrink: true,
+        shrink_resources: true,
+        obfuscate: false,
+        proguard_compatibility: false,
+    },
+}
diff --git a/packages/SoundPicker2/AndroidManifest.xml b/packages/SoundPicker2/AndroidManifest.xml
new file mode 100644
index 0000000..934b003
--- /dev/null
+++ b/packages/SoundPicker2/AndroidManifest.xml
@@ -0,0 +1,43 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+        package="com.android.soundpicker"
+        android:sharedUserId="android.media">
+
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+    <uses-permission android:name="android.permission.RECEIVE_DEVICE_CUSTOMIZATION_READY" />
+    <uses-permission android:name="android.permission.WRITE_SETTINGS" />
+
+    <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" />
+
+    <application
+            android:name=".RingtonePickerApplication"
+            android:allowBackup="false"
+            android:label="@string/app_label"
+            android:theme="@style/Theme.AppCompat"
+            android:supportsRtl="true">
+        <receiver android:name="RingtoneReceiver"
+                android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.DEVICE_CUSTOMIZATION_READY"/>
+            </intent-filter>
+        </receiver>
+
+        <service android:name="RingtoneOverlayService" />
+
+        <activity android:name="RingtonePickerActivity"
+                android:theme="@style/Theme.AppCompat.Dialog"
+                android:enabled="@*android:bool/config_defaultRingtonePickerEnabled"
+                android:excludeFromRecents="true"
+                android:exported="true">
+            <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>
+</manifest>
diff --git a/packages/SoundPicker2/OWNERS b/packages/SoundPicker2/OWNERS
new file mode 100644
index 0000000..5bf46e0
--- /dev/null
+++ b/packages/SoundPicker2/OWNERS
@@ -0,0 +1,2 @@
+# Haptics team works on the SoundPicker
+include platform/frameworks/base:/services/core/java/com/android/server/vibrator/OWNERS
diff --git a/packages/SoundPicker2/res/drawable/ic_add.xml b/packages/SoundPicker2/res/drawable/ic_add.xml
new file mode 100644
index 0000000..22b3fe9
--- /dev/null
+++ b/packages/SoundPicker2/res/drawable/ic_add.xml
@@ -0,0 +1,24 @@
+<!--
+    Copyright (C) 2016 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24.0dp"
+        android:height="24.0dp"
+        android:viewportWidth="48.0"
+        android:viewportHeight="48.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M38.0,26.0L26.0,26.0l0.0,12.0l-4.0,0.0L22.0,26.0L10.0,26.0l0.0,-4.0l12.0,0.0L22.0,10.0l4.0,0.0l0.0,12.0l12.0,0.0l0.0,4.0z"/>
+</vector>
\ No newline at end of file
diff --git a/packages/SoundPicker2/res/drawable/ic_add_padded.xml b/packages/SoundPicker2/res/drawable/ic_add_padded.xml
new file mode 100644
index 0000000..c376867
--- /dev/null
+++ b/packages/SoundPicker2/res/drawable/ic_add_padded.xml
@@ -0,0 +1,22 @@
+<!--
+    Copyright (C) 2017 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.
+-->
+
+<inset xmlns:android="http://schemas.android.com/apk/res/android"
+        android:drawable="@drawable/ic_add"
+        android:insetTop="4dp"
+        android:insetRight="4dp"
+        android:insetBottom="4dp"
+        android:insetLeft="4dp"/>
diff --git a/packages/SoundPicker2/res/layout-watch/add_new_sound_item.xml b/packages/SoundPicker2/res/layout-watch/add_new_sound_item.xml
new file mode 100644
index 0000000..edfc0ab
--- /dev/null
+++ b/packages/SoundPicker2/res/layout-watch/add_new_sound_item.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2017 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.
+-->
+
+<!--
+     Currently, no file manager app on watch could handle ACTION_GET_CONTENT intent.
+     Make the visibility to "gone" to prevent failures.
+ -->
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+        android:id="@+id/add_new_sound_text"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:minHeight="?android:attr/listPreferredItemHeightSmall"
+        android:textAppearance="?android:attr/textAppearanceMedium"
+        android:text="@null"
+        android:textColor="?android:attr/colorAccent"
+        android:gravity="center_vertical"
+        android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+        android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
+        android:drawableStart="@drawable/ic_add_padded"
+        android:drawablePadding="8dp"
+        android:ellipsize="marquee"
+        android:visibility="gone" />
diff --git a/packages/SoundPicker2/res/layout-watch/radio_with_work_badge.xml b/packages/SoundPicker2/res/layout-watch/radio_with_work_badge.xml
new file mode 100644
index 0000000..ee29a37
--- /dev/null
+++ b/packages/SoundPicker2/res/layout-watch/radio_with_work_badge.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2017 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.
+-->
+<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"
+    >
+
+    <CheckedTextView
+        android:id="@+id/checked_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:minHeight="?android:attr/listPreferredItemHeightSmall"
+        android:textAppearance="?android:attr/textAppearanceMedium"
+        android:textColor="?android:attr/textColorAlertDialogListItem"
+        android:gravity="center_vertical"
+        android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+        android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
+        android:drawableStart="?android:attr/listChoiceIndicatorSingle"
+        android:drawablePadding="8dp"
+        android:ellipsize="marquee"
+        android:layout_toLeftOf="@+id/work_icon"
+        android:maxLines="3" />
+
+    <ImageView
+        android:id="@id/work_icon"
+        android:layout_width="18dp"
+        android:layout_height="18dp"
+        android:layout_alignParentRight="true"
+        android:layout_centerVertical="true"
+        android:scaleType="centerCrop"
+        android:layout_marginRight="20dp" />
+</com.android.soundpicker.CheckedListItem>
diff --git a/packages/SoundPicker2/res/layout/activity_ringtone_picker.xml b/packages/SoundPicker2/res/layout/activity_ringtone_picker.xml
new file mode 100644
index 0000000..6fc6080
--- /dev/null
+++ b/packages/SoundPicker2/res/layout/activity_ringtone_picker.xml
@@ -0,0 +1,21 @@
+<?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"/>
\ No newline at end of file
diff --git a/packages/SoundPicker2/res/layout/add_new_sound_item.xml b/packages/SoundPicker2/res/layout/add_new_sound_item.xml
new file mode 100644
index 0000000..024b97e
--- /dev/null
+++ b/packages/SoundPicker2/res/layout/add_new_sound_item.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2016 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: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
+        android:layout_width="24dp"
+        android:layout_height="24dp"
+        android:layout_alignParentRight="true"
+        android:layout_centerVertical="true"
+        android:scaleType="centerCrop"
+        android:layout_marginRight="24dp"
+        android:layout_marginLeft="24dp"
+        android:src="@drawable/ic_add"/>
+
+    <TextView
+        android:id="@+id/add_new_sound_text"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:minHeight="?android:attr/listPreferredItemHeightSmall"
+        android:text="@null"
+        android:textColor="?android:attr/colorAccent"
+        android:textAppearance="?android:attr/textAppearanceMedium"
+        android:maxLines="3"
+        android:gravity="center_vertical"
+        android:paddingEnd="?android:attr/dialogPreferredPadding"
+        android:drawablePadding="20dp"
+        android:ellipsize="marquee"/>
+</LinearLayout>
\ No newline at end of file
diff --git a/packages/SoundPicker2/res/layout/fragment_ringtone_picker.xml b/packages/SoundPicker2/res/layout/fragment_ringtone_picker.xml
new file mode 100644
index 0000000..787f92e
--- /dev/null
+++ b/packages/SoundPicker2/res/layout/fragment_ringtone_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/SoundPicker2/res/layout/fragment_tabbed_dialog.xml b/packages/SoundPicker2/res/layout/fragment_tabbed_dialog.xml
new file mode 100644
index 0000000..7efd911
--- /dev/null
+++ b/packages/SoundPicker2/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/SoundPicker2/res/layout/radio_with_work_badge.xml b/packages/SoundPicker2/res/layout/radio_with_work_badge.xml
new file mode 100644
index 0000000..36ac93e
--- /dev/null
+++ b/packages/SoundPicker2/res/layout/radio_with_work_badge.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 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.
+-->
+
+<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"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:minHeight="?android:attr/listPreferredItemHeightSmall"
+        android:textAppearance="?android:attr/textAppearanceMedium"
+        android:textColor="?android:attr/textColorAlertDialogListItem"
+        android:gravity="center_vertical"
+        android:paddingStart="20dp"
+        android:paddingEnd="?android:attr/dialogPreferredPadding"
+        android:drawableStart="?android:attr/listChoiceIndicatorSingle"
+        android:drawablePadding="20dp"
+        android:ellipsize="marquee"
+        android:layout_toLeftOf="@+id/work_icon"
+        android:maxLines="3"/>
+
+    <ImageView
+        android:id="@id/work_icon"
+        android:layout_width="18dp"
+        android:layout_height="18dp"
+        android:layout_alignParentRight="true"
+        android:layout_centerVertical="true"
+        android:scaleType="centerCrop"
+        android:layout_marginRight="20dp"/>
+</com.android.soundpicker.CheckedListItem>
diff --git a/packages/SoundPicker2/res/raw/default_alarm_alert.ogg b/packages/SoundPicker2/res/raw/default_alarm_alert.ogg
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/SoundPicker2/res/raw/default_alarm_alert.ogg
diff --git a/packages/SoundPicker2/res/raw/default_notification_sound.ogg b/packages/SoundPicker2/res/raw/default_notification_sound.ogg
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/SoundPicker2/res/raw/default_notification_sound.ogg
diff --git a/packages/SoundPicker2/res/raw/default_ringtone.ogg b/packages/SoundPicker2/res/raw/default_ringtone.ogg
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/SoundPicker2/res/raw/default_ringtone.ogg
diff --git a/packages/SoundPicker2/res/values/config.xml b/packages/SoundPicker2/res/values/config.xml
new file mode 100644
index 0000000..4e237a2
--- /dev/null
+++ b/packages/SoundPicker2/res/values/config.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 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.
+-->
+
+<!-- These resources are around just to allow their values to be customized
+     for different hardware and product builds.  Do not translate.
+
+     NOTE: The naming convention is "config_camelCaseValue".  -->
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- True if the ringtone picker should show the ok/cancel buttons. If it is not shown, the
+    ringtone will be automatically selected when the picker is closed. -->
+    <bool name="config_showOkCancelButtons">true</bool>
+</resources>
diff --git a/packages/SoundPicker2/res/values/strings.xml b/packages/SoundPicker2/res/values/strings.xml
new file mode 100644
index 0000000..ab7b95a
--- /dev/null
+++ b/packages/SoundPicker2/res/values/strings.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2009 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.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+    <!-- Choice in the ringtone picker.  If chosen, the default ringtone will be used. -->
+    <string name="ringtone_default">Default ringtone</string>
+
+    <!-- Choice in the notification sound picker.  If chosen, the default notification sound will be
+         used. -->
+    <string name="notification_sound_default">Default notification sound</string>
+
+    <!-- Choice in the alarm sound picker.  If chosen, the default alarm sound will be used. -->
+    <string name="alarm_sound_default">Default alarm sound</string>
+
+    <!-- Text for the RingtonePicker item that allows adding a new ringtone. -->
+    <string name="add_ringtone_text">Add ringtone</string>
+    <!-- Text for the RingtonePicker item that allows adding a new alarm. -->
+    <string name="add_alarm_text">Add alarm</string>
+    <!-- Text for the RingtonePicker item that allows adding a new notification. -->
+    <string name="add_notification_text">Add notification</string>
+    <!-- Text for the RingtonePicker item ContextMenu that allows deleting a custom ringtone. -->
+    <string name="delete_ringtone_text">Delete</string>
+    <!-- Text for the Toast displayed when adding a custom ringtone fails. -->
+    <string name="unable_to_add_ringtone">Unable to add custom ringtone</string>
+    <!-- Text for the Toast displayed when deleting a custom ringtone fails. -->
+    <string name="unable_to_delete_ringtone">Unable to delete custom ringtone</string>
+
+    <!-- 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/SoundPicker2/res/values/styles.xml b/packages/SoundPicker2/res/values/styles.xml
new file mode 100644
index 0000000..d22d9c4
--- /dev/null
+++ b/packages/SoundPicker2/res/values/styles.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 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.
+-->
+
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <style name="PickerDialogTheme" parent="@*android:style/Theme.DeviceDefault.Settings.Dialog">
+    </style>
+
+</resources>
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/BasePickerFragment.java b/packages/SoundPicker2/src/com/android/soundpicker/BasePickerFragment.java
new file mode 100644
index 0000000..4fc2a86
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/BasePickerFragment.java
@@ -0,0 +1,312 @@
+/*
+ * 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.UserHandle;
+import android.os.UserManager;
+import android.provider.MediaStore;
+import android.util.Log;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import dagger.hilt.android.AndroidEntryPoint;
+
+import java.util.Objects;
+
+/**
+ * Base class for generic picker fragments.
+ *
+ * <p>This fragment displays a recycler view that is populated by a {@link RingtoneListViewAdapter}
+ * with data provided by a {@link RingtoneListHandler}. Each item can be selected on click,
+ * which also triggers a ringtone preview performed by the shared {@link RingtonePickerViewModel}.
+ * The ringtone preview uses the selection state of all picker fragments (e.g. sound selected by
+ * one fragment and vibration selected by another).
+ */
+@AndroidEntryPoint(Fragment.class)
+public abstract class BasePickerFragment extends Hilt_BasePickerFragment implements
+        RingtoneListViewAdapter.Callbacks {
+
+    private static final String TAG = "BasePickerFragment";
+    private static final String COLUMN_LABEL = MediaStore.Audio.Media.TITLE;
+    private boolean mIsManagedProfile;
+    private Drawable mWorkIconDrawable;
+
+    protected RingtoneListViewAdapter mRingtoneListViewAdapter;
+    protected RecyclerView mRecyclerView;
+    protected RingtonePickerViewModel.Config mPickerConfig;
+    protected RingtonePickerViewModel mRingtonePickerViewModel;
+    protected RingtoneListHandler.Config mRingtoneListConfig;
+    protected RingtoneListHandler mRingtoneListHandler;
+
+    public BasePickerFragment() {
+        super(R.layout.fragment_ringtone_picker);
+    }
+
+    @Override
+    public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
+        super.onViewCreated(view, savedInstanceState);
+        mRingtonePickerViewModel = new ViewModelProvider(requireActivity()).get(
+                RingtonePickerViewModel.class);
+        mRingtoneListHandler = getRingtoneListHandler();
+        mRecyclerView = view.requireViewById(R.id.recycler_view);
+
+        mPickerConfig = mRingtonePickerViewModel.getPickerConfig();
+        mRingtoneListConfig = mRingtoneListHandler.getRingtoneListConfig();
+
+        mIsManagedProfile = UserManager.get(requireActivity()).isManagedProfile(
+                mPickerConfig.userId);
+
+        mRingtoneListViewAdapter = createRingtoneListViewAdapter();
+        mRecyclerView.setHasFixedSize(true);
+        mRecyclerView.setAdapter(mRingtoneListViewAdapter);
+        mRecyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));
+        setSelectedItem(mRingtoneListHandler.getSelectedItemPosition());
+        prepareRecyclerView(mRecyclerView);
+    }
+
+    @Override
+    public boolean isWorkRingtone(int position) {
+        if (!mIsManagedProfile) {
+            return false;
+        }
+
+        /*
+         * 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 = mRingtoneListHandler.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());
+    }
+
+    @Override
+    public Drawable getWorkIconDrawable() {
+        if (mWorkIconDrawable == null) {
+            mWorkIconDrawable = requireActivity().getPackageManager()
+                    .getUserBadgeForDensityNoBackground(
+                            UserHandle.of(mPickerConfig.userId), /* density= */ -1);
+        }
+
+        return mWorkIconDrawable;
+    }
+
+    @Override
+    public void onRingtoneSelected(int position) {
+        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) {
+            setSuccessResultWithSelectedRingtone();
+        }
+
+        // Play clip
+        mRingtonePickerViewModel.playRingtone();
+    }
+
+    @Override
+    public void onAddRingtoneSelected() {
+        addRingtoneAsync();
+    }
+
+    /**
+     * Sets up the list by adding fixed items to the top and bottom, if required. And sets the
+     * selected item in the list.
+     * @param recyclerView The recyclerview that contains the list of displayed items.
+     */
+    protected void prepareRecyclerView(@NonNull RecyclerView recyclerView) {
+        // Reset the static item count, as this method can be called multiple times
+        mRingtoneListHandler.resetFixedItems();
+
+        if (mRingtoneListConfig.hasDefaultItem) {
+            int defaultItemPos = addDefaultRingtoneItem();
+
+            if (getSelectedItem() < 0
+                    && RingtoneManager.isDefault(mRingtoneListConfig.initialSelectedUri)) {
+                setSelectedItem(defaultItemPos);
+            }
+        }
+
+        if (mRingtoneListConfig.hasSilentItem) {
+            int silentItemPos = addSilentItem();
+
+            // The 'Silent' item should use a null Uri
+            if (getSelectedItem() < 0
+                    && mRingtoneListConfig.initialSelectedUri == null) {
+                setSelectedItem(silentItemPos);
+            }
+        }
+
+        if (getSelectedItem() < 0) {
+            setSelectedItem(mRingtoneListHandler.getRingtonePosition(
+                    mRingtoneListConfig.initialSelectedUri));
+        }
+
+        // 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) {
+            setSuccessResultWithSelectedRingtone();
+        }
+
+        addNewRingtoneItem();
+
+        // Enable context menu in ringtone items
+        registerForContextMenu(recyclerView);
+    }
+
+    /**
+     * Returns the fragment's sound/vibration list handler.
+     * @return The ringtone list handler.
+     */
+    protected abstract RingtoneListHandler getRingtoneListHandler();
+
+    /**
+     * Starts the process to add a new ringtone to the list of ringtones asynchronously.
+     * Currently, only works for adding sound files.
+     */
+    protected abstract void addRingtoneAsync();
+
+    /**
+     * Adds an item to the end of the list that can be used to add new ringtones to the list.
+     * Currently, only works for adding sound files.
+     */
+    protected abstract void addNewRingtoneItem();
+
+    protected int getSelectedItem() {
+        return mRingtoneListHandler.getSelectedItemPosition();
+    }
+
+    /**
+     * Returns the selected URI to the caller activity.
+     */
+    protected void setSuccessResultWithSelectedRingtone() {
+        requireActivity().setResult(Activity.RESULT_OK,
+                new Intent().putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI,
+                        mRingtonePickerViewModel.getSelectedRingtoneUri()));
+    }
+
+    /**
+     * Creates a ringtone recyclerview adapter using the ringtone manager cursor.
+     * @return The created RingtoneListViewAdapter.
+     */
+    protected RingtoneListViewAdapter createRingtoneListViewAdapter() {
+        LocalizedCursor cursor = new LocalizedCursor(
+                mRingtoneListHandler.getRingtoneCursor(), getResources(), COLUMN_LABEL);
+        return new RingtoneListViewAdapter(cursor, /* RingtoneListViewAdapterCallbacks= */ this);
+    }
+
+    /**
+     * Sets the selected item in the list and scroll to the position in the recyclerview.
+     * @param pos the position of the selected item in the list.
+     */
+    protected void setSelectedItem(int pos) {
+        Objects.requireNonNull(mRingtoneListViewAdapter);
+        mRingtoneListHandler.setSelectedItemPosition(pos);
+        mRingtoneListViewAdapter.setSelectedItem(pos);
+        mRingtoneListHandler.setSelectedItemId(mRingtoneListViewAdapter.getItemId(pos));
+        mRecyclerView.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 index of the inserted fixed item in the adapter.
+     */
+    protected int addFixedItem(int textResId) {
+        return mRingtoneListViewAdapter.addTitleForFixedItem(textResId);
+    }
+
+    /**
+     * 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 ringtone.
+     * <p>
+     * This should only need to happen after adding or removing a ringtone.
+     */
+    protected void requeryForAdapter() {
+        mRingtonePickerViewModel.reinit();
+        // Refresh and set a new cursor, and closing the old one.
+        mRingtoneListViewAdapter = createRingtoneListViewAdapter();
+        mRecyclerView.setAdapter(mRingtoneListViewAdapter);
+        prepareRecyclerView(mRecyclerView);
+
+        // Update selected item location.
+        for (int i = 0; i < mRingtoneListViewAdapter.getItemCount(); i++) {
+            if (mRingtoneListViewAdapter.getItemId(i)
+                    == mRingtoneListHandler.getSelectedItemId()) {
+                setSelectedItem(i);
+                return;
+            }
+        }
+
+        // If selected item is still unknown, then set it to the default item, if available.
+        // If it's not available, then attempt to set it to the silent item in the list.
+        int selectedPosition = mRingtoneListHandler.getDefaultItemPosition();
+
+        if (selectedPosition < 0) {
+            selectedPosition = mRingtoneListHandler.getSilentItemPosition();
+        }
+
+        setSelectedItem(selectedPosition);
+    }
+
+    private int addDefaultRingtoneItem() {
+        int defaultItemPosInAdapter = addFixedItem(
+                RingtonePickerViewModel.getDefaultRingtoneItemTextByType(
+                        mPickerConfig.ringtoneType));
+        int defaultItemPosInListHandler = mRingtoneListHandler.addDefaultItem();
+
+        if (defaultItemPosInAdapter != defaultItemPosInListHandler) {
+            Log.wtf(TAG, "Default item position in adapter and list handler must match.");
+            return RingtoneListHandler.ITEM_POSITION_UNKNOWN;
+        }
+
+        return defaultItemPosInListHandler;
+    }
+
+    private int addSilentItem() {
+        int silentItemPosInAdapter = addFixedItem(com.android.internal.R.string.ringtone_silent);
+        int silentItemPosInListHandler = mRingtoneListHandler.addSilentItem();
+
+        if (silentItemPosInAdapter != silentItemPosInListHandler) {
+            Log.wtf(TAG, "Silent item position in adapter and list handler must match.");
+            return RingtoneListHandler.ITEM_POSITION_UNKNOWN;
+        }
+
+        return silentItemPosInListHandler;
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/CheckedListItem.java b/packages/SoundPicker2/src/com/android/soundpicker/CheckedListItem.java
new file mode 100644
index 0000000..819ae98
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/CheckedListItem.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2016 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.Context;
+import android.util.AttributeSet;
+import android.widget.Checkable;
+import android.widget.CheckedTextView;
+import android.widget.RelativeLayout;
+
+/**
+ * The {@link CheckedListItem} is a layout item that represents a ringtone, and is used in
+ * {@link RingtonePickerActivity}. It contains the ringtone's name, and a work badge to right of the
+ * name if the ringtone belongs to a work profile.
+ */
+public class CheckedListItem extends RelativeLayout implements Checkable {
+
+    public CheckedListItem(Context context) {
+        super(context);
+    }
+
+    public CheckedListItem(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public CheckedListItem(Context context, AttributeSet attrs, int defStyleAttr) {
+        super(context, attrs, defStyleAttr);
+    }
+
+    public CheckedListItem(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+        super(context, attrs, defStyleAttr, defStyleRes);
+    }
+
+    @Override
+    public void setChecked(boolean checked) {
+        getCheckedTextView().setChecked(checked);
+    }
+
+    @Override
+    public boolean isChecked() {
+        return getCheckedTextView().isChecked();
+    }
+
+    @Override
+    public void toggle() {
+        getCheckedTextView().toggle();
+    }
+
+    private CheckedTextView getCheckedTextView() {
+        return (CheckedTextView) findViewById(R.id.checked_text_view);
+    }
+
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/ListeningExecutorServiceFactory.java b/packages/SoundPicker2/src/com/android/soundpicker/ListeningExecutorServiceFactory.java
new file mode 100644
index 0000000..afdbf05
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/ListeningExecutorServiceFactory.java
@@ -0,0 +1,44 @@
+/*
+ * 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 com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.Executors;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+/**
+ * A factory class used to create {@link ListeningExecutorService}.
+ */
+@Singleton
+public class ListeningExecutorServiceFactory {
+
+    @Inject
+    ListeningExecutorServiceFactory() {
+    }
+
+    /**
+     * Returns a single thread {@link ListeningExecutorService}.
+     *
+     */
+    public ListeningExecutorService createSingleThreadExecutor() {
+        return MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/LocalizedCursor.java b/packages/SoundPicker2/src/com/android/soundpicker/LocalizedCursor.java
new file mode 100644
index 0000000..83d04a3
--- /dev/null
+++ b/packages/SoundPicker2/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/SoundPicker2/src/com/android/soundpicker/RingtoneFactory.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneFactory.java
new file mode 100644
index 0000000..6817f53
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneFactory.java
@@ -0,0 +1,60 @@
+/*
+ * 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.Context;
+import android.media.AudioAttributes;
+import android.media.Ringtone;
+import android.media.RingtoneManager;
+import android.net.Uri;
+
+import dagger.hilt.android.qualifiers.ApplicationContext;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+/**
+ * A factory class used to create {@link Ringtone}.
+ */
+@Singleton
+public class RingtoneFactory {
+
+    private final Context mApplicationContext;
+
+    @Inject
+    RingtoneFactory(@ApplicationContext Context applicationContext) {
+        mApplicationContext = applicationContext;
+    }
+
+    /**
+     * Returns a {@link Ringtone} built from the provided URI and audio attributes flags.
+     *
+     * @param uri The URI used to build the {@link Ringtone}.
+     * @param audioAttributesFlags A combination of audio attribute flags that affect the volume
+     *                             and settings when playing the ringtone.
+     * @return the built {@link Ringtone}.
+     */
+    public Ringtone create(Uri uri, int audioAttributesFlags) {
+        AudioAttributes audioAttributes = new AudioAttributes.Builder()
+                .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
+                .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+                .setFlags(audioAttributesFlags)
+                .build();
+        return RingtoneManager.getRingtone(mApplicationContext, uri,
+                /* volumeShaperConfig= */ null, audioAttributes);
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/RingtoneListHandler.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneListHandler.java
new file mode 100644
index 0000000..bb38e0e
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneListHandler.java
@@ -0,0 +1,222 @@
+/*
+ * 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 java.util.Objects.requireNonNull;
+
+import android.annotation.Nullable;
+import android.database.Cursor;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import javax.inject.Inject;
+
+/**
+ * Handles ringtone list state and actions. This includes keeping track of the selected item,
+ * ringtone manager cursor and added items to the list.
+ */
+public class RingtoneListHandler {
+
+    // TODO: We're using an empty URI instead of null, because null URIs still produce a sound,
+    //  while empty ones don't (Potentially this might be due to empty URIs being perceived as
+    //  malformed ones). We will switch to using the official silent URIs (SOUND_OFF, VIBRATION_OFF)
+    //  once they become available.
+    static final Uri SILENT_URI = Uri.EMPTY;
+    static final int ITEM_POSITION_UNKNOWN = -1;
+
+    private static final String TAG = "RingtoneListHandler";
+
+    /** The position in the list of the 'Silent' item. */
+    private int mSilentItemPosition = ITEM_POSITION_UNKNOWN;
+    /** The position in the list of the 'Default' item. */
+    private int mDefaultItemPosition = ITEM_POSITION_UNKNOWN;
+    /** The number of fixed items in the list. */
+    private int mFixedItemCount;
+    /**
+     * 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;
+
+    private RingtoneManager mRingtoneManager;
+    private Config mRingtoneListConfig;
+    private Cursor mRingtoneCursor;
+
+    /**
+     * Holds immutable info on the ringtone list that is displayed.
+     */
+    static final class Config {
+        /**
+         * 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;
+        /**
+         * The initially selected uri in the list.
+         */
+        public final Uri initialSelectedUri;
+
+        Config(boolean hasDefaultItem, Uri uriForDefaultItem, boolean hasSilentItem,
+                Uri initialSelectedUri) {
+            this.hasDefaultItem = hasDefaultItem;
+            this.uriForDefaultItem = uriForDefaultItem;
+            this.hasSilentItem = hasSilentItem;
+            this.initialSelectedUri = initialSelectedUri;
+        }
+    }
+
+    @Inject
+    RingtoneListHandler() {
+    }
+
+    void init(@NonNull Config ringtoneListConfig,
+            @NonNull RingtoneManager ringtoneManager, @NonNull Cursor ringtoneCursor) {
+        mRingtoneManager = requireNonNull(ringtoneManager);
+        mRingtoneListConfig = requireNonNull(ringtoneListConfig);
+        mRingtoneCursor = requireNonNull(ringtoneCursor);
+    }
+
+    Config getRingtoneListConfig() {
+        return mRingtoneListConfig;
+    }
+
+    Cursor getRingtoneCursor() {
+        requireInitCalled();
+        return mRingtoneCursor;
+    }
+
+    Uri getRingtoneUri(int position) {
+        if (position < 0) {
+            Log.w(TAG, "Selected item position is unknown.");
+            // When the selected item is ITEM_POSITION_UNKNOWN, it is not the case we expected.
+            // We return SILENT_URI for this case.
+            return SILENT_URI;
+        } else if (position == mDefaultItemPosition) {
+            // Use the default Uri that they originally gave us.
+            return mRingtoneListConfig.uriForDefaultItem;
+        } else if (position == mSilentItemPosition) {
+            // Use SILENT_URI for the 'Silent' item.
+            return SILENT_URI;
+        } else {
+            requireInitCalled();
+            return mRingtoneManager.getRingtoneUri(mapListPositionToRingtonePosition(position));
+        }
+    }
+
+    int getRingtonePosition(Uri uri) {
+        requireInitCalled();
+        return mapRingtonePositionToListPosition(mRingtoneManager.getRingtonePosition(uri));
+    }
+
+    void resetFixedItems() {
+        mFixedItemCount = 0;
+        mDefaultItemPosition = ITEM_POSITION_UNKNOWN;
+        mSilentItemPosition = ITEM_POSITION_UNKNOWN;
+    }
+
+    int addDefaultItem() {
+        if (mDefaultItemPosition < 0) {
+            mDefaultItemPosition = addFixedItem();
+        }
+        return mDefaultItemPosition;
+    }
+
+    int getDefaultItemPosition() {
+        return mDefaultItemPosition;
+    }
+
+    int addSilentItem() {
+        if (mSilentItemPosition < 0) {
+            mSilentItemPosition = addFixedItem();
+        }
+        return mSilentItemPosition;
+    }
+
+    public int getSilentItemPosition() {
+        return mSilentItemPosition;
+    }
+
+    int getSelectedItemPosition() {
+        return mSelectedItemPosition;
+    }
+
+    void setSelectedItemPosition(int selectedItemPosition) {
+        mSelectedItemPosition = selectedItemPosition;
+    }
+
+    void setSelectedItemId(long selectedItemId) {
+        mSelectedItemId = selectedItemId;
+    }
+
+    long getSelectedItemId() {
+        return mSelectedItemId;
+    }
+
+    @Nullable
+    Uri getSelectedRingtoneUri() {
+        return getRingtoneUri(mSelectedItemPosition);
+    }
+
+    /**
+     * Maps the item position in the list, to its equivalent position in the RingtoneManager.
+     *
+     * @param itemPosition the position of item in the list.
+     * @return position of the item in the RingtoneManager.
+     */
+    private int mapListPositionToRingtonePosition(int itemPosition) {
+        // If the manager position is less than add items, then return that.
+        if (itemPosition < mFixedItemCount) return itemPosition;
+
+        return itemPosition - 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 less than add items, then return that.
+        if (itemPosition < 0) return itemPosition;
+
+        return itemPosition + mFixedItemCount;
+    }
+
+    /**
+     * Increments the number of added fixed items and returns the index of the newest added item.
+     * @return index of the newest added fixed item.
+     */
+    private int addFixedItem() {
+        return mFixedItemCount++;
+    }
+
+    private void requireInitCalled() {
+        requireNonNull(mRingtoneManager);
+        requireNonNull(mRingtoneCursor);
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/RingtoneListViewAdapter.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneListViewAdapter.java
new file mode 100644
index 0000000..4ca8943
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneListViewAdapter.java
@@ -0,0 +1,264 @@
+/*
+ * 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 RingtoneListViewAdapter 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 Callbacks mCallbacks;
+    private final int mRowIDColumn;
+    private int mSelectedItem = -1;
+    @StringRes private Integer mAddRingtoneItemTitle;
+
+    /** Provides callbacks for the adapter. */
+    interface Callbacks {
+        void onRingtoneSelected(int position);
+        void onAddRingtoneSelected();
+        boolean isWorkRingtone(int position);
+        Drawable getWorkIconDrawable();
+    }
+
+    RingtoneListViewAdapter(Cursor cursor,
+            Callbacks callbacks) {
+        mCursor = cursor;
+        mCallbacks = callbacks;
+        mFixedItemTitles = new ArrayList<>();
+        mRowIDColumn = mCursor != null ? mCursor.getColumnIndex("_id") : -1;
+        setHasStableIds(true);
+    }
+
+    void setSelectedItem(int position) {
+        notifyItemChanged(mSelectedItem);
+        mSelectedItem = position;
+        notifyItemChanged(mSelectedItem);
+    }
+
+    /**
+     * Adds title to the fixed items list and returns the index of the newest added item.
+     * @param textResId the title to add to the fixed items list.
+     * @return The index of the newest added item in the fixed items list.
+     */
+    int addTitleForFixedItem(@StringRes int textResId) {
+        mFixedItemTitles.add(textResId);
+        notifyItemInserted(mFixedItemTitles.size() - 1);
+        return 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, mCallbacks);
+        }
+
+        if (viewType == VIEW_TYPE_ADD_RINGTONE_ITEM) {
+            View addRingtoneItemView = inflater.inflate(R.layout.add_new_sound_item, parent, false);
+
+            return new AddRingtoneItemViewHolder(addRingtoneItemView,
+                    mCallbacks);
+        }
+
+        View view = inflater.inflate(R.layout.radio_with_work_badge, parent, false);
+
+        return new DynamicItemViewHolder(view, mCallbacks);
+    }
+
+    @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 = (mCallbacks != null)
+                && mCallbacks.isWorkRingtone(position)
+                ? mCallbacks.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, Callbacks listener) {
+            super(itemView);
+
+            mTitleTextView = itemView.requireViewById(R.id.checked_text_view);
+            mWorkIcon = itemView.requireViewById(R.id.work_icon);
+            itemView.setOnClickListener(v -> listener.onRingtoneSelected(this.getLayoutPosition()));
+        }
+
+        void onBind(String title, boolean isChecked, Drawable workIcon) {
+            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, Callbacks 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, Callbacks listener) {
+            super(itemView);
+
+            mTitleTextView = itemView.requireViewById(R.id.add_new_sound_text);
+            itemView.setOnClickListener(v -> listener.onAddRingtoneSelected());
+        }
+
+        void onBind(@StringRes int title) {
+            mTitleTextView.setText(title);
+        }
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/RingtoneManagerFactory.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneManagerFactory.java
new file mode 100644
index 0000000..f08eb24
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneManagerFactory.java
@@ -0,0 +1,49 @@
+/*
+ * 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.Context;
+import android.media.RingtoneManager;
+
+import dagger.hilt.android.qualifiers.ApplicationContext;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+/**
+ * A factory class used to create {@link RingtoneManager}.
+ */
+@Singleton
+public class RingtoneManagerFactory {
+
+    private final Context mApplicationContext;
+
+    @Inject
+    RingtoneManagerFactory(@ApplicationContext Context applicationContext) {
+        mApplicationContext = applicationContext;
+    }
+
+    /**
+     * Creates a new {@link RingtoneManager} and returns it.
+     *
+     * @return a {@link RingtoneManager}
+     */
+    public RingtoneManager create() {
+        return new RingtoneManager(mApplicationContext, /* includeParentRingtones */ true);
+    }
+}
+
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/RingtoneOverlayService.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneOverlayService.java
new file mode 100644
index 0000000..b94ebeb
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneOverlayService.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2018 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.Service;
+import android.content.Intent;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Environment;
+import android.os.FileUtils;
+import android.os.IBinder;
+import android.provider.MediaStore;
+import android.provider.Settings.System;
+import android.util.Log;
+
+import androidx.annotation.IdRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Service to copy and set customization of default sounds
+ */
+public class RingtoneOverlayService extends Service {
+    private static final String TAG = "RingtoneOverlayService";
+    private static final boolean DEBUG = false;
+
+    @Override
+    public int onStartCommand(@Nullable final Intent intent, final int flags, final int startId) {
+        AsyncTask.execute(() -> {
+            updateRingtones();
+            stopSelf();
+        });
+
+        // Try again later if we are killed before we finish.
+        return Service.START_REDELIVER_INTENT;
+    }
+
+    @Override
+    public IBinder onBind(@Nullable final Intent intent) {
+        return null;
+    }
+
+    private void updateRingtones() {
+        copyResourceAndSetAsSound(R.raw.default_ringtone,
+                System.RINGTONE, Environment.DIRECTORY_RINGTONES);
+        copyResourceAndSetAsSound(R.raw.default_notification_sound,
+                System.NOTIFICATION_SOUND, Environment.DIRECTORY_NOTIFICATIONS);
+        copyResourceAndSetAsSound(R.raw.default_alarm_alert,
+                System.ALARM_ALERT, Environment.DIRECTORY_ALARMS);
+    }
+
+    /* If the resource contains any data, copy a resource to the file system, scan it, and set the
+     * file URI as the default for a sound. */
+    private void copyResourceAndSetAsSound(@IdRes final int id, @NonNull final String name,
+            @NonNull final String subPath) {
+        final File destDir = Environment.getExternalStoragePublicDirectory(subPath);
+        if (!destDir.exists() && !destDir.mkdirs()) {
+            Log.e(TAG, "can't create " + destDir.getAbsolutePath());
+            return;
+        }
+
+        final File dest = new File(destDir, "default_" + name + ".ogg");
+        try (
+                InputStream is = getResources().openRawResource(id);
+                FileOutputStream os = new FileOutputStream(dest);
+        ) {
+            if (is.available() > 0) {
+                FileUtils.copy(is, os);
+                final Uri uri = scanFile(dest);
+                if (uri != null) {
+                    set(name, uri);
+                }
+            } else {
+                // TODO Shall we remove any former copied resource in this case and unset
+                // the defaults if we use this event a second time to clear the data?
+                if (DEBUG) Log.d(TAG, "Resource for " + name + " has no overlay");
+            }
+        } catch (IOException e) {
+            Log.e(TAG, "Unable to open resource for " + name + ": " + e);
+        }
+    }
+
+    private Uri scanFile(@NonNull final File file) {
+        return MediaStore.scanFile(getContentResolver(), file);
+    }
+
+    private void set(@NonNull final String name, @NonNull final Uri uri) {
+        final Uri settingUri = System.getUriFor(name);
+        RingtoneManager.setActualDefaultRingtoneUri(this,
+                RingtoneManager.getDefaultType(settingUri), uri);
+        System.putInt(getContentResolver(), name + "_set", 1);
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerActivity.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerActivity.java
new file mode 100644
index 0000000..90a14f9
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerActivity.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright (C) 2007 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.Intent;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.util.Log;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.lifecycle.ViewModelProvider;
+
+import dagger.hilt.android.AndroidEntryPoint;
+
+/**
+ * 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.
+ *
+ * @see RingtoneManager#ACTION_RINGTONE_PICKER
+ */
+@AndroidEntryPoint(AppCompatActivity.class)
+public final class RingtonePickerActivity extends Hilt_RingtonePickerActivity {
+
+    private static final String TAG = "RingtonePickerActivity";
+    // TODO: Use the extra keys from RingtoneManager once they're added.
+    private static final String EXTRA_RINGTONE_PICKER_CATEGORY = "EXTRA_RINGTONE_PICKER_CATEGORY";
+    private static final String EXTRA_VIBRATION_SHOW_DEFAULT = "EXTRA_VIBRATION_SHOW_DEFAULT";
+    private static final String EXTRA_VIBRATION_DEFAULT_URI = "EXTRA_VIBRATION_DEFAULT_URI";
+    private static final String EXTRA_VIBRATION_SHOW_SILENT = "EXTRA_VIBRATION_SHOW_SILENT";
+    private static final String EXTRA_VIBRATION_EXISTING_URI = "EXTRA_VIBRATION_EXISTING_URI";
+    private static final boolean RINGTONE_PICKER_CATEGORY_FEATURE_ENABLED = false;
+
+    private RingtonePickerViewModel mRingtonePickerViewModel;
+    private int mAttributesFlags;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_ringtone_picker);
+
+        mRingtonePickerViewModel = new ViewModelProvider(this).get(RingtonePickerViewModel.class);
+
+        Intent intent = getIntent();
+        /**
+         * Id of the user to which the ringtone picker should list the ringtones
+         */
+        int pickerUserId = UserHandle.myUserId();
+
+        // Get the types of ringtones to show
+        int ringtoneType = intent.getIntExtra(RingtoneManager.EXTRA_RINGTONE_TYPE,
+                RingtonePickerViewModel.RINGTONE_TYPE_UNKNOWN);
+
+        // AudioAttributes flags
+        mAttributesFlags |= intent.getIntExtra(
+                RingtoneManager.EXTRA_RINGTONE_AUDIO_ATTRIBUTES_FLAGS,
+                0 /*defaultValue == no flags*/);
+
+        boolean showOkCancelButtons = getResources().getBoolean(R.bool.config_showOkCancelButtons);
+
+        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);
+
+        RingtoneListHandler.Config soundListConfig = getSoundListConfig(pickerType, intent,
+                ringtoneType);
+        RingtoneListHandler.Config vibrationListConfig = getVibrationListConfig(pickerType, intent);
+
+        RingtonePickerViewModel.Config pickerConfig =
+                new RingtonePickerViewModel.Config(title, pickerUserId, ringtoneType,
+                        showOkCancelButtons, mAttributesFlags, pickerType);
+
+        mRingtonePickerViewModel.init(pickerConfig, soundListConfig, vibrationListConfig);
+
+        if (savedInstanceState == null) {
+            TabbedDialogFragment dialogFragment = new TabbedDialogFragment();
+
+            FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
+            Fragment prev = getSupportFragmentManager().findFragmentByTag(TabbedDialogFragment.TAG);
+            if (prev != null) {
+                ft.remove(prev);
+            }
+            ft.addToBackStack(null);
+            dialogFragment.show(ft, TabbedDialogFragment.TAG);
+        }
+
+        // The volume keys will control the stream that we are choosing a ringtone for
+        setVolumeControlStream(mRingtonePickerViewModel.getRingtoneStreamType());
+    }
+
+    private RingtoneListHandler.Config getSoundListConfig(
+            RingtonePickerViewModel.PickerType pickerType, Intent intent, int ringtoneType) {
+        if (pickerType != RingtonePickerViewModel.PickerType.SOUND_PICKER
+                && pickerType != RingtonePickerViewModel.PickerType.RINGTONE_PICKER) {
+            // This ringtone picker does not require a sound picker.
+            return null;
+        }
+
+        // Get whether to show the 'Default' sound item, and the URI to play when it's clicked
+        boolean hasDefaultSoundItem =
+                intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true);
+
+        // The Uri to play when the 'Default' sound item is clicked.
+        Uri uriForDefaultSoundItem =
+                intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI);
+        if (uriForDefaultSoundItem == null) {
+            uriForDefaultSoundItem = RingtonePickerViewModel.getDefaultItemUriByType(ringtoneType);
+        }
+
+        // Get whether this list has the 'Silent' sound item.
+        boolean hasSilentSoundItem =
+                intent.getBooleanExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true);
+
+        // AudioAttributes flags
+        mAttributesFlags |= intent.getIntExtra(
+                RingtoneManager.EXTRA_RINGTONE_AUDIO_ATTRIBUTES_FLAGS,
+                0 /*defaultValue == no flags*/);
+
+        // Get the sound URI whose list item should have a checkmark
+        Uri existingSoundUri = intent
+                .getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI);
+
+        return new RingtoneListHandler.Config(hasDefaultSoundItem,
+                uriForDefaultSoundItem, hasSilentSoundItem, existingSoundUri);
+    }
+
+    private RingtoneListHandler.Config getVibrationListConfig(
+            RingtonePickerViewModel.PickerType pickerType, Intent intent) {
+        if (pickerType != RingtonePickerViewModel.PickerType.VIBRATION_PICKER
+                && pickerType != RingtonePickerViewModel.PickerType.RINGTONE_PICKER) {
+            // This ringtone picker does not require a vibration picker.
+            return null;
+        }
+
+        // Get whether to show the 'Default' vibration item, and the URI to play when it's clicked
+        boolean hasDefaultVibrationItem =
+                intent.getBooleanExtra(EXTRA_VIBRATION_SHOW_DEFAULT, false);
+
+        // The Uri to play when the 'Default' vibration item is clicked.
+        Uri uriForDefaultVibrationItem = intent.getParcelableExtra(EXTRA_VIBRATION_DEFAULT_URI);
+
+        // Get whether this list has the 'Silent' vibration item.
+        boolean hasSilentVibrationItem =
+                intent.getBooleanExtra(EXTRA_VIBRATION_SHOW_SILENT, true);
+
+        // Get the vibration URI whose list item should have a checkmark
+        Uri existingVibrationUri = intent.getParcelableExtra(EXTRA_VIBRATION_EXISTING_URI);
+
+        return new RingtoneListHandler.Config(
+                hasDefaultVibrationItem, uriForDefaultVibrationItem, hasSilentVibrationItem,
+                existingVibrationUri);
+    }
+
+    @Override
+    public void onDestroy() {
+        mRingtonePickerViewModel.cancelPendingAsyncTasks();
+        super.onDestroy();
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        mRingtonePickerViewModel.onStop(isChangingConfigurations());
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+        mRingtonePickerViewModel.onPause(isChangingConfigurations());
+    }
+
+    /**
+     * 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/SoundPicker2/src/com/android/soundpicker/RingtonePickerApplication.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerApplication.java
new file mode 100644
index 0000000..48fd4fe
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerApplication.java
@@ -0,0 +1,28 @@
+/*
+ * 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.Application;
+
+import dagger.hilt.android.HiltAndroidApp;
+
+/**
+ * The main application class for the project.
+ */
+@HiltAndroidApp(Application.class)
+public class RingtonePickerApplication extends Hilt_RingtonePickerApplication {
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerViewModel.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerViewModel.java
new file mode 100644
index 0000000..2c09711
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtonePickerViewModel.java
@@ -0,0 +1,340 @@
+/*
+ * 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 java.util.Objects.requireNonNull;
+
+import android.annotation.Nullable;
+import android.annotation.StringRes;
+import android.media.Ringtone;
+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;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+
+import dagger.hilt.android.lifecycle.HiltViewModel;
+
+import java.io.IOException;
+import java.util.concurrent.Executor;
+
+import javax.inject.Inject;
+
+/**
+ * A view model which holds immutable info about the picker state and means to retrieve and play
+ * currently selected ringtones.
+ */
+@HiltViewModel
+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.
+     */
+    @VisibleForTesting
+    static Ringtone sPlayingRingtone;
+
+    private static final String TAG = "RingtonePickerViewModel";
+
+    private final RingtoneManagerFactory mRingtoneManagerFactory;
+    private final RingtoneFactory mRingtoneFactory;
+    private final RingtoneListHandler mSoundListHandler;
+    private final RingtoneListHandler mVibrationListHandler;
+    private final ListeningExecutorService mListeningExecutorService;
+
+    private RingtoneManager mRingtoneManager;
+
+    /**
+     * The ringtone that's currently playing.
+     */
+    private Ringtone mCurrentRingtone;
+
+    private Config mPickerConfig;
+
+    private ListenableFuture<Uri> mAddCustomRingtoneFuture;
+
+    public enum PickerType {
+        RINGTONE_PICKER,
+        SOUND_PICKER,
+        VIBRATION_PICKER
+    }
+
+    /**
+     * Holds immutable info on the picker that should be displayed.
+     */
+    static final class Config {
+        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;
+        /**
+         * AudioAttributes flags.
+         */
+        public final int audioAttributesFlags;
+        /**
+         * In the buttonless (watch-only) version we don't show the OK/Cancel buttons.
+         */
+        public final boolean showOkCancelButtons;
+
+        public final PickerType mPickerType;
+
+        Config(String title, int userId, int ringtoneType, boolean showOkCancelButtons,
+                int audioAttributesFlags, PickerType pickerType) {
+            this.title = title;
+            this.userId = userId;
+            this.ringtoneType = ringtoneType;
+            this.showOkCancelButtons = showOkCancelButtons;
+            this.audioAttributesFlags = audioAttributesFlags;
+            this.mPickerType = pickerType;
+        }
+    }
+
+    @Inject
+    RingtonePickerViewModel(RingtoneManagerFactory ringtoneManagerFactory,
+            RingtoneFactory ringtoneFactory,
+            ListeningExecutorServiceFactory listeningExecutorServiceFactory,
+            RingtoneListHandler soundListHandler,
+            RingtoneListHandler vibrationListHandler) {
+        mRingtoneManagerFactory = ringtoneManagerFactory;
+        mRingtoneFactory = ringtoneFactory;
+        mListeningExecutorService = listeningExecutorServiceFactory.createSingleThreadExecutor();
+        mSoundListHandler = soundListHandler;
+        mVibrationListHandler = vibrationListHandler;
+    }
+
+    @StringRes
+    static int getTitleByType(int ringtoneType) {
+        switch (ringtoneType) {
+            case RingtoneManager.TYPE_ALARM:
+                return com.android.internal.R.string.ringtone_picker_title_alarm;
+            case RingtoneManager.TYPE_NOTIFICATION:
+                return com.android.internal.R.string.ringtone_picker_title_notification;
+            default:
+                return com.android.internal.R.string.ringtone_picker_title;
+        }
+    }
+
+    static Uri getDefaultItemUriByType(int ringtoneType) {
+        switch (ringtoneType) {
+            case RingtoneManager.TYPE_ALARM:
+                return Settings.System.DEFAULT_ALARM_ALERT_URI;
+            case RingtoneManager.TYPE_NOTIFICATION:
+                return Settings.System.DEFAULT_NOTIFICATION_URI;
+            default:
+                return Settings.System.DEFAULT_RINGTONE_URI;
+        }
+    }
+
+    @StringRes
+    static int getAddNewItemTextByType(int ringtoneType) {
+        switch (ringtoneType) {
+            case RingtoneManager.TYPE_ALARM:
+                return R.string.add_alarm_text;
+            case RingtoneManager.TYPE_NOTIFICATION:
+                return R.string.add_notification_text;
+            default:
+                return R.string.add_ringtone_text;
+        }
+    }
+
+    @StringRes
+    static int getDefaultRingtoneItemTextByType(int ringtoneType) {
+        switch (ringtoneType) {
+            case RingtoneManager.TYPE_ALARM:
+                return R.string.alarm_sound_default;
+            case RingtoneManager.TYPE_NOTIFICATION:
+                return R.string.notification_sound_default;
+            default:
+                return R.string.ringtone_default;
+        }
+    }
+
+    void init(@NonNull Config pickerConfig,
+            RingtoneListHandler.Config soundListConfig,
+            RingtoneListHandler.Config vibrationListConfig) {
+        mRingtoneManager = mRingtoneManagerFactory.create();
+        mPickerConfig = pickerConfig;
+        if (mPickerConfig.ringtoneType != RINGTONE_TYPE_UNKNOWN) {
+            mRingtoneManager.setType(mPickerConfig.ringtoneType);
+        }
+        if (soundListConfig != null) {
+            mSoundListHandler.init(soundListConfig, mRingtoneManager,
+                    mRingtoneManager.getCursor());
+        }
+        if (vibrationListConfig != null) {
+            // TODO: Switch to the vibration cursor, once the API is made available.
+            mVibrationListHandler.init(vibrationListConfig, mRingtoneManager,
+                    mRingtoneManager.getCursor());
+        }
+    }
+
+    /**
+     * Re-initializes the view model which is required after updating any of the picker lists.
+     * This could happen when adding a custom ringtone.
+     */
+    void reinit() {
+        init(mPickerConfig, mSoundListHandler.getRingtoneListConfig(),
+                mVibrationListHandler.getRingtoneListConfig());
+    }
+
+    @NonNull
+    Config getPickerConfig() {
+        requireInitCalled();
+        return mPickerConfig;
+    }
+
+    @NonNull
+    RingtoneListHandler getSoundListHandler() {
+        return mSoundListHandler;
+    }
+
+    @NonNull
+    RingtoneListHandler getVibrationListHandler() {
+        return mVibrationListHandler;
+    }
+
+    /**
+     * Combined the currently selected sound and vibration URIs and returns a unified URI. If the
+     * picker does not show either sound or vibration, that portion of the URI will be null.
+     *
+     * Currently only the sound URI is returned, since we don't have the API to retrieve vibrations
+     * yet.
+     * @return Combined sound and vibration URI.
+     */
+    Uri getSelectedRingtoneUri() {
+        // TODO: Combine sound and vibration URIs before returning.
+        return mSoundListHandler.getSelectedRingtoneUri();
+    }
+
+    int getRingtoneStreamType() {
+        requireInitCalled();
+        return mRingtoneManager.inferStreamType();
+    }
+
+    void onPause(boolean isChangingConfigurations) {
+        if (!isChangingConfigurations) {
+            stopAnyPlayingRingtone();
+        }
+    }
+
+    void onStop(boolean isChangingConfigurations) {
+        if (isChangingConfigurations) {
+            saveAnyPlayingRingtone();
+        } else {
+            stopAnyPlayingRingtone();
+        }
+    }
+
+    /**
+     * Plays a ringtone which is created using the currently selected sound and vibration URIs. If
+     * this is a sound or vibration only picker, then the other portion of the URI will be empty
+     * and should not affect the played ringtone.
+     *
+     * Currently, we only use the sound URI to create the ringtone, since we still don't have the
+     * API to retrieve the available vibrations list.
+     */
+    void playRingtone() {
+        requireInitCalled();
+        stopAnyPlayingRingtone();
+
+        mCurrentRingtone = mRingtoneFactory.create(getSelectedRingtoneUri(),
+                mPickerConfig.audioAttributesFlags);
+
+        if (mCurrentRingtone != null) {
+            mCurrentRingtone.play();
+        }
+    }
+
+    /**
+     * Cancels all pending async tasks.
+     */
+    void cancelPendingAsyncTasks() {
+        if (mAddCustomRingtoneFuture != null && !mAddCustomRingtoneFuture.isDone()) {
+            mAddCustomRingtoneFuture.cancel(/* mayInterruptIfRunning= */ true);
+        }
+    }
+
+    /**
+     * Adds an audio file to the list of ringtones asynchronously.
+     * Any previous async tasks are canceled before start the new one.
+     *
+     * @param uri      Uri of the file to be added as ringtone. Must be a media file.
+     * @param type     The type of the ringtone to be added.
+     * @param callback The callback to invoke when the task is completed.
+     * @param executor The executor to run the callback on when the task completes.
+     */
+    void addSoundRingtoneAsync(Uri uri, int type, FutureCallback<Uri> callback, Executor executor) {
+        // Cancel any currently running add ringtone tasks before starting a new one
+        cancelPendingAsyncTasks();
+        mAddCustomRingtoneFuture = mListeningExecutorService.submit(
+                () -> addRingtone(uri, type));
+        Futures.addCallback(mAddCustomRingtoneFuture, callback, executor);
+    }
+
+    /**
+     * Adds an audio file to the list of ringtones.
+     *
+     * @param uri  Uri of the file to be added as ringtone. Must be a media file.
+     * @param type The type of the ringtone to be added.
+     * @return The Uri of the installed ringtone, which may be the {@code uri} if it
+     * is already in ringtone storage. Or null if it failed to add the audio file.
+     */
+    @Nullable
+    private Uri addRingtone(Uri uri, int type) throws IOException {
+        requireInitCalled();
+        return mRingtoneManager.addCustomExternalRingtone(uri, type);
+    }
+
+    private void saveAnyPlayingRingtone() {
+        if (mCurrentRingtone != null && mCurrentRingtone.isPlaying()) {
+            sPlayingRingtone = mCurrentRingtone;
+        }
+        mCurrentRingtone = null;
+    }
+
+    private void stopAnyPlayingRingtone() {
+        if (sPlayingRingtone != null && sPlayingRingtone.isPlaying()) {
+            sPlayingRingtone.stop();
+        }
+        sPlayingRingtone = null;
+
+        if (mCurrentRingtone != null && mCurrentRingtone.isPlaying()) {
+            mCurrentRingtone.stop();
+        }
+        mCurrentRingtone = null;
+    }
+
+    private void requireInitCalled() {
+        requireNonNull(mRingtoneManager);
+        requireNonNull(mPickerConfig);
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/RingtoneReceiver.java b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneReceiver.java
new file mode 100644
index 0000000..6a34936
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/RingtoneReceiver.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2007 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.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class RingtoneReceiver extends BroadcastReceiver {
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        final String action = intent.getAction();
+        if (Intent.ACTION_DEVICE_CUSTOMIZATION_READY.equals(action)) {
+            initResourceRingtones(context);
+        }
+    }
+
+    private void initResourceRingtones(Context context) {
+        context.startService(
+                new Intent(context, RingtoneOverlayService.class));
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/SoundPickerFragment.java b/packages/SoundPicker2/src/com/android/soundpicker/SoundPickerFragment.java
new file mode 100644
index 0000000..a37191f
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/SoundPickerFragment.java
@@ -0,0 +1,122 @@
+/*
+ * 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.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+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.core.content.ContextCompat;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.google.common.util.concurrent.FutureCallback;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A fragment that displays a picker used to select sound or silent. It also includes the
+ * ability to add custom sounds.
+ */
+public class SoundPickerFragment extends BasePickerFragment {
+
+    private static final String TAG = "SoundPickerFragment";
+
+    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.addSoundRingtoneAsync(data.getData(),
+                                mPickerConfig.ringtoneType,
+                                mAddCustomRingtoneCallback,
+                                // Causes the callback to be executed on the main thread.
+                                ContextCompat.getMainExecutor(
+                                        requireActivity().getApplicationContext()));
+                    }
+                }
+            });
+
+    @Override
+    public void onViewCreated(@NotNull View view, Bundle savedInstanceState) {
+        mRingtonePickerViewModel = new ViewModelProvider(requireActivity()).get(
+                RingtonePickerViewModel.class);
+        super.onViewCreated(view, savedInstanceState);
+    }
+
+    @Override
+    protected RingtoneListHandler getRingtoneListHandler() {
+        return mRingtonePickerViewModel.getSoundListHandler();
+    }
+
+    @Override
+    protected void addRingtoneAsync() {
+        // 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);
+    }
+
+    @Override
+    protected void addNewRingtoneItem() {
+        // If external storage is available, add a button to install sounds from storage.
+        if (resolvesMediaFilePicker()
+                && Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
+            mRingtoneListViewAdapter.addTitleForAddRingtoneItem(
+                    RingtonePickerViewModel.getAddNewItemTextByType(mPickerConfig.ringtoneType));
+        }
+    }
+
+    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;
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/TabbedDialogFragment.java b/packages/SoundPicker2/src/com/android/soundpicker/TabbedDialogFragment.java
new file mode 100644
index 0000000..50ea9d7
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/TabbedDialogFragment.java
@@ -0,0 +1,180 @@
+/*
+ * 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.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;
+
+/**
+ * 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;
+
+    private final ViewPager2.OnPageChangeCallback mOnPageChangeCallback =
+            new ViewPager2.OnPageChangeCallback() {
+                @Override
+                public void onPageScrollStateChanged(int state) {
+                    super.onPageScrollStateChanged(state);
+                    if (state == ViewPager2.SCROLL_STATE_IDLE) {
+                        mRingtonePickerViewModel.onStop(/* isChangingConfigurations= */ false);
+                    }
+                }
+            };
+
+    @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) -> {
+                                setSuccessResultWithSelectedRingtone();
+                                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 setSuccessResultWithSelectedRingtone() {
+        requireActivity().setResult(Activity.RESULT_OK,
+                new Intent().putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI,
+                        mRingtonePickerViewModel.getSelectedRingtoneUri()));
+    }
+
+    /**
+     * 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.requireViewById(R.id.tabLayout);
+        ViewPager2 viewPager = view.requireViewById(R.id.masterViewPager);
+
+        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);
+        viewPager.registerOnPageChangeCallback(mOnPageChangeCallback);
+        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/SoundPicker2/src/com/android/soundpicker/VibrationPickerFragment.java b/packages/SoundPicker2/src/com/android/soundpicker/VibrationPickerFragment.java
new file mode 100644
index 0000000..7412c19
--- /dev/null
+++ b/packages/SoundPicker2/src/com/android/soundpicker/VibrationPickerFragment.java
@@ -0,0 +1,52 @@
+/*
+ * 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.os.Bundle;
+import android.view.View;
+
+import androidx.lifecycle.ViewModelProvider;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A fragment that displays a picker used to select vibration or silent (no vibration).
+ */
+public class VibrationPickerFragment extends BasePickerFragment {
+
+    @Override
+    public void onViewCreated(@NotNull View view, Bundle savedInstanceState) {
+        mRingtonePickerViewModel = new ViewModelProvider(requireActivity()).get(
+                RingtonePickerViewModel.class);
+        super.onViewCreated(view, savedInstanceState);
+    }
+
+    @Override
+    protected RingtoneListHandler getRingtoneListHandler() {
+        return mRingtonePickerViewModel.getVibrationListHandler();
+    }
+
+    @Override
+    protected void addRingtoneAsync() {
+        // no-op
+    }
+
+    @Override
+    protected void addNewRingtoneItem() {
+        // no-op
+    }
+}
diff --git a/packages/SoundPicker2/src/com/android/soundpicker/ViewPagerAdapter.java b/packages/SoundPicker2/src/com/android/soundpicker/ViewPagerAdapter.java
new file mode 100644
index 0000000..179068e
--- /dev/null
+++ b/packages/SoundPicker2/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/SoundPicker2/tests/Android.bp b/packages/SoundPicker2/tests/Android.bp
new file mode 100644
index 0000000..d88d442
--- /dev/null
+++ b/packages/SoundPicker2/tests/Android.bp
@@ -0,0 +1,38 @@
+// Copyright 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 {
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_test {
+    name: "SoundPicker2Tests",
+    certificate: "platform",
+    libs: [
+        "android.test.runner",
+        "android.test.base",
+    ],
+    static_libs: [
+        "androidx.test.core",
+        "androidx.test.rules",
+        "androidx.test.ext.junit",
+        "androidx.test.ext.truth",
+        "mockito-target-minus-junit4",
+        "guava-android-testlib",
+        "SoundPicker2Lib",
+    ],
+    srcs: [
+        "src/**/*.java",
+    ],
+}
diff --git a/packages/SoundPicker2/tests/AndroidManifest.xml b/packages/SoundPicker2/tests/AndroidManifest.xml
new file mode 100644
index 0000000..295aeb1
--- /dev/null
+++ b/packages/SoundPicker2/tests/AndroidManifest.xml
@@ -0,0 +1,11 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+        package="com.android.soundpicker.tests">
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="com.android.soundpicker.tests"
+        android:label="Sound picker tests">
+    </instrumentation>
+</manifest>
diff --git a/packages/SoundPicker2/tests/src/com/android/soundpicker/RingtoneListHandlerTest.java b/packages/SoundPicker2/tests/src/com/android/soundpicker/RingtoneListHandlerTest.java
new file mode 100644
index 0000000..80e71e200
--- /dev/null
+++ b/packages/SoundPicker2/tests/src/com/android/soundpicker/RingtoneListHandlerTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import android.database.Cursor;
+import android.media.RingtoneManager;
+import android.net.Uri;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class RingtoneListHandlerTest {
+
+    private static final Uri DEFAULT_URI = Uri.parse("media://custom/ringtone/default_uri");
+    private static final Uri RINGTONE_URI = Uri.parse("media://custom/ringtone/uri");
+    private static final int SILENT_RINGTONE_POSITION = 0;
+    private static final int DEFAULT_RINGTONE_POSITION = 1;
+    private static final int RINGTONE_POSITION = 2;
+
+    @Mock
+    private RingtoneManager mMockRingtoneManager;
+    @Mock
+    private Cursor mMockCursor;
+
+    private RingtoneListHandler mRingtoneListHandler;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        RingtoneListHandler.Config mRingtoneListConfig = createRingtoneListConfig();
+
+        mRingtoneListHandler = new RingtoneListHandler();
+
+        // Add silent and default options to the list.
+        mRingtoneListHandler.addSilentItem();
+        mRingtoneListHandler.addDefaultItem();
+
+        mRingtoneListHandler.init(mRingtoneListConfig, mMockRingtoneManager, mMockCursor);
+    }
+
+    @Test
+    public void testGetRingtoneCursor_returnsTheCorrectRingtoneCursor() {
+        assertThat(mRingtoneListHandler.getRingtoneCursor()).isEqualTo(mMockCursor);
+    }
+
+    @Test
+    public void testGetRingtoneUri_returnsTheCorrectRingtoneUri() {
+        Uri expectedUri = RINGTONE_URI;
+        when(mMockRingtoneManager.getRingtoneUri(eq(0))).thenReturn(expectedUri);
+
+        // Request 3rd item from list.
+        Uri actualUri = mRingtoneListHandler.getRingtoneUri(RINGTONE_POSITION);
+        assertThat(actualUri).isEqualTo(expectedUri);
+    }
+
+    @Test
+    public void testGetRingtoneUri_withSelectedItemUnknown_returnsTheCorrectRingtoneUri() {
+        Uri uri = mRingtoneListHandler.getRingtoneUri(RingtoneListHandler.ITEM_POSITION_UNKNOWN);
+        assertThat(uri).isEqualTo(RingtoneListHandler.SILENT_URI);
+    }
+
+    @Test
+    public void testGetRingtoneUri_withSelectedItemDefaultPosition_returnsTheCorrectRingtoneUri() {
+        Uri actualUri = mRingtoneListHandler.getRingtoneUri(DEFAULT_RINGTONE_POSITION);
+        assertThat(actualUri).isEqualTo(DEFAULT_URI);
+    }
+
+    @Test
+    public void testGetRingtoneUri_withSelectedItemSilentPosition_returnsTheCorrectRingtoneUri() {
+        Uri uri = mRingtoneListHandler.getRingtoneUri(SILENT_RINGTONE_POSITION);
+        assertThat(uri).isEqualTo(RingtoneListHandler.SILENT_URI);
+    }
+
+    @Test
+    public void testGetCurrentlySelectedRingtoneUri_returnsTheCorrectRingtoneUri() {
+        mRingtoneListHandler.setSelectedItemPosition(RingtoneListHandler.ITEM_POSITION_UNKNOWN);
+        Uri actualUri = mRingtoneListHandler.getSelectedRingtoneUri();
+        assertThat(actualUri).isEqualTo(RingtoneListHandler.SILENT_URI);
+
+        mRingtoneListHandler.setSelectedItemPosition(DEFAULT_RINGTONE_POSITION);
+        actualUri = mRingtoneListHandler.getSelectedRingtoneUri();
+        assertThat(actualUri).isEqualTo(DEFAULT_URI);
+
+        mRingtoneListHandler.setSelectedItemPosition(SILENT_RINGTONE_POSITION);
+        actualUri = mRingtoneListHandler.getSelectedRingtoneUri();
+        assertThat(actualUri).isEqualTo(RingtoneListHandler.SILENT_URI);
+
+        when(mMockRingtoneManager.getRingtoneUri(eq(0))).thenReturn(RINGTONE_URI);
+        mRingtoneListHandler.setSelectedItemPosition(RINGTONE_POSITION);
+        actualUri = mRingtoneListHandler.getSelectedRingtoneUri();
+        assertThat(actualUri).isEqualTo(RINGTONE_URI);
+    }
+
+    @Test
+    public void testGetRingtonePosition_returnsTheCorrectRingtonePosition() {
+        when(mMockRingtoneManager.getRingtonePosition(RINGTONE_URI)).thenReturn(0);
+
+        int actualPosition = mRingtoneListHandler.getRingtonePosition(RINGTONE_URI);
+
+        assertThat(actualPosition).isEqualTo(RINGTONE_POSITION);
+
+    }
+
+    @Test
+    public void testFixedItems_onlyAddsItemsOnceAndInOrder() {
+        // Clear fixed items before testing the add methods.
+        mRingtoneListHandler.resetFixedItems();
+
+        assertThat(mRingtoneListHandler.getSilentItemPosition()).isEqualTo(
+                RingtoneListHandler.ITEM_POSITION_UNKNOWN);
+        assertThat(mRingtoneListHandler.getDefaultItemPosition()).isEqualTo(
+                RingtoneListHandler.ITEM_POSITION_UNKNOWN);
+
+        mRingtoneListHandler.addSilentItem();
+        mRingtoneListHandler.addDefaultItem();
+        mRingtoneListHandler.addSilentItem();
+        mRingtoneListHandler.addDefaultItem();
+
+        assertThat(mRingtoneListHandler.getSilentItemPosition()).isEqualTo(
+                SILENT_RINGTONE_POSITION);
+        assertThat(mRingtoneListHandler.getDefaultItemPosition()).isEqualTo(
+                DEFAULT_RINGTONE_POSITION);
+    }
+
+    @Test
+    public void testResetFixedItems_resetsSilentAndDefaultItemPositions() {
+        assertThat(mRingtoneListHandler.getSilentItemPosition()).isEqualTo(
+                SILENT_RINGTONE_POSITION);
+        assertThat(mRingtoneListHandler.getDefaultItemPosition()).isEqualTo(
+                DEFAULT_RINGTONE_POSITION);
+
+        mRingtoneListHandler.resetFixedItems();
+
+        assertThat(mRingtoneListHandler.getSilentItemPosition()).isEqualTo(
+                RingtoneListHandler.ITEM_POSITION_UNKNOWN);
+        assertThat(mRingtoneListHandler.getDefaultItemPosition()).isEqualTo(
+                RingtoneListHandler.ITEM_POSITION_UNKNOWN);
+    }
+
+    private RingtoneListHandler.Config createRingtoneListConfig() {
+        return new RingtoneListHandler.Config(/* hasDefaultItem= */ true,
+                /* uriForDefaultItem= */ DEFAULT_URI, /* hasSilentItem= */ true,
+                /* existingUri= */ DEFAULT_URI);
+    }
+}
diff --git a/packages/SoundPicker2/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java b/packages/SoundPicker2/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java
new file mode 100644
index 0000000..cde6c76
--- /dev/null
+++ b/packages/SoundPicker2/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java
@@ -0,0 +1,534 @@
+/*
+ * 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 junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertNull;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.database.Cursor;
+import android.media.AudioAttributes;
+import android.media.AudioManager;
+import android.media.Ringtone;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.provider.Settings;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.testing.TestingExecutors;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutorService;
+
+@RunWith(AndroidJUnit4.class)
+public class RingtonePickerViewModelTest {
+
+    private static final Uri DEFAULT_URI = Uri.parse("media://custom/ringtone/default_uri");
+    private static final Uri RINGTONE_URI = Uri.parse("media://custom/ringtone/uri");
+    private static final int RINGTONE_TYPE_UNKNOWN = -1;
+    private static final int DEFAULT_RINGTONE_POSITION = 1;
+
+    @Mock
+    private RingtoneManagerFactory mMockRingtoneManagerFactory;
+    @Mock
+    private RingtoneFactory mMockRingtoneFactory;
+    @Mock
+    private RingtoneManager mMockRingtoneManager;
+    @Mock
+    private ListeningExecutorServiceFactory mMockListeningExecutorServiceFactory;
+    @Mock
+    private Cursor mMockCursor;
+
+    private RingtoneListHandler mSoundListHandler;
+    private RingtoneListHandler mVibrationListHandler;
+    private ExecutorService mMainThreadExecutor;
+    private ListeningExecutorService mBackgroundThreadExecutor;
+    private Ringtone mMockDefaultRingtone;
+    private Ringtone mMockRingtone;
+    private RingtonePickerViewModel mViewModel;
+    private RingtoneListHandler.Config mSoundListConfig;
+    private RingtoneListHandler.Config mVibrationListConfig;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mSoundListHandler = new RingtoneListHandler();
+        mVibrationListHandler = new RingtoneListHandler();
+        mSoundListConfig = createRingtoneListConfig();
+        mVibrationListConfig = createRingtoneListConfig();
+        mMockDefaultRingtone = createMockRingtone();
+        mMockRingtone = createMockRingtone();
+        when(mMockRingtoneManagerFactory.create()).thenReturn(mMockRingtoneManager);
+        when(mMockRingtoneFactory.create(DEFAULT_URI,
+                AudioAttributes.FLAG_AUDIBILITY_ENFORCED)).thenReturn(mMockDefaultRingtone);
+        when(mMockRingtoneManager.getRingtoneUri(anyInt())).thenReturn(RINGTONE_URI);
+        when(mMockRingtoneManager.getCursor()).thenReturn(mMockCursor);
+        mMainThreadExecutor = TestingExecutors.sameThreadScheduledExecutor();
+        mBackgroundThreadExecutor = TestingExecutors.sameThreadScheduledExecutor();
+        when(mMockListeningExecutorServiceFactory.createSingleThreadExecutor()).thenReturn(
+                mBackgroundThreadExecutor);
+
+        mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory,
+                mMockListeningExecutorServiceFactory, mSoundListHandler,
+                mVibrationListHandler);
+
+        // Add silent and default options to the sound list.
+        mSoundListHandler.addSilentItem();
+        mSoundListHandler.addDefaultItem();
+
+        // Add silent and default options to the vibration list.
+        mVibrationListHandler.addSilentItem();
+        mVibrationListHandler.addDefaultItem();
+
+        mSoundListHandler.setSelectedItemPosition(DEFAULT_RINGTONE_POSITION);
+        mVibrationListHandler.setSelectedItemPosition(DEFAULT_RINGTONE_POSITION);
+    }
+
+    @After
+    public void teardown() {
+        if (mMainThreadExecutor != null && !mMainThreadExecutor.isShutdown()) {
+            mMainThreadExecutor.shutdown();
+        }
+        if (mBackgroundThreadExecutor != null && !mBackgroundThreadExecutor.isShutdown()) {
+            mBackgroundThreadExecutor.shutdown();
+        }
+    }
+
+    @Test
+    public void testInitRingtoneManager_whenTypeIsUnknown_createManagerButDoNotSetType() {
+        mViewModel.init(createPickerConfig(RINGTONE_TYPE_UNKNOWN), mSoundListConfig,
+                mVibrationListConfig);
+
+        verify(mMockRingtoneManagerFactory).create();
+        verify(mMockRingtoneManager, never()).setType(anyInt());
+        assertNotNull(mViewModel.getSoundListHandler().getRingtoneListConfig());
+        assertNotNull(mViewModel.getVibrationListHandler().getRingtoneListConfig());
+    }
+
+    @Test
+    public void testInitRingtoneManager_whenTypeIsNotUnknown_createManagerAndSetType() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_NOTIFICATION), mSoundListConfig,
+                mVibrationListConfig);
+
+        verify(mMockRingtoneManagerFactory).create();
+        verify(mMockRingtoneManager).setType(RingtoneManager.TYPE_NOTIFICATION);
+        assertNotNull(mViewModel.getSoundListHandler().getRingtoneListConfig());
+        assertNotNull(mViewModel.getVibrationListHandler().getRingtoneListConfig());
+    }
+
+    @Test
+    public void testInitRingtoneManager_bothListConfigsAreNull_onlyRecreateRingtoneManager() {
+        mViewModel.init(
+                createPickerConfig(RingtoneManager.TYPE_NOTIFICATION),
+                /* soundListConfig= */ null, /* vibrationListConfig= */ null);
+
+        verify(mMockRingtoneManagerFactory).create();
+        verify(mMockRingtoneManager).setType(RingtoneManager.TYPE_NOTIFICATION);
+        assertNull(mViewModel.getSoundListHandler().getRingtoneListConfig());
+        assertNull(mViewModel.getVibrationListHandler().getRingtoneListConfig());
+    }
+
+    @Test
+    public void testReinitialize_bothListConfigsInitialized_recreateManagerAndReinitHandlers() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_NOTIFICATION), mSoundListConfig,
+                mVibrationListConfig);
+        mViewModel.reinit();
+
+        verify(mMockRingtoneManagerFactory, times(2)).create();
+        verify(mMockRingtoneManager, times(2)).setType(RingtoneManager.TYPE_NOTIFICATION);
+        assertNotNull(mViewModel.getSoundListHandler().getRingtoneListConfig());
+        assertNotNull(mViewModel.getVibrationListHandler().getRingtoneListConfig());
+    }
+
+    @Test
+    public void testReinitialize_bothListConfigsAlreadyNull_onlyRecreateRingtoneManager() {
+        mViewModel.init(
+                createPickerConfig(RingtoneManager.TYPE_NOTIFICATION),
+                /* soundListConfig= */ null, /* vibrationListConfig= */ null);
+        mViewModel.reinit();
+
+        verify(mMockRingtoneManagerFactory, times(2)).create();
+        verify(mMockRingtoneManager, times(2)).setType(RingtoneManager.TYPE_NOTIFICATION);
+        assertNull(mViewModel.getSoundListHandler().getRingtoneListConfig());
+        assertNull(mViewModel.getVibrationListHandler().getRingtoneListConfig());
+    }
+
+    @Test
+    public void testGetStreamType_returnsTheCorrectStreamType() {
+        when(mMockRingtoneManager.inferStreamType()).thenReturn(AudioManager.STREAM_ALARM);
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        assertEquals(mViewModel.getRingtoneStreamType(), AudioManager.STREAM_ALARM);
+    }
+
+    @Test
+    public void testOnPause_withChangingConfigurationTrue_doNotStopPlayingRingtone() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone);
+        mViewModel.onPause(/* isChangingConfigurations= */ true);
+        verify(mMockDefaultRingtone, never()).stop();
+    }
+
+    @Test
+    public void testOnPause_withChangingConfigurationFalse_stopPlayingRingtone() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone);
+        mViewModel.onPause(/* isChangingConfigurations= */ false);
+        verify(mMockDefaultRingtone).stop();
+    }
+
+    @Test
+    public void testOnViewModelRecreated_previousRingtoneCanStillBeStopped() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        Ringtone mockRingtone1 = createMockRingtone();
+        Ringtone mockRingtone2 = createMockRingtone();
+
+        when(mMockRingtoneFactory.create(any(), anyInt())).thenReturn(mockRingtone1, mockRingtone2);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mockRingtone1);
+        // Fake a scenario where the activity is destroyed and recreated due to a config change.
+        // This will result in a new view model getting created.
+        mViewModel.onStop(/* isChangingConfigurations= */ true);
+        verify(mockRingtone1, never()).stop();
+        mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory,
+                mMockListeningExecutorServiceFactory, mSoundListHandler,
+                mVibrationListHandler);
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mockRingtone2);
+        verify(mockRingtone1).stop();
+        verify(mockRingtone2, never()).stop();
+    }
+
+    @Test
+    public void testOnStop_withChangingConfigurationTrueAndDefaultRingtonePlaying_saveRingtone() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone);
+        mViewModel.onStop(/* isChangingConfigurations= */ true);
+        assertEquals(RingtonePickerViewModel.sPlayingRingtone, mMockDefaultRingtone);
+    }
+
+    @Test
+    public void testOnStop_withChangingConfigurationTrueAndCurrentRingtonePlaying_saveRingtone() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone);
+        mViewModel.onStop(/* isChangingConfigurations= */ true);
+        assertEquals(RingtonePickerViewModel.sPlayingRingtone, mMockDefaultRingtone);
+    }
+
+    @Test
+    public void testOnStop_withChangingConfigurationTrueAndNoPlayingRingtone_saveNothing() {
+        mViewModel.onStop(/* isChangingConfigurations= */ true);
+        assertNull(RingtonePickerViewModel.sPlayingRingtone);
+    }
+
+    @Test
+    public void testOnStop_withChangingConfigurationFalse_stopPlayingRingtone() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone);
+        mViewModel.onStop(/* isChangingConfigurations= */ false);
+        verify(mMockDefaultRingtone).stop();
+    }
+
+    @Test
+    public void testGetCurrentlySelectedRingtoneUri_returnsTheCorrectRingtoneUri() {
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+
+        assertEquals(DEFAULT_URI, mViewModel.getSelectedRingtoneUri());
+    }
+
+    @Test
+    public void testPlayRingtone_playTheCorrectRingtone() {
+        mSoundListHandler.setSelectedItemPosition(DEFAULT_RINGTONE_POSITION);
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone);
+    }
+
+    @Test
+    public void testPlayRingtone_stopsPreviouslyRunningRingtone() {
+        // Start playing the first ringtone
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone);
+        // Start playing the second ringtone
+        when(mMockRingtoneFactory.create(DEFAULT_URI,
+                AudioAttributes.FLAG_AUDIBILITY_ENFORCED)).thenReturn(mMockRingtone);
+        mViewModel.playRingtone();
+        verifyRingtonePlayCalledAndMockPlayingState(mMockRingtone);
+
+        verify(mMockDefaultRingtone).stop();
+    }
+
+    @Test
+    public void testDefaultItemUri_withNotificationIntent_returnDefaultNotificationUri() {
+        Uri uri = RingtonePickerViewModel.getDefaultItemUriByType(
+                RingtoneManager.TYPE_NOTIFICATION);
+        assertEquals(Settings.System.DEFAULT_NOTIFICATION_URI, uri);
+    }
+
+    @Test
+    public void testDefaultItemUri_withAlarmIntent_returnDefaultAlarmUri() {
+        Uri uri = RingtonePickerViewModel.getDefaultItemUriByType(RingtoneManager.TYPE_ALARM);
+        assertEquals(Settings.System.DEFAULT_ALARM_ALERT_URI, uri);
+    }
+
+    @Test
+    public void testDefaultItemUri_withRingtoneIntent_returnDefaultRingtoneUri() {
+        Uri uri = RingtonePickerViewModel.getDefaultItemUriByType(RingtoneManager.TYPE_RINGTONE);
+        assertEquals(Settings.System.DEFAULT_RINGTONE_URI, uri);
+    }
+
+    @Test
+    public void testDefaultItemUri_withInvalidRingtoneType_returnDefaultRingtoneUri() {
+        Uri uri = RingtonePickerViewModel.getDefaultItemUriByType(-1);
+        assertEquals(Settings.System.DEFAULT_RINGTONE_URI, uri);
+    }
+
+    @Test
+    public void testTitle_withNotificationRingtoneType_returnRingtoneNotificationTitle() {
+        int title = RingtonePickerViewModel.getTitleByType(RingtoneManager.TYPE_NOTIFICATION);
+        assertEquals(com.android.internal.R.string.ringtone_picker_title_notification, title);
+    }
+
+    @Test
+    public void testTitle_withAlarmRingtoneType_returnRingtoneAlarmTitle() {
+        int title = RingtonePickerViewModel.getTitleByType(RingtoneManager.TYPE_ALARM);
+        assertEquals(com.android.internal.R.string.ringtone_picker_title_alarm, title);
+    }
+
+    @Test
+    public void testTitle_withInvalidRingtoneType_returnDefaultRingtoneTitle() {
+        int title = RingtonePickerViewModel.getTitleByType(/*ringtoneType= */ -1);
+        assertEquals(com.android.internal.R.string.ringtone_picker_title, title);
+    }
+
+    @Test
+    public void testAddNewItemText_withAlarmType_returnAlarmAddItemText() {
+        int addNewItemTextResId = RingtonePickerViewModel.getAddNewItemTextByType(
+                RingtoneManager.TYPE_ALARM);
+        assertEquals(R.string.add_alarm_text, addNewItemTextResId);
+    }
+
+    @Test
+    public void testAddNewItemText_withNotificationType_returnNotificationAddItemText() {
+        int addNewItemTextResId = RingtonePickerViewModel.getAddNewItemTextByType(
+                RingtoneManager.TYPE_NOTIFICATION);
+        assertEquals(R.string.add_notification_text, addNewItemTextResId);
+    }
+
+    @Test
+    public void testAddNewItemText_withRingtoneType_returnRingtoneAddItemText() {
+        int addNewItemTextResId = RingtonePickerViewModel.getAddNewItemTextByType(
+                RingtoneManager.TYPE_RINGTONE);
+        assertEquals(R.string.add_ringtone_text, addNewItemTextResId);
+    }
+
+    @Test
+    public void testAddNewItemText_withInvalidType_returnRingtoneAddItemText() {
+        int addNewItemTextResId = RingtonePickerViewModel.getAddNewItemTextByType(-1);
+        assertEquals(R.string.add_ringtone_text, addNewItemTextResId);
+    }
+
+    @Test
+    public void testDefaultItemText_withNotificationType_returnNotificationDefaultItemText() {
+        int defaultRingtoneItemText = RingtonePickerViewModel.getDefaultRingtoneItemTextByType(
+                RingtoneManager.TYPE_NOTIFICATION);
+        assertEquals(R.string.notification_sound_default, defaultRingtoneItemText);
+    }
+
+    @Test
+    public void testDefaultItemText_withAlarmType_returnAlarmDefaultItemText() {
+        int defaultRingtoneItemText = RingtonePickerViewModel.getDefaultRingtoneItemTextByType(
+                RingtoneManager.TYPE_NOTIFICATION);
+        assertEquals(R.string.notification_sound_default, defaultRingtoneItemText);
+    }
+
+    @Test
+    public void testDefaultItemText_withRingtoneType_returnRingtoneDefaultItemText() {
+        int defaultRingtoneItemText = RingtonePickerViewModel.getDefaultRingtoneItemTextByType(
+                RingtoneManager.TYPE_RINGTONE);
+        assertEquals(R.string.ringtone_default, defaultRingtoneItemText);
+    }
+
+    @Test
+    public void testDefaultItemText_withInvalidType_returnRingtoneDefaultItemText() {
+        int defaultRingtoneItemText = RingtonePickerViewModel.getDefaultRingtoneItemTextByType(-1);
+        assertEquals(R.string.ringtone_default, defaultRingtoneItemText);
+    }
+
+    @Test
+    public void testCancelPendingAsyncTasks_correctlyCancelsPendingTasks()
+            throws IOException {
+        FutureCallback<Uri> mockCallback = mock(FutureCallback.class);
+
+        when(mMockListeningExecutorServiceFactory.createSingleThreadExecutor()).thenReturn(
+                TestingExecutors.noOpScheduledExecutor());
+
+        mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory,
+                mMockListeningExecutorServiceFactory, mSoundListHandler,
+                mVibrationListHandler);
+        mViewModel.addSoundRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION,
+                mockCallback, mMainThreadExecutor);
+        verify(mockCallback, never()).onFailure(any());
+        // Calling cancelPendingAsyncTasks should cancel the pending task. Cancelling an async
+        // task invokes the onFailure method in the callable.
+        mViewModel.cancelPendingAsyncTasks();
+        verify(mockCallback).onFailure(any());
+        verify(mockCallback, never()).onSuccess(any());
+
+    }
+
+    @Test
+    public void testAddRingtoneAsync_cancelPreviousTaskBeforeStartingNewOne()
+            throws IOException {
+        FutureCallback<Uri> mockCallback1 = mock(FutureCallback.class);
+        FutureCallback<Uri> mockCallback2 = mock(FutureCallback.class);
+
+        when(mMockListeningExecutorServiceFactory.createSingleThreadExecutor()).thenReturn(
+                TestingExecutors.noOpScheduledExecutor());
+
+        mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory,
+                mMockListeningExecutorServiceFactory, mSoundListHandler,
+                mVibrationListHandler);
+        mViewModel.addSoundRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION,
+                mockCallback1, mMainThreadExecutor);
+        verify(mockCallback1, never()).onFailure(any());
+        // We call addRingtoneAsync again to cancel the previous task and start a new one.
+        // Cancelling an async task invokes the onFailure method in the callable.
+        mViewModel.addSoundRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION,
+                mockCallback2, mMainThreadExecutor);
+        verify(mockCallback1).onFailure(any());
+        verify(mockCallback1, never()).onSuccess(any());
+        verifyNoMoreInteractions(mockCallback2);
+    }
+
+    @Test
+    public void testAddRingtoneAsync_whenAddRingtoneIsSuccessful_successCallbackIsInvoked()
+            throws IOException {
+        Uri expectedUri = DEFAULT_URI;
+        FutureCallback<Uri> mockCallback = mock(FutureCallback.class);
+
+        when(mMockRingtoneManager.addCustomExternalRingtone(DEFAULT_URI,
+                RingtoneManager.TYPE_NOTIFICATION)).thenReturn(expectedUri);
+
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+
+        mViewModel.addSoundRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION,
+                mockCallback, mMainThreadExecutor);
+
+        verify(mockCallback).onSuccess(expectedUri);
+        verify(mockCallback, never()).onFailure(any());
+    }
+
+    @Test
+    public void testAddRingtoneAsync_whenAddRingtoneFailed_failureCallbackIsInvoked()
+            throws IOException {
+        FutureCallback<Uri> mockCallback = mock(FutureCallback.class);
+
+        when(mMockRingtoneManager.addCustomExternalRingtone(any(), anyInt())).thenThrow(
+                IOException.class);
+
+        mViewModel.init(createPickerConfig(RingtoneManager.TYPE_RINGTONE), mSoundListConfig,
+                mVibrationListConfig);
+
+        mViewModel.addSoundRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION,
+                mockCallback, mMainThreadExecutor);
+
+        verify(mockCallback).onFailure(any(IOException.class));
+        verify(mockCallback, never()).onSuccess(any());
+    }
+
+    private Ringtone createMockRingtone() {
+        Ringtone mockRingtone = mock(Ringtone.class);
+        when(mockRingtone.getAudioAttributes()).thenReturn(
+                audioAttributes(AudioAttributes.USAGE_NOTIFICATION_RINGTONE, 0));
+
+        return mockRingtone;
+    }
+
+    private void verifyRingtonePlayCalledAndMockPlayingState(Ringtone ringtone) {
+        verify(ringtone).play();
+        when(ringtone.isPlaying()).thenReturn(true);
+    }
+
+    private static AudioAttributes audioAttributes(int audioUsage, int flags) {
+        return new AudioAttributes.Builder()
+                .setUsage(audioUsage)
+                .setFlags(flags)
+                .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+                .build();
+    }
+
+    private RingtonePickerViewModel.Config createPickerConfig(int ringtoneType,
+            int audioAttributes) {
+        return new RingtonePickerViewModel.Config("Phone ringtone", /* userId= */ 1,
+                ringtoneType, /* showOkCancelButtons= */ true,
+                audioAttributes, RingtonePickerViewModel.PickerType.RINGTONE_PICKER);
+    }
+
+    private RingtonePickerViewModel.Config createPickerConfig(int ringtoneType) {
+        return new RingtonePickerViewModel.Config("Phone ringtone", /* userId= */ 1,
+                ringtoneType, /* showOkCancelButtons= */ true,
+                AudioAttributes.FLAG_AUDIBILITY_ENFORCED,
+                RingtonePickerViewModel.PickerType.RINGTONE_PICKER);
+    }
+
+    private RingtoneListHandler.Config createRingtoneListConfig() {
+        return new RingtoneListHandler.Config(/* hasDefaultItem= */ true,
+                /* uriForDefaultItem= */ DEFAULT_URI, /* hasSilentItem= */ true,
+                /* existingUri= */ Uri.parse(""));
+    }
+}