/* * Copyright (C) 2020 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.deskclock.ringtone import android.app.Dialog import android.content.Context import android.content.ContentResolver import android.content.DialogInterface import android.content.Intent import android.media.AudioManager import android.media.RingtoneManager import android.net.Uri import android.os.AsyncTask import android.os.Bundle import android.provider.MediaStore import android.provider.OpenableColumns import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.View import androidx.annotation.Keep import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager.LoaderCallbacks import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.deskclock.BaseActivity import com.android.deskclock.DropShadowController import com.android.deskclock.LogUtils import com.android.deskclock.R import com.android.deskclock.RingtonePreviewKlaxon import com.android.deskclock.ItemAdapter import com.android.deskclock.ItemAdapter.ItemHolder import com.android.deskclock.ItemAdapter.ItemViewHolder import com.android.deskclock.ItemAdapter.OnItemClickedListener import com.android.deskclock.actionbarmenu.MenuItemControllerFactory import com.android.deskclock.actionbarmenu.NavUpMenuItemController import com.android.deskclock.actionbarmenu.OptionsMenuManager import com.android.deskclock.alarms.AlarmUpdateHandler import com.android.deskclock.data.DataModel import com.android.deskclock.provider.Alarm /** * This activity presents a set of ringtones from which the user may select one. The set includes: * * * system ringtones from the Android framework * * a ringtone representing pure silence * * a ringtone representing a default ringtone * * user-selected audio files available as ringtones * */ // TODO(b/165664115) Replace deprecated AsyncTask calls class RingtonePickerActivity : BaseActivity(), LoaderCallbacks>> { /** The controller that shows the drop shadow when content is not scrolled to the top. */ private var mDropShadowController: DropShadowController? = null /** Generates the items in the activity context menu. */ private lateinit var mOptionsMenuManager: OptionsMenuManager /** Displays a set of selectable ringtones. */ private lateinit var mRecyclerView: RecyclerView /** Stores the set of ItemHolders that wrap the selectable ringtones. */ private lateinit var mRingtoneAdapter: ItemAdapter> /** The title of the default ringtone. */ private var mDefaultRingtoneTitle: String? = null /** The uri of the default ringtone. */ private var mDefaultRingtoneUri: Uri? = null /** The uri of the ringtone to select after data is loaded. */ private var mSelectedRingtoneUri: Uri? = null /** `true` indicates the [.mSelectedRingtoneUri] must be played after data load. */ private var mIsPlaying = false /** Identifies the alarm to receive the selected ringtone; -1 indicates there is no alarm. */ private var mAlarmId: Long = -1 /** The location of the custom ringtone to be removed. */ private var mIndexOfRingtoneToRemove: Int = RecyclerView.NO_POSITION override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.ringtone_picker) setVolumeControlStream(AudioManager.STREAM_ALARM) mOptionsMenuManager = OptionsMenuManager() mOptionsMenuManager.addMenuItemController(NavUpMenuItemController(this)) .addMenuItemController(*MenuItemControllerFactory.buildMenuItemControllers(this)) val context: Context = getApplicationContext() val intent: Intent = getIntent() if (savedInstanceState != null) { mIsPlaying = savedInstanceState.getBoolean(STATE_KEY_PLAYING) mSelectedRingtoneUri = savedInstanceState.getParcelable(EXTRA_RINGTONE_URI) } if (mSelectedRingtoneUri == null) { mSelectedRingtoneUri = intent.getParcelableExtra(EXTRA_RINGTONE_URI) } mAlarmId = intent.getLongExtra(EXTRA_ALARM_ID, -1) mDefaultRingtoneUri = intent.getParcelableExtra(EXTRA_DEFAULT_RINGTONE_URI) val defaultRingtoneTitleId = intent.getIntExtra(EXTRA_DEFAULT_RINGTONE_NAME, 0) mDefaultRingtoneTitle = context.getString(defaultRingtoneTitleId) val inflater: LayoutInflater = getLayoutInflater() val listener: OnItemClickedListener = ItemClickWatcher() val ringtoneFactory: ItemViewHolder.Factory = RingtoneViewHolder.Factory(inflater) val headerFactory: ItemViewHolder.Factory = HeaderViewHolder.Factory(inflater) val addNewFactory: ItemViewHolder.Factory = AddCustomRingtoneViewHolder.Factory(inflater) mRingtoneAdapter = ItemAdapter() mRingtoneAdapter .withViewTypes(headerFactory, null, HeaderViewHolder.VIEW_TYPE_ITEM_HEADER) .withViewTypes(addNewFactory, listener, AddCustomRingtoneViewHolder.VIEW_TYPE_ADD_NEW) .withViewTypes(ringtoneFactory, listener, RingtoneViewHolder.VIEW_TYPE_SYSTEM_SOUND) .withViewTypes(ringtoneFactory, listener, RingtoneViewHolder.VIEW_TYPE_CUSTOM_SOUND) mRecyclerView = findViewById(R.id.ringtone_content) as RecyclerView mRecyclerView.setLayoutManager(LinearLayoutManager(context)) mRecyclerView.setAdapter(mRingtoneAdapter) mRecyclerView.setItemAnimator(null) mRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (mIndexOfRingtoneToRemove != RecyclerView.NO_POSITION) { closeContextMenu() } } }) val titleResourceId = intent.getIntExtra(EXTRA_TITLE, 0) setTitle(context.getString(titleResourceId)) LoaderManager.getInstance(this).initLoader(0 /* id */, Bundle.EMPTY /* args */, this /* callback */) registerForContextMenu(mRecyclerView) } override fun onResume() { super.onResume() val dropShadow: View = findViewById(R.id.drop_shadow) mDropShadowController = DropShadowController(dropShadow, mRecyclerView) } override fun onPause() { mDropShadowController!!.stop() mDropShadowController = null mSelectedRingtoneUri?.let { if (mAlarmId != -1L) { val context: Context = getApplicationContext() val cr: ContentResolver = getContentResolver() // Start a background task to fetch the alarm whose ringtone must be updated. object : AsyncTask() { override fun doInBackground(vararg parameters: Void?): Alarm? { val alarm = Alarm.getAlarm(cr, mAlarmId) if (alarm != null) { alarm.alert = it } return alarm } override fun onPostExecute(alarm: Alarm) { // Update the default ringtone for future new alarms. DataModel.dataModel.defaultAlarmRingtoneUri = alarm.alert!! // Start a second background task to persist the updated alarm. AlarmUpdateHandler(context, mScrollHandler = null, mSnackbarAnchor = null) .asyncUpdateAlarm(alarm, popToast = false, minorUpdate = true) } }.execute() } else { DataModel.dataModel.timerRingtoneUri = it } } super.onPause() } override fun onStop() { if (!isChangingConfigurations()) { stopPlayingRingtone(selectedRingtoneHolder, false) } super.onStop() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(STATE_KEY_PLAYING, mIsPlaying) outState.putParcelable(EXTRA_RINGTONE_URI, mSelectedRingtoneUri) } override fun onCreateOptionsMenu(menu: Menu): Boolean { mOptionsMenuManager.onCreateOptionsMenu(menu) return true } override fun onPrepareOptionsMenu(menu: Menu): Boolean { mOptionsMenuManager.onPrepareOptionsMenu(menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { return mOptionsMenuManager.onOptionsItemSelected(item) || super.onOptionsItemSelected(item) } override fun onCreateLoader(id: Int, args: Bundle?): Loader>> { return RingtoneLoader(getApplicationContext(), mDefaultRingtoneUri!!, mDefaultRingtoneTitle!!) } override fun onLoadFinished( loader: Loader>>, itemHolders: List> ) { // Update the adapter with fresh data. mRingtoneAdapter.setItems(itemHolders) // Attempt to select the requested ringtone. val toSelect = getRingtoneHolder(mSelectedRingtoneUri) if (toSelect != null) { toSelect.isSelected = true mSelectedRingtoneUri = toSelect.uri toSelect.notifyItemChanged() // Start playing the ringtone if indicated. if (mIsPlaying) { startPlayingRingtone(toSelect) } } else { // Clear the selection since it does not exist in the data. RingtonePreviewKlaxon.stop(this) mSelectedRingtoneUri = null mIsPlaying = false } } override fun onLoaderReset(loader: Loader>>) { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (resultCode != RESULT_OK) { return } val uri = data?.data ?: return // Bail if the permission to read (playback) the audio at the uri was not granted. val flags = data.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION if (flags != Intent.FLAG_GRANT_READ_URI_PERMISSION) { return } // Start a task to fetch the display name of the audio content and add the custom ringtone. AddCustomRingtoneTask(uri).execute() } override fun onContextItemSelected(item: MenuItem): Boolean { // Find the ringtone to be removed. val items = mRingtoneAdapter.items val toRemove = items!![mIndexOfRingtoneToRemove] as RingtoneHolder mIndexOfRingtoneToRemove = RecyclerView.NO_POSITION // Launch the confirmation dialog. val manager: FragmentManager = supportFragmentManager val hasPermissions = toRemove.hasPermissions() ConfirmRemoveCustomRingtoneDialogFragment.show(manager, toRemove.uri, hasPermissions) return true } private fun getRingtoneHolder(uri: Uri?): RingtoneHolder? { for (itemHolder in mRingtoneAdapter.items!!) { if (itemHolder is RingtoneHolder) { if (itemHolder.uri == uri) { return itemHolder } } } return null } @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val selectedRingtoneHolder: RingtoneHolder? get() = getRingtoneHolder(mSelectedRingtoneUri) /** * The given `ringtone` will be selected as a side-effect of playing the ringtone. * * @param ringtone the ringtone to be played */ private fun startPlayingRingtone(ringtone: RingtoneHolder) { if (!ringtone.isPlaying && !ringtone.isSilent) { RingtonePreviewKlaxon.start(getApplicationContext(), ringtone.uri) ringtone.isPlaying = true mIsPlaying = true } if (!ringtone.isSelected) { ringtone.isSelected = true mSelectedRingtoneUri = ringtone.uri } ringtone.notifyItemChanged() } /** * @param ringtone the ringtone to stop playing * @param deselect `true` indicates the ringtone should also be deselected; * `false` indicates its selection state should remain unchanged */ private fun stopPlayingRingtone(ringtone: RingtoneHolder?, deselect: Boolean) { if (ringtone == null) { return } if (ringtone.isPlaying) { RingtonePreviewKlaxon.stop(this) ringtone.isPlaying = false mIsPlaying = false } if (deselect && ringtone.isSelected) { ringtone.isSelected = false mSelectedRingtoneUri = null } ringtone.notifyItemChanged() } /** * Proceeds with removing the custom ringtone with the given uri. * * @param toRemove identifies the custom ringtone to be removed */ private fun removeCustomRingtone(toRemove: Uri) { RemoveCustomRingtoneTask(toRemove).execute() } /** * This DialogFragment informs the user of the side-effects of removing a custom ringtone while * it is in use by alarms and/or timers and prompts them to confirm the removal. */ class ConfirmRemoveCustomRingtoneDialogFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val arguments = requireArguments() val toRemove = arguments.getParcelable(ARG_RINGTONE_URI_TO_REMOVE) val okListener = DialogInterface.OnClickListener { _, _ -> (activity as RingtonePickerActivity).removeCustomRingtone(toRemove!!) } return if (arguments.getBoolean(ARG_RINGTONE_HAS_PERMISSIONS)) { AlertDialog.Builder(requireActivity()) .setPositiveButton(R.string.remove_sound, okListener) .setNegativeButton(android.R.string.cancel, null /* listener */) .setMessage(R.string.confirm_remove_custom_ringtone) .create() } else { AlertDialog.Builder(requireActivity()) .setPositiveButton(R.string.remove_sound, okListener) .setMessage(R.string.custom_ringtone_lost_permissions) .create() } } companion object { private const val ARG_RINGTONE_URI_TO_REMOVE = "arg_ringtone_uri_to_remove" private const val ARG_RINGTONE_HAS_PERMISSIONS = "arg_ringtone_has_permissions" fun show(manager: FragmentManager, toRemove: Uri?, hasPermissions: Boolean) { if (manager.isDestroyed) { return } val args = Bundle() args.putParcelable(ARG_RINGTONE_URI_TO_REMOVE, toRemove) args.putBoolean(ARG_RINGTONE_HAS_PERMISSIONS, hasPermissions) val fragment: DialogFragment = ConfirmRemoveCustomRingtoneDialogFragment() fragment.arguments = args fragment.isCancelable = hasPermissions fragment.show(manager, "confirm_ringtone_remove") } } } /** * This click handler alters selection and playback of ringtones. It also launches the system * file chooser to search for openable audio files that may serve as ringtones. */ private inner class ItemClickWatcher : OnItemClickedListener { override fun onItemClicked(viewHolder: ItemViewHolder<*>, id: Int) { when (id) { AddCustomRingtoneViewHolder.CLICK_ADD_NEW -> { stopPlayingRingtone(selectedRingtoneHolder, false) startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) .addCategory(Intent.CATEGORY_OPENABLE) .setType("audio/*"), 0) } RingtoneViewHolder.CLICK_NORMAL -> { val oldSelection = selectedRingtoneHolder val newSelection = viewHolder.itemHolder as RingtoneHolder // Tapping the existing selection toggles playback of the ringtone. if (oldSelection === newSelection) { if (newSelection.isPlaying) { stopPlayingRingtone(newSelection, false) } else { startPlayingRingtone(newSelection) } } else { // Tapping a new selection changes the selection and playback. stopPlayingRingtone(oldSelection, true) startPlayingRingtone(newSelection) } } RingtoneViewHolder.CLICK_LONG_PRESS -> { mIndexOfRingtoneToRemove = viewHolder.getAdapterPosition() } RingtoneViewHolder.CLICK_NO_PERMISSIONS -> { ConfirmRemoveCustomRingtoneDialogFragment.show(supportFragmentManager, (viewHolder.itemHolder as RingtoneHolder).uri, false) } } } } /** * This task locates a displayable string in the background that is fit for use as the title of * the audio content. It adds a custom ringtone using the uri and title on the main thread. */ private inner class AddCustomRingtoneTask(private val mUri: Uri) : AsyncTask() { private val mContext: Context = getApplicationContext() override fun doInBackground(vararg voids: Void?): String { val contentResolver = mContext.contentResolver // Take the long-term permission to read (playback) the audio at the uri. contentResolver .takePersistableUriPermission(mUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) try { contentResolver.query(mUri, null, null, null, null).use { cursor -> if (cursor != null && cursor.moveToFirst()) { // If the file was a media file, return its title. val titleIndex = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE) if (titleIndex != -1) { return cursor.getString(titleIndex) } // If the file was a simple openable, return its display name. val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (displayNameIndex != -1) { var title = cursor.getString(displayNameIndex) val dotIndex = title.lastIndexOf(".") if (dotIndex > 0) { title = title.substring(0, dotIndex) } return title } } else { LogUtils.e("No ringtone for uri: %s", mUri) } } } catch (e: Exception) { LogUtils.e("Unable to locate title for custom ringtone: $mUri", e) } return mContext.getString(R.string.unknown_ringtone_title) } override fun onPostExecute(title: String) { // Add the new custom ringtone to the data model. DataModel.dataModel.addCustomRingtone(mUri, title) // When the loader completes, it must play the new ringtone. mSelectedRingtoneUri = mUri mIsPlaying = true // Reload the data to reflect the change in the UI. LoaderManager.getInstance(this@RingtonePickerActivity).restartLoader(0 /* id */, null /* args */, this@RingtonePickerActivity /* callback */) } } /** * Removes a custom ringtone with the given uri. Taking this action has side-effects because * all alarms that use the custom ringtone are reassigned to the Android system default alarm * ringtone. If the application's default alarm ringtone is being removed, it is reset to the * Android system default alarm ringtone. If the application's timer ringtone is being removed, * it is reset to the application's default timer ringtone. */ private inner class RemoveCustomRingtoneTask(private val mRemoveUri: Uri) : AsyncTask() { private lateinit var mSystemDefaultRingtoneUri: Uri override fun doInBackground(vararg voids: Void?): Void? { mSystemDefaultRingtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) // Update all alarms that use the custom ringtone to use the system default. val cr: ContentResolver = getContentResolver() val alarms = Alarm.getAlarms(cr, null) for (alarm in alarms) { if (mRemoveUri == alarm.alert) { alarm.alert = mSystemDefaultRingtoneUri // Start a second background task to persist the updated alarm. AlarmUpdateHandler(this@RingtonePickerActivity, null, null) .asyncUpdateAlarm(alarm, popToast = false, minorUpdate = true) } } try { // Release the permission to read (playback) the audio at the uri. cr.releasePersistableUriPermission(mRemoveUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (ignore: SecurityException) { // If the file was already deleted from the file system, a SecurityException is // thrown indicating this app did not hold the read permission being released. LogUtils.w("SecurityException while releasing read permission for $mRemoveUri") } return null } override fun onPostExecute(v: Void?) { // Reset the default alarm ringtone if it was just removed. if (mRemoveUri == DataModel.dataModel.defaultAlarmRingtoneUri) { DataModel.dataModel.defaultAlarmRingtoneUri = mSystemDefaultRingtoneUri } // Reset the timer ringtone if it was just removed. if (mRemoveUri == DataModel.dataModel.timerRingtoneUri) { val timerRingtoneUri = DataModel.dataModel.defaultTimerRingtoneUri DataModel.dataModel.timerRingtoneUri = timerRingtoneUri } // Remove the corresponding custom ringtone. DataModel.dataModel.removeCustomRingtone(mRemoveUri) // Find the ringtone to be removed from the adapter. val toRemove = getRingtoneHolder(mRemoveUri) ?: return // If the ringtone to remove is also the selected ringtone, adjust the selection. if (toRemove.isSelected) { stopPlayingRingtone(toRemove, false) val defaultRingtone = getRingtoneHolder(mDefaultRingtoneUri) if (defaultRingtone != null) { defaultRingtone.isSelected = true mSelectedRingtoneUri = defaultRingtone.uri defaultRingtone.notifyItemChanged() } } // Remove the ringtone from the adapter. mRingtoneAdapter.removeItem(toRemove) } } companion object { /** Key to an extra that defines resource id to the title of this activity. */ private const val EXTRA_TITLE = "extra_title" /** Key to an extra that identifies the alarm to which the selected ringtone is attached. */ private const val EXTRA_ALARM_ID = "extra_alarm_id" /** Key to an extra that identifies the selected ringtone. */ private const val EXTRA_RINGTONE_URI = "extra_ringtone_uri" /** Key to an extra that defines the uri representing the default ringtone. */ private const val EXTRA_DEFAULT_RINGTONE_URI = "extra_default_ringtone_uri" /** Key to an extra that defines the name of the default ringtone. */ private const val EXTRA_DEFAULT_RINGTONE_NAME = "extra_default_ringtone_name" /** Key to an instance state value indicating if the * selected ringtone is currently playing. */ private const val STATE_KEY_PLAYING = "extra_is_playing" /** * @return an intent that launches the ringtone picker to edit the ringtone of the given * `alarm` */ @JvmStatic @Keep fun createAlarmRingtonePickerIntent(context: Context, alarm: Alarm): Intent { return Intent(context, RingtonePickerActivity::class.java) .putExtra(EXTRA_TITLE, R.string.alarm_sound) .putExtra(EXTRA_ALARM_ID, alarm.id) .putExtra(EXTRA_RINGTONE_URI, alarm.alert) .putExtra(EXTRA_DEFAULT_RINGTONE_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) .putExtra(EXTRA_DEFAULT_RINGTONE_NAME, R.string.default_alarm_ringtone_title) } /** * @return an intent that launches the ringtone picker to edit the ringtone of all timers */ @JvmStatic @Keep fun createTimerRingtonePickerIntent(context: Context): Intent { val dataModel = DataModel.dataModel return Intent(context, RingtonePickerActivity::class.java) .putExtra(EXTRA_TITLE, R.string.timer_sound) .putExtra(EXTRA_RINGTONE_URI, dataModel.timerRingtoneUri) .putExtra(EXTRA_DEFAULT_RINGTONE_URI, dataModel.defaultTimerRingtoneUri) .putExtra(EXTRA_DEFAULT_RINGTONE_NAME, R.string.default_timer_ringtone_title) } } }