diff options
11 files changed, 402 insertions, 128 deletions
diff --git a/packages/SoundPicker/Android.bp b/packages/SoundPicker/Android.bp index a33b2bee78d9..c8999fbcd271 100644 --- a/packages/SoundPicker/Android.bp +++ b/packages/SoundPicker/Android.bp @@ -17,6 +17,8 @@ android_library { ], static_libs: [ "androidx.appcompat_appcompat", + "hilt_android", + "guava", ], } diff --git a/packages/SoundPicker/AndroidManifest.xml b/packages/SoundPicker/AndroidManifest.xml index cdfe2421fdb7..1f99e75ebc88 100644 --- a/packages/SoundPicker/AndroidManifest.xml +++ b/packages/SoundPicker/AndroidManifest.xml @@ -12,8 +12,10 @@ <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"> @@ -25,7 +27,7 @@ <service android:name="RingtoneOverlayService" /> <activity android:name="RingtonePickerActivity" - android:theme="@style/PickerDialogTheme" + android:theme="@style/Theme.AppCompat.Dialog" android:enabled="@*android:bool/config_defaultRingtonePickerEnabled" android:excludeFromRecents="true" android:exported="true"> diff --git a/packages/SoundPicker/res/layout/activity_ringtone_picker.xml b/packages/SoundPicker/res/layout/activity_ringtone_picker.xml new file mode 100644 index 000000000000..4eecf89bb481 --- /dev/null +++ b/packages/SoundPicker/res/layout/activity_ringtone_picker.xml @@ -0,0 +1,20 @@ +<?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:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" />
\ No newline at end of file diff --git a/packages/SoundPicker/src/com/android/soundpicker/ListeningExecutorServiceFactory.java b/packages/SoundPicker/src/com/android/soundpicker/ListeningExecutorServiceFactory.java new file mode 100644 index 000000000000..afdbf053ac22 --- /dev/null +++ b/packages/SoundPicker/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/SoundPicker/src/com/android/soundpicker/RingtoneFactory.java b/packages/SoundPicker/src/com/android/soundpicker/RingtoneFactory.java index 6ee7c357461d..0a8a73b27f26 100644 --- a/packages/SoundPicker/src/com/android/soundpicker/RingtoneFactory.java +++ b/packages/SoundPicker/src/com/android/soundpicker/RingtoneFactory.java @@ -21,15 +21,22 @@ 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; - RingtoneFactory(Context context) { - mApplicationContext = context.getApplicationContext(); + @Inject + RingtoneFactory(@ApplicationContext Context applicationContext) { + mApplicationContext = applicationContext; } /** diff --git a/packages/SoundPicker/src/com/android/soundpicker/RingtoneManagerFactory.java b/packages/SoundPicker/src/com/android/soundpicker/RingtoneManagerFactory.java index a7da506581bb..f08eb24ec20d 100644 --- a/packages/SoundPicker/src/com/android/soundpicker/RingtoneManagerFactory.java +++ b/packages/SoundPicker/src/com/android/soundpicker/RingtoneManagerFactory.java @@ -19,15 +19,22 @@ 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; - RingtoneManagerFactory(Context context) { - mApplicationContext = context.getApplicationContext(); + @Inject + RingtoneManagerFactory(@ApplicationContext Context applicationContext) { + mApplicationContext = applicationContext; } /** diff --git a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java index 359bd0db4292..f591aa54a50e 100644 --- a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java +++ b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerActivity.java @@ -26,7 +26,6 @@ import android.database.Cursor; import android.database.CursorWrapper; import android.media.RingtoneManager; import android.net.Uri; -import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; @@ -45,8 +44,15 @@ import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; -import com.android.internal.app.AlertActivity; -import com.android.internal.app.AlertController; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; + +import com.google.common.util.concurrent.FutureCallback; + +import dagger.hilt.android.AndroidEntryPoint; import java.util.regex.Pattern; @@ -56,9 +62,10 @@ import java.util.regex.Pattern; * * @see RingtoneManager#ACTION_RINGTONE_PICKER */ -public final class RingtonePickerActivity extends AlertActivity implements +@AndroidEntryPoint(AppCompatActivity.class) +public final class RingtonePickerActivity extends Hilt_RingtonePickerActivity implements AdapterView.OnItemSelectedListener, Runnable, DialogInterface.OnClickListener, - AlertController.AlertParams.OnPrepareListViewListener { + DialogInterface.OnDismissListener { private static final int POS_UNKNOWN = -1; @@ -106,6 +113,10 @@ public final class RingtonePickerActivity extends AlertActivity implements private boolean mShowOkCancelButtons; + private AlertDialog mAlertDialog; + + private int mCheckedItem = POS_UNKNOWN; + private final DialogInterface.OnClickListener mRingtoneClickListener = new DialogInterface.OnClickListener() { @@ -137,12 +148,28 @@ public final class RingtonePickerActivity extends AlertActivity implements } }; + private final FutureCallback<Uri> mAddCustomRingtoneCallback = new FutureCallback<>() { + @Override + public void onSuccess(Uri ringtoneUri) { + requeryForAdapter(); + } + + @Override + public void onFailure(Throwable throwable) { + Log.e(TAG, "Failed to add custom ringtone.", throwable); + // Ringtone was not added, display error Toast + Toast.makeText(RingtonePickerActivity.this.getApplicationContext(), + R.string.unable_to_add_ringtone, Toast.LENGTH_SHORT).show(); + } + }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - mRingtonePickerViewModel = new RingtonePickerViewModel( - new RingtoneManagerFactory(this), new RingtoneFactory(this)); + setContentView(R.layout.activity_ringtone_picker); + + mRingtonePickerViewModel = new ViewModelProvider(this).get(RingtonePickerViewModel.class); + mHandler = new Handler(); Intent intent = getIntent(); @@ -151,7 +178,7 @@ public final class RingtonePickerActivity extends AlertActivity implements // Get the types of ringtones to show mType = intent.getIntExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtonePickerViewModel.RINGTONE_TYPE_UNKNOWN); - mRingtonePickerViewModel.setRingtoneType(mType); + mRingtonePickerViewModel.initRingtoneManager(mType); setupCursor(); /* @@ -183,36 +210,34 @@ public final class RingtonePickerActivity extends AlertActivity implements // Create the list of ringtones and hold on to it so we can update later. mAdapter = new BadgedRingtoneAdapter(this, mCursor, /* isManagedProfile = */ UserManager.get(this).isManagedProfile(mPickerUserId)); - if (savedInstanceState != null) { - setCheckedItem(savedInstanceState.getInt(SAVE_CLICKED_POS, POS_UNKNOWN)); - } - final AlertController.AlertParams p = mAlertParams; - p.mAdapter = mAdapter; - p.mOnClickListener = mRingtoneClickListener; - p.mLabelColumn = COLUMN_LABEL; - p.mIsSingleChoice = true; - p.mOnItemSelectedListener = this; + AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(this, + android.R.style.ThemeOverlay_Material_Dialog); + alertDialogBuilder + .setSingleChoiceItems(mAdapter, getCheckedItem(), mRingtoneClickListener) + .setOnItemSelectedListener(this) + .setOnDismissListener(this); if (mShowOkCancelButtons) { - p.mPositiveButtonText = getString(com.android.internal.R.string.ok); - p.mPositiveButtonListener = this; - p.mNegativeButtonText = getString(com.android.internal.R.string.cancel); - p.mPositiveButtonListener = this; - } - p.mOnPrepareListViewListener = this; - p.mTitle = intent.getCharSequenceExtra(RingtoneManager.EXTRA_RINGTONE_TITLE); - if (p.mTitle == null) { - p.mTitle = getString(RingtonePickerViewModel.getTitleByType(mType)); + alertDialogBuilder + .setPositiveButton(getString(com.android.internal.R.string.ok), this) + .setNegativeButton(getString(com.android.internal.R.string.cancel), this); } - setupAlert(); + String title = intent.getStringExtra(RingtoneManager.EXTRA_RINGTONE_TITLE); + alertDialogBuilder.setTitle( + title != null ? title : getString(RingtonePickerViewModel.getTitleByType(mType))); - ListView listView = mAlert.getListView(); + mAlertDialog = alertDialogBuilder.show(); + ListView listView = mAlertDialog.getListView(); if (listView != null) { // List view needs to gain focus in order for RSB to work. if (!listView.requestFocus()) { Log.e(TAG, "Unable to gain focus! RSB may not work properly."); } + prepareListView(listView); + } + if (savedInstanceState != null) { + setCheckedItem(savedInstanceState.getInt(SAVE_CLICKED_POS, POS_UNKNOWN)); } } @Override @@ -226,66 +251,27 @@ public final class RingtonePickerActivity extends AlertActivity implements super.onActivityResult(requestCode, resultCode, data); if (requestCode == ADD_FILE_REQUEST_CODE && resultCode == RESULT_OK) { - // Add the custom ringtone in a separate thread - final AsyncTask<Uri, Void, Uri> installTask = new AsyncTask<Uri, Void, Uri>() { - @Override - protected Uri doInBackground(Uri... params) { - return mRingtonePickerViewModel.addRingtone(params[0], mType); - } - - @Override - protected void onPostExecute(Uri ringtoneUri) { - if (ringtoneUri != null) { - requeryForAdapter(); - } else { - // Ringtone was not added, display error Toast - Toast.makeText(RingtonePickerActivity.this, R.string.unable_to_add_ringtone, - Toast.LENGTH_SHORT).show(); - } - } - }; - installTask.execute(data.getData()); + mRingtonePickerViewModel.addRingtoneAsync(data.getData(), + mType, + mAddCustomRingtoneCallback, + // Causes the callback to be executed on the main thread. + ContextCompat.getMainExecutor(this.getApplicationContext())); } } - // Disabled because context menus aren't Material Design :( - /* @Override - public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { - int position = ((AdapterContextMenuInfo) menuInfo).position; - - Ringtone ringtone = getRingtone(getRingtoneManagerPosition(position)); - if (ringtone != null && mRingtoneManager.isCustomRingtone(ringtone.getUri())) { - // It's a custom ringtone so we display the context menu - menu.setHeaderTitle(ringtone.getTitle(this)); - menu.add(Menu.NONE, Menu.FIRST, Menu.NONE, R.string.delete_ringtone_text); + public void onDismiss(DialogInterface dialog) { + if (!isChangingConfigurations()) { + finish(); } } @Override - public boolean onContextItemSelected(MenuItem item) { - switch (item.getItemId()) { - case Menu.FIRST: { - int deletedRingtonePos = ((AdapterContextMenuInfo) item.getMenuInfo()).position; - Uri deletedRingtoneUri = getRingtone( - getRingtoneManagerPosition(deletedRingtonePos)).getUri(); - if(mRingtoneManager.deleteExternalRingtone(deletedRingtoneUri)) { - requeryForAdapter(); - } else { - Toast.makeText(this, R.string.unable_to_delete_ringtone, Toast.LENGTH_SHORT) - .show(); - } - return true; - } - default: { - return false; - } - } - } - */ - - @Override public void onDestroy() { + mRingtonePickerViewModel.cancelPendingAsyncTasks(); + if (mAlertDialog != null && mAlertDialog.isShowing()) { + mAlertDialog.dismiss(); + } if (mHandler != null) { mHandler.removeCallbacksAndMessages(null); } @@ -296,7 +282,7 @@ public final class RingtonePickerActivity extends AlertActivity implements super.onDestroy(); } - public void onPrepareListView(ListView listView) { + private void prepareListView(@NonNull ListView listView) { // Reset the static item count, as this method can be called multiple times mRingtonePickerViewModel.resetFixedItemCount(); @@ -363,7 +349,6 @@ public final class RingtonePickerActivity extends AlertActivity implements checkedPosition = mRingtonePickerViewModel.getSilentItemPosition(); } setCheckedItem(checkedPosition); - setupAlert(); } /** @@ -374,7 +359,7 @@ public final class RingtonePickerActivity extends AlertActivity implements * @param textResId The resource ID of the text for the item. * @return The position of the inserted item. */ - private int addStaticItem(ListView listView, int textResId) { + private int addStaticItem(@NonNull ListView listView, int textResId) { TextView textView = (TextView) getLayoutInflater().inflate( com.android.internal.R.layout.select_dialog_singlechoice_material, listView, false); textView.setText(textResId); @@ -383,20 +368,20 @@ public final class RingtonePickerActivity extends AlertActivity implements return listView.getHeaderViewsCount() - 1; } - private int addDefaultRingtoneItem(ListView listView) { + private int addDefaultRingtoneItem(@NonNull ListView listView) { int defaultRingtoneItemPos = addStaticItem(listView, RingtonePickerViewModel.getDefaultRingtoneItemTextByType(mType)); mRingtonePickerViewModel.setDefaultItemPosition(defaultRingtoneItemPos); return defaultRingtoneItemPos; } - private int addSilentItem(ListView listView) { + private int addSilentItem(@NonNull ListView listView) { int silentItemPos = addStaticItem(listView, com.android.internal.R.string.ringtone_silent); mRingtonePickerViewModel.setSilentItemPosition(silentItemPos); return silentItemPos; } - private void addNewSoundItem(ListView listView) { + private void addNewSoundItem(@NonNull ListView listView) { View view = getLayoutInflater().inflate(R.layout.add_new_sound_item, listView, false /* attachToRoot */); TextView text = (TextView)view.findViewById(R.id.add_new_sound_text); @@ -412,11 +397,16 @@ public final class RingtonePickerActivity extends AlertActivity implements } private int getCheckedItem() { - return mAlertParams.mCheckedItem; + return mCheckedItem; } private void setCheckedItem(int pos) { - mAlertParams.mCheckedItem = pos; + mCheckedItem = pos; + ListView listView = mAlertDialog.getListView(); + if (listView != null) { + listView.setItemChecked(pos, true); + listView.smoothScrollToPosition(pos); + } mCheckedItemId = mAdapter.getItemId( mRingtonePickerViewModel.itemPositionToRingtonePosition(pos)); } @@ -491,7 +481,6 @@ public final class RingtonePickerActivity extends AlertActivity implements new Intent().putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, ringtoneUri)); } - private int getListPosition(int ringtoneManagerPos) { // If the manager position is -1 (for not found), return that diff --git a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerApplication.java b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerApplication.java new file mode 100644 index 000000000000..48fd4fe2f15e --- /dev/null +++ b/packages/SoundPicker/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/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java index 164964277a4a..f045dc2f864c 100644 --- a/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java +++ b/packages/SoundPicker/src/com/android/soundpicker/RingtonePickerViewModel.java @@ -16,6 +16,8 @@ package com.android.soundpicker; +import static java.util.Objects.requireNonNull; + import android.annotation.Nullable; import android.annotation.StringRes; import android.database.Cursor; @@ -24,16 +26,28 @@ import android.media.Ringtone; import android.media.RingtoneManager; import android.net.Uri; import android.provider.Settings; -import android.util.Log; + +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; /** * View model for {@link RingtonePickerActivity}. */ -public final class RingtonePickerViewModel { +@HiltViewModel +public final class RingtonePickerViewModel extends ViewModel { static final int RINGTONE_TYPE_UNKNOWN = -1; /** @@ -43,10 +57,14 @@ public final class RingtonePickerViewModel { @VisibleForTesting static Ringtone sPlayingRingtone; private static final String TAG = "RingtonePickerViewModel"; + private static final String RINGTONE_MANAGER_NULL_MESSAGE = + "RingtoneManager must not be null. Did you forget to call " + + "RingtonePickerViewModel#initRingtoneManager?"; private static final int ITEM_POSITION_UNKNOWN = -1; private final RingtoneManagerFactory mRingtoneManagerFactory; private final RingtoneFactory mRingtoneFactory; + private final ListeningExecutorService mListeningExecutorService; /** The position in the list of the 'Silent' item. */ private int mSilentItemPosition = ITEM_POSITION_UNKNOWN; @@ -56,7 +74,7 @@ public final class RingtonePickerViewModel { private int mDefaultItemPosition = ITEM_POSITION_UNKNOWN; /** The number of static items in the list. */ private int mFixedItemCount; - + private ListenableFuture<Uri> mAddCustomRingtoneFuture; private RingtoneManager mRingtoneManager; /** @@ -64,11 +82,13 @@ public final class RingtonePickerViewModel { */ private Ringtone mCurrentRingtone; + @Inject RingtonePickerViewModel(RingtoneManagerFactory ringtoneManagerFactory, - RingtoneFactory ringtoneFactory) { + RingtoneFactory ringtoneFactory, + ListeningExecutorServiceFactory listeningExecutorServiceFactory) { mRingtoneManagerFactory = ringtoneManagerFactory; mRingtoneFactory = ringtoneFactory; - initRingtoneManager(RINGTONE_TYPE_UNKNOWN); + mListeningExecutorService = listeningExecutorServiceFactory.createSingleThreadExecutor(); } @StringRes @@ -120,28 +140,53 @@ public final class RingtonePickerViewModel { void initRingtoneManager(int type) { mRingtoneManager = mRingtoneManagerFactory.create(); - setRingtoneType(type); - } - - void setRingtoneType(int type) { if (type != RINGTONE_TYPE_UNKNOWN) { mRingtoneManager.setType(type); } } + /** + * 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 addRingtoneAsync(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); + } + + /** + * Cancels all pending async tasks. + */ + void cancelPendingAsyncTasks() { + if (mAddCustomRingtoneFuture != null && !mAddCustomRingtoneFuture.isDone()) { + mAddCustomRingtoneFuture.cancel(/*mayInterruptIfRunning=*/true); + } + } + int getRingtoneStreamType() { + requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); return mRingtoneManager.inferStreamType(); } Cursor getRingtoneCursor() { + requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); return mRingtoneManager.getCursor(); } Uri getRingtoneUri(int ringtonePosition) { + requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); return mRingtoneManager.getRingtoneUri(ringtonePosition); } int getRingtonePosition(Uri uri) { + requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); return mRingtoneManager.getRingtonePosition(uri); } @@ -218,24 +263,8 @@ public final class RingtonePickerViewModel { } } - /** - * 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 - Uri addRingtone(Uri uri, int type) { - try { - return mRingtoneManager.addCustomExternalRingtone(uri, type); - } catch (IOException | IllegalArgumentException e) { - Log.e(TAG, "Unable to add new ringtone", e); - } - return null; - } - void playRingtone(int position, Uri uriForDefaultItem, int attributesFlags) { + requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); stopAnyPlayingRingtone(); if (mSampleItemPosition == mSilentItemPosition) { return; @@ -263,6 +292,20 @@ public final class RingtonePickerViewModel { } } + /** + * 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 { + requireNonNull(mRingtoneManager, RINGTONE_MANAGER_NULL_MESSAGE); + return mRingtoneManager.addCustomExternalRingtone(uri, type); + } + private void saveAnyPlayingRingtone() { if (mCurrentRingtone != null && mCurrentRingtone.isPlaying()) { sPlayingRingtone = mCurrentRingtone; diff --git a/packages/SoundPicker/tests/Android.bp b/packages/SoundPicker/tests/Android.bp index d6aea48ece27..dcd7b98c0cb0 100644 --- a/packages/SoundPicker/tests/Android.bp +++ b/packages/SoundPicker/tests/Android.bp @@ -28,6 +28,7 @@ android_test { "androidx.test.rules", "androidx.test.ext.junit", "mockito-target-minus-junit4", + "guava-android-testlib", "SoundPickerLib", ], srcs: [ diff --git a/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java b/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java index 333a4e09627c..9ef3aa3b245f 100644 --- a/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java +++ b/packages/SoundPicker/tests/src/com/android/soundpicker/RingtonePickerViewModelTest.java @@ -24,6 +24,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import android.database.Cursor; @@ -36,6 +37,11 @@ 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; @@ -43,11 +49,13 @@ 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("https://www.google.com/login.html"); + private static final int RINGTONE_TYPE_UNKNOWN = -1; private static final int POS_UNKNOWN = -1; private static final int NO_ATTRIBUTES_FLAGS = 0; private static final int SILENT_RINGTONE_POSITION = 0; @@ -62,7 +70,11 @@ public class RingtonePickerViewModelTest { private RingtoneManager mMockRingtoneManager; @Mock private Cursor mMockCursor; + @Mock + private ListeningExecutorServiceFactory mMockListeningExecutorServiceFactory; + private ExecutorService mMainThreadExecutor; + private ListeningExecutorService mBackgroundThreadExecutor; private Ringtone mMockDefaultRingtone; private Ringtone mMockRingtone; private RingtonePickerViewModel mViewModel; @@ -76,23 +88,55 @@ public class RingtonePickerViewModelTest { mMockRingtone = createMockRingtone(); when(mMockRingtoneFactory.create(DEFAULT_URI)).thenReturn(mMockDefaultRingtone); when(mMockRingtoneManager.getRingtone(anyInt())).thenReturn(mMockRingtone); + mMainThreadExecutor = TestingExecutors.sameThreadScheduledExecutor(); + mBackgroundThreadExecutor = TestingExecutors.sameThreadScheduledExecutor(); + when(mMockListeningExecutorServiceFactory.createSingleThreadExecutor()).thenReturn( + mBackgroundThreadExecutor); - mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory); - + mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory, + mMockListeningExecutorServiceFactory); mViewModel.setSilentItemPosition(SILENT_RINGTONE_POSITION); mViewModel.setDefaultItemPosition(DEFAULT_RINGTONE_POSITION); mViewModel.setSampleItemPosition(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.initRingtoneManager(RINGTONE_TYPE_UNKNOWN); + + verify(mMockRingtoneManagerFactory).create(); + verify(mMockRingtoneManager, never()).setType(anyInt()); + } + + @Test + public void testInitRingtoneManager_whenTypeIsNotUnknown_createManagerAndSetType() { + mViewModel.initRingtoneManager(RingtoneManager.TYPE_NOTIFICATION); + + verify(mMockRingtoneManagerFactory).create(); + verify(mMockRingtoneManager).setType(RingtoneManager.TYPE_NOTIFICATION); + } + @Test public void testGetStreamType_returnsTheCorrectStreamType() { when(mMockRingtoneManager.inferStreamType()).thenReturn(AudioManager.STREAM_ALARM); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); assertEquals(mViewModel.getRingtoneStreamType(), AudioManager.STREAM_ALARM); } @Test public void testGetRingtoneCursor_returnsTheCorrectRingtoneCursor() { when(mMockRingtoneManager.getCursor()).thenReturn(mMockCursor); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); assertEquals(mViewModel.getRingtoneCursor(), mMockCursor); } @@ -100,12 +144,14 @@ public class RingtonePickerViewModelTest { public void testGetRingtoneUri_returnsTheCorrectRingtoneUri() { Uri expectedUri = DEFAULT_URI; when(mMockRingtoneManager.getRingtoneUri(anyInt())).thenReturn(expectedUri); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); Uri actualUri = mViewModel.getRingtoneUri(DEFAULT_RINGTONE_POSITION); assertEquals(actualUri, expectedUri); } @Test public void testOnPause_withChangingConfigurationTrue_doNotStopPlayingRingtone() { + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); verifyRingtonePlayCalledAndMockPlayingState(mMockRingtone); @@ -115,6 +161,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnPause_withChangingConfigurationFalse_stopPlayingRingtone() { + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -125,6 +172,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnViewModelRecreated_previousRingtoneCanStillBeStopped() { + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.setSampleItemPosition(RINGTONE_POSITION); Ringtone mockRingtone1 = createMockRingtone(); Ringtone mockRingtone2 = createMockRingtone(); @@ -136,7 +184,9 @@ public class RingtonePickerViewModelTest { // This will result in a new view model getting created. mViewModel.onStop(/* isChangingConfigurations= */ true); verify(mockRingtone1, never()).stop(); - mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory); + mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory, + mMockListeningExecutorServiceFactory); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.setSampleItemPosition(RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -147,6 +197,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnStop_withChangingConfigurationTrueAndDefaultRingtonePlaying_saveRingtone() { + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -157,6 +208,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnStop_withChangingConfigurationTrueAndCurrentRingtonePlaying_saveRingtone() { + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.setSampleItemPosition(RINGTONE_POSITION); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -174,6 +226,7 @@ public class RingtonePickerViewModelTest { @Test public void testOnStop_withChangingConfigurationFalse_stopPlayingRingtone() { + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -203,20 +256,93 @@ public class RingtonePickerViewModelTest { } @Test - public void testAddRingtone_returnsTheCorrectUri() throws IOException { + public void testCancelPendingAsyncTasks_correctlyCancelsPendingTasks() + throws IOException { + FutureCallback<Uri> mockCallback = mock(FutureCallback.class); + + when(mMockListeningExecutorServiceFactory.createSingleThreadExecutor()).thenReturn( + TestingExecutors.noOpScheduledExecutor()); + when(mMockRingtoneManager.addCustomExternalRingtone(DEFAULT_URI, + RingtoneManager.TYPE_NOTIFICATION)).thenReturn(DEFAULT_URI); + mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory, + mMockListeningExecutorServiceFactory); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.addRingtoneAsync(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()); + when(mMockRingtoneManager.addCustomExternalRingtone(DEFAULT_URI, + RingtoneManager.TYPE_NOTIFICATION)).thenReturn(DEFAULT_URI); + mViewModel = new RingtonePickerViewModel(mMockRingtoneManagerFactory, mMockRingtoneFactory, + mMockListeningExecutorServiceFactory); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.addRingtoneAsync(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.addRingtoneAsync(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; - when(mMockRingtoneManager.addCustomExternalRingtone(any(), anyInt())).thenReturn( - expectedUri); - Uri actualUri = mViewModel.addRingtone(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION); + FutureCallback<Uri> mockCallback = mock(FutureCallback.class); + + when(mMockRingtoneManager.addCustomExternalRingtone(DEFAULT_URI, + RingtoneManager.TYPE_NOTIFICATION)).thenReturn(expectedUri); + + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.addRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION, mockCallback, + mMainThreadExecutor); + verify(mMockRingtoneManager).addCustomExternalRingtone(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION); - assertEquals(actualUri, expectedUri); + 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.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); + mViewModel.addRingtoneAsync(DEFAULT_URI, RingtoneManager.TYPE_NOTIFICATION, mockCallback, + mMainThreadExecutor); + + verify(mockCallback).onFailure(any(IOException.class)); + verify(mockCallback, never()).onSuccess(any()); } @Test public void testGetCurrentlySelectedRingtoneUri_checkedItemRingtonePos_returnsTheCorrectUri() { Uri expectedUri = DEFAULT_URI; when(mMockRingtoneManager.getRingtoneUri(RINGTONE_POSITION)).thenReturn(expectedUri); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); Uri actualUri = mViewModel.getCurrentlySelectedRingtoneUri(RINGTONE_POSITION, DEFAULT_URI); verify(mMockRingtoneManager).getRingtoneUri(RINGTONE_POSITION); @@ -227,6 +353,7 @@ public class RingtonePickerViewModelTest { public void testPlayRingtone_stopsPreviouslyRunningRingtone() { // Start playing the first ringtone mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone); @@ -242,6 +369,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_samplePosEqualToSilentPos_onlyStopPlayingRingtone() { mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.playRingtone(DEFAULT_RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); verifyRingtonePlayCalledAndMockPlayingState(mMockDefaultRingtone); @@ -260,6 +388,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_samplePosEqualToDefaultPos_playDefaultRingtone() { mViewModel.setSampleItemPosition(DEFAULT_RINGTONE_POSITION); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); when(mMockRingtoneManager.inferStreamType()).thenReturn(AudioManager.STREAM_ALARM); @@ -274,6 +403,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_samplePosNotEqualToDefaultPos_playRingtone() { mViewModel.setSampleItemPosition(RINGTONE_POSITION); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, AudioAttributes.FLAG_AUDIBILITY_ENFORCED); @@ -287,6 +417,7 @@ public class RingtonePickerViewModelTest { @Test public void testPlayRingtone_withNoAttributeFlags_doNotUpdateRingtoneAttributesFlags() { mViewModel.setSampleItemPosition(RINGTONE_POSITION); + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); mViewModel.playRingtone(RINGTONE_POSITION, DEFAULT_URI, NO_ATTRIBUTES_FLAGS); @@ -299,7 +430,7 @@ public class RingtonePickerViewModelTest { public void testGetRingtonePosition_returnsTheCorrectRingtonePosition() { int expectedPosition = 1; when(mMockRingtoneManager.getRingtonePosition(any())).thenReturn(expectedPosition); - + mViewModel.initRingtoneManager(RingtoneManager.TYPE_RINGTONE); int actualPosition = mViewModel.getRingtonePosition(DEFAULT_URI); assertEquals(actualPosition, expectedPosition); |