blob: 1dadc69cc90c2b0660848c9f8567a2acbea5c749 [file] [log] [blame]
/*
* 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<List<ItemHolder<Uri?>>> {
/** 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<ItemHolder<Uri?>>
/** 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<Void?, Void?, Alarm>() {
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<List<ItemHolder<Uri?>>> {
return RingtoneLoader(getApplicationContext(), mDefaultRingtoneUri!!,
mDefaultRingtoneTitle!!)
}
override fun onLoadFinished(
loader: Loader<List<ItemHolder<Uri?>>>,
itemHolders: List<ItemHolder<Uri?>>
) {
// 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<List<ItemHolder<Uri?>>>) {
}
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<Uri>(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<Void?, Void?, String>() {
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<Void?, Void?, Void?>() {
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)
}
}
}