From ec09b2c8faa1c31cc51ed17caf2a5a2bbf712281 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 13 Sep 2026 21:49:56 +0300 Subject: [PATCH] Make the in-conversation message sound opt-in (fixes #298) --- .../AppSettingsRepositoryImplTest.kt | 27 ++ ...gleNotificationsBlockedConversationTest.kt | 29 +- .../AppSettingsUiStateMapperImplTest.kt | 2 + .../messaging/util/InConversationSoundTest.kt | 349 +++++++++++++++++ res/values/constants.xml | 2 + res/values/strings.xml | 2 + .../data/appsettings/model/AppBooleanPref.kt | 1 + .../data/appsettings/model/AppSettings.kt | 1 + .../repository/AppSettingsRepository.kt | 4 + .../datamodel/BugleNotifications.java | 53 +-- .../datamodel/action/BugleActionToasts.java | 4 + .../action/ReceiveSmsMessageAction.java | 4 +- .../general/AppSettingsViewModel.kt | 4 + .../general/delegate/AppSettingsDelegate.kt | 8 + .../mapper/AppSettingsUiStateMapper.kt | 1 + .../general/model/AppSettingsAction.kt | 4 + .../general/model/AppSettingsUiState.kt | 1 + .../general/ui/AppSettingsScreen.kt | 11 + .../messaging/util/InConversationSound.kt | 148 +++++++ .../messaging/util/NotificationPlayer.java | 363 ------------------ 20 files changed, 578 insertions(+), 440 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/util/InConversationSoundTest.kt create mode 100644 src/com/android/messaging/util/InConversationSound.kt delete mode 100644 src/com/android/messaging/util/NotificationPlayer.java diff --git a/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt index b1d126cc0..f5d54c76c 100644 --- a/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt @@ -62,6 +62,9 @@ internal class AppSettingsRepositoryImplTest { every { factory.getPhoneUtils(ParticipantData.DEFAULT_SELF_SUB_ID) } returns phoneUtils every { context.resources } returns resources every { context.getString(R.string.send_sound_pref_key) } returns SEND_SOUND_PREF_KEY + every { + context.getString(R.string.in_conversation_sound_pref_key) + } returns IN_CONVERSATION_SOUND_PREF_KEY every { context.getString(R.string.dump_sms_pref_key) } returns DUMP_SMS_PREF_KEY every { context.getString(R.string.dump_mms_pref_key) } returns DUMP_MMS_PREF_KEY every { @@ -72,6 +75,9 @@ internal class AppSettingsRepositoryImplTest { } returns YOUTUBE_LINK_PREVIEWS_DEFAULT every { resources.getBoolean(R.bool.send_sound_pref_default) } returns SEND_SOUND_DEFAULT + every { + resources.getBoolean(R.bool.in_conversation_sound_pref_default) + } returns IN_CONVERSATION_SOUND_DEFAULT every { resources.getBoolean(R.bool.dump_sms_pref_default) } returns DUMP_SMS_DEFAULT every { resources.getBoolean(R.bool.dump_mms_pref_default) } returns DUMP_MMS_DEFAULT } @@ -88,6 +94,12 @@ internal class AppSettingsRepositoryImplTest { every { phoneUtils.defaultSmsAppLabel } returns DEFAULT_SMS_APP_LABEL every { debugFeaturesProvider.isEnabled() } returns true every { appPrefs.getBoolean(SEND_SOUND_PREF_KEY, SEND_SOUND_DEFAULT) } returns false + every { + appPrefs.getBoolean( + IN_CONVERSATION_SOUND_PREF_KEY, + IN_CONVERSATION_SOUND_DEFAULT, + ) + } returns true every { appPrefs.getBoolean(DUMP_SMS_PREF_KEY, DUMP_SMS_DEFAULT) } returns true every { appPrefs.getBoolean(DUMP_MMS_PREF_KEY, DUMP_MMS_DEFAULT) } returns false every { @@ -104,12 +116,17 @@ internal class AppSettingsRepositoryImplTest { assertTrue(result.isDefaultSmsApp) assertEquals(DEFAULT_SMS_APP_LABEL, result.defaultSmsAppLabel) assertFalse(result.sendSoundEnabled) + assertTrue(result.inConversationSoundEnabled) assertTrue(result.youTubeLinkPreviewsEnabled) assertTrue(result.isDebugEnabled) assertTrue(result.dumpSmsEnabled) assertFalse(result.dumpMmsEnabled) verify(exactly = 1) { appPrefs.getBoolean(SEND_SOUND_PREF_KEY, SEND_SOUND_DEFAULT) + appPrefs.getBoolean( + IN_CONVERSATION_SOUND_PREF_KEY, + IN_CONVERSATION_SOUND_DEFAULT, + ) appPrefs.getBoolean( YOUTUBE_LINK_PREVIEWS_PREF_KEY, YOUTUBE_LINK_PREVIEWS_DEFAULT, @@ -132,6 +149,10 @@ internal class AppSettingsRepositoryImplTest { pref = AppBooleanPref.SEND_SOUND, enabled = true, ) + repository.setBooleanPref( + pref = AppBooleanPref.IN_CONVERSATION_SOUND, + enabled = true, + ) repository.setBooleanPref( pref = AppBooleanPref.DUMP_SMS, enabled = false, @@ -150,6 +171,10 @@ internal class AppSettingsRepositoryImplTest { SEND_SOUND_PREF_KEY, true, ) + appPrefs.putBoolean( + IN_CONVERSATION_SOUND_PREF_KEY, + true, + ) appPrefs.putBoolean( DUMP_SMS_PREF_KEY, false, @@ -213,6 +238,8 @@ internal class AppSettingsRepositoryImplTest { private const val DUMP_MMS_PREF_KEY = "dump_mms" private const val DUMP_SMS_DEFAULT = true private const val DUMP_SMS_PREF_KEY = "dump_sms" + private const val IN_CONVERSATION_SOUND_DEFAULT = false + private const val IN_CONVERSATION_SOUND_PREF_KEY = "in_conversation_sound" private const val SEND_SOUND_DEFAULT = true private const val SEND_SOUND_PREF_KEY = "send_sound" private const val YOUTUBE_LINK_PREVIEWS_DEFAULT = false diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt index c83a3716b..31d4dae68 100644 --- a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt +++ b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt @@ -1,6 +1,5 @@ package com.android.messaging.datamodel -import android.media.AudioManager import android.net.Uri import com.android.messaging.FactoryTestAccess import com.android.messaging.data.conversationsettings.repository.ConversationSnoozeQuery @@ -34,7 +33,6 @@ class BugleNotificationsBlockedConversationTest { dataModel = dataModel, ) every { dataModel.getDatabase() } returns database - silenceRinger() stubConversationLookup() stubSnoozeLookup() stubNotificationDelivery() @@ -76,25 +74,17 @@ class BugleNotificationsBlockedConversationTest { } @Test - fun createMessageNotification_withBlockedObservableConversation_playsNoSound() { - givenNoUnseenMessages() - givenConversationObservable(BLOCKED_CONVERSATION_ID) - - BugleNotifications.createMessageNotification(BLOCKED_CONVERSATION_ID) - - verify(exactly = 0) { RingtoneUtil.getNotificationRingtoneUri(any(), any()) } - } - - @Test - fun createMessageNotification_withAllowedObservableConversation_playsSound() { + fun createMessageNotification_withObservableConversation_postsAndPlaysNothing() { + // Issue #298: a message arriving in the conversation the user is watching is already + // marked seen, so there is nothing to post - and nothing to play either. The app used + // to sound the conversation ringtone here, which no notification setting could silence. givenNoUnseenMessages() givenConversationObservable(ALLOWED_CONVERSATION_ID) BugleNotifications.createMessageNotification(ALLOWED_CONVERSATION_ID) - verify(exactly = 1) { - RingtoneUtil.getNotificationRingtoneUri(ALLOWED_CONVERSATION_ID, null) - } + verify(exactly = 0) { BugleNotifications.processAndSend(any(), any()) } + verify(exactly = 0) { RingtoneUtil.getNotificationRingtoneUri(any(), any()) } } private fun stubConversationLookup() { @@ -116,7 +106,6 @@ class BugleNotificationsBlockedConversationTest { ) { val convData = mockk(relaxed = true) every { convData.otherParticipantNormalizedDestination } returns sender - every { convData.notificationSoundUri } returns null every { ConversationListItemData.getExistingConversation(database, conversationId) } returns convData @@ -135,12 +124,6 @@ class BugleNotificationsBlockedConversationTest { every { BugleNotifications.processAndSend(any(), any()) } just runs } - private fun silenceRinger() { - RuntimeEnvironment.getApplication() - .getSystemService(AudioManager::class.java) - .ringerMode = AudioManager.RINGER_MODE_SILENT - } - private fun givenUnseenMessage( conversationId: String, ): MessageNotificationState.Conversation { diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt index 5dc90a286..0229f220d 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt @@ -28,6 +28,7 @@ internal class AppSettingsUiStateMapperImplTest { isDefaultSmsApp = true, defaultSmsAppLabel = DEFAULT_SMS_APP_LABEL, sendSoundEnabled = false, + inConversationSoundEnabled = true, youTubeLinkPreviewsEnabled = true, isDebugEnabled = true, dumpSmsEnabled = true, @@ -40,6 +41,7 @@ internal class AppSettingsUiStateMapperImplTest { isDefaultSmsApp = true, defaultSmsAppLabel = FORMATTED_DEFAULT_SMS_APP_LABEL, sendSoundEnabled = false, + inConversationSoundEnabled = true, youTubeLinkPreviewsEnabled = true, isDebugEnabled = true, dumpSmsEnabled = true, diff --git a/app/src/test/kotlin/com/android/messaging/util/InConversationSoundTest.kt b/app/src/test/kotlin/com/android/messaging/util/InConversationSoundTest.kt new file mode 100644 index 000000000..99b33bfd2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/util/InConversationSoundTest.kt @@ -0,0 +1,349 @@ +package com.android.messaging.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.media.AudioManager +import android.media.Ringtone +import android.media.RingtoneManager +import android.net.Uri +import com.android.messaging.FactoryTestAccess +import com.android.messaging.R +import com.android.messaging.data.conversationsettings.repository.ConversationSnoozeQuery +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.testutil.FakeBuglePrefs +import com.android.messaging.testutil.createIncomingMessagesTestChannel +import com.android.messaging.testutil.installTestFactory +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class InConversationSoundTest { + + private val context: Context = RuntimeEnvironment.getApplication().applicationContext + private val prefs = FakeBuglePrefs() + private val ringtone = mockk(relaxed = true) + private val scheduler = TestCoroutineScheduler() + + // The worker is serialized in production; UnconfinedTestDispatcher runs it eagerly so the + // assertions below stay synchronous, while the scheduler still drives the five second stop. + private val sound = InConversationSound(dispatcher = UnconfinedTestDispatcher(scheduler)) + + @Before + fun setUp() { + installTestFactory(context = context, prefs = prefs) + createIncomingMessagesTestChannel() + mockkStatic(RingtoneUtil::class) + every { RingtoneUtil.getNotificationRingtoneUri(any(), any()) } returns RINGTONE_URI + mockkStatic(RingtoneManager::class) + every { RingtoneManager.getRingtone(any(), any()) } returns ringtone + mockkStatic(ConversationSnoozeQuery::class) + every { ConversationSnoozeQuery.isConversationSnoozed(any()) } returns false + mockkStatic(BugleNotifications::class) + every { BugleNotifications.isConversationBlocked(any()) } returns false + } + + @After + fun tearDown() { + unmockkAll() + FactoryTestAccess.reset() + } + + @Test + fun post_byDefault_staysSilent() { + // The chime is opt-in: issue #298 is fixed for anyone who never touches the setting. + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenEnabled_plays() { + givenSoundEnabled() + + sound.post(CONVERSATION_ID) + + verify(exactly = 1) { ringtone.play() } + } + + @Test + fun post_withoutConversationId_staysSilent() { + givenSoundEnabled() + + sound.post(null) + sound.post("") + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenDoNotDisturbIsOn_staysSilent() { + givenSoundEnabled() + shadowOf(notificationManager()).setNotificationPolicyAccessGranted(true) + notificationManager() + .setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY) + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenRingerIsSilenced_staysSilent() { + givenSoundEnabled() + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.ringerMode = AudioManager.RINGER_MODE_SILENT + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenAppNotificationsAreDisabled_staysSilent() { + givenSoundEnabled() + shadowOf(notificationManager()).setNotificationsEnabled(false) + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenConversationIsMuted_staysSilent() { + givenSoundEnabled() + givenConversationChannel(NotificationManager.IMPORTANCE_LOW) + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenTheIncomingMessagesChannelIsSilenced_staysSilent() { + // A conversation the user has only ever read live has no channel of its own yet, so the + // one it would inherit from is what decides. + givenSoundEnabled() + createIncomingMessagesTestChannel(importance = NotificationManager.IMPORTANCE_LOW) + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenTheSenderIsBlocked_staysSilent() { + givenSoundEnabled() + every { BugleNotifications.isConversationBlocked(CONVERSATION_ID) } returns true + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenConversationIsSnoozed_staysSilent() { + givenSoundEnabled() + every { ConversationSnoozeQuery.isConversationSnoozed(CONVERSATION_ID) } returns true + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_whenConversationSoundIsNone_staysSilent() { + givenSoundEnabled() + every { RingtoneUtil.getNotificationRingtoneUri(any(), any()) } returns null + + sound.post(CONVERSATION_ID) + + verify(exactly = 0) { ringtone.play() } + } + + @Test + fun post_doesNothingOnTheCallerThread() { + // The caller can be inside a database transaction, and preparing a ringtone blocks on + // mediaserver, so nothing may touch the media or notification stack before the worker runs. + givenSoundEnabled() + val posted = InConversationSound(dispatcher = StandardTestDispatcher(scheduler)) + + posted.post(CONVERSATION_ID) + + verify(exactly = 0) { BugleNotifications.isConversationBlocked(any()) } + verify(exactly = 0) { RingtoneManager.getRingtone(any(), any()) } + verify(exactly = 0) { ringtone.play() } + + scheduler.runCurrent() + + verify(exactly = 1) { ringtone.play() } + } + + @Test + fun post_whileAChimeIsPlaying_dropsTheNewOne() { + givenSoundEnabled() + every { ringtone.isPlaying } returns true + + sound.post(CONVERSATION_ID) + sound.post(CONVERSATION_ID) + + verify(exactly = 1) { ringtone.play() } + verify(exactly = 0) { ringtone.stop() } + } + + @Test + fun post_afterAChimeEnded_releasesItBeforePlayingTheNextOne() { + // A ringtone that ended on its own still holds its MediaPlayer until stop() releases it. + givenSoundEnabled() + val first = mockk(relaxed = true) + val second = mockk(relaxed = true) + every { RingtoneManager.getRingtone(any(), any()) } returnsMany listOf(first, second) + + sound.post(CONVERSATION_ID) + sound.post(CONVERSATION_ID) + + verifyOrder { + first.play() + first.stop() + second.play() + } + } + + @Test + fun post_stopsTheChimeAfterFiveSeconds() { + // Restores the cap BugleNotifications used to post to the main thread, so a ringtone that + // is a whole song does not play in full. + givenSoundEnabled() + + sound.post(CONVERSATION_ID) + scheduler.advanceTimeBy(MAX_DURATION_MS) + + verify(exactly = 0) { ringtone.stop() } + + scheduler.runCurrent() + + verify(exactly = 1) { ringtone.stop() } + } + + @Test + fun post_whenAMessageIsDropped_doesNotExtendTheStop() { + givenSoundEnabled() + every { ringtone.isPlaying } returns true + + sound.post(CONVERSATION_ID) + scheduler.advanceTimeBy(MAX_DURATION_MS - 100) + sound.post(CONVERSATION_ID) + scheduler.advanceTimeBy(100) + scheduler.runCurrent() + + verify(exactly = 1) { ringtone.stop() } + } + + @Test + fun post_whenAnEndedChimeIsReplaced_doesNotCutTheNewOneShort() { + // The first chime's timer must not stop the second one three seconds early. + givenSoundEnabled() + val first = mockk(relaxed = true) + val second = mockk(relaxed = true) + every { RingtoneManager.getRingtone(any(), any()) } returnsMany listOf(first, second) + + sound.post(CONVERSATION_ID) + scheduler.advanceTimeBy(3_000) + sound.post(CONVERSATION_ID) + + scheduler.advanceTimeBy(2_000) + scheduler.runCurrent() + + verify(exactly = 0) { second.stop() } + + scheduler.advanceTimeBy(3_000) + scheduler.runCurrent() + + verify(exactly = 1) { second.stop() } + } + + @Test + fun post_whenTheChimeFails_releasesTheRingtoneWithoutAnotherMessage() { + // A failed chime still holds a prepared MediaPlayer, and nothing guarantees a next message + // to clean up after it, so the cap has to be armed before play() can throw. + givenSoundEnabled() + every { ringtone.play() } throws IllegalStateException("mediaserver died") + + sound.post(CONVERSATION_ID) + scheduler.advanceTimeBy(MAX_DURATION_MS) + scheduler.runCurrent() + + verify(exactly = 1) { ringtone.stop() } + } + + @Test + fun post_whenTheChimeFails_doesNotCrashTheProcess() { + // The caller may still be inside a database transaction, and BugleApplication forwards an + // uncaught background throwable to the system handler. That would kill the process along + // with the transaction, so a mediaserver or database failure has to stop here. + givenSoundEnabled() + every { ringtone.play() } throws IllegalStateException("mediaserver died") + val escaped = mutableListOf() + val worker = Thread.currentThread() + val previous = worker.uncaughtExceptionHandler + worker.uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, failure -> + escaped.add(failure) + } + + try { + sound.post(CONVERSATION_ID) + // A no-op while the worker runs eagerly, and the whole point under a queued + // dispatcher: the failure has to land while this thread's handler is still swapped. + scheduler.runCurrent() + } finally { + worker.uncaughtExceptionHandler = previous + } + + assertTrue("the failure reached the uncaught handler: $escaped", escaped.isEmpty()) + + // The next message still chimes: the failure did not poison the worker. + every { ringtone.play() } just runs + sound.post(CONVERSATION_ID) + + verify(exactly = 2) { ringtone.play() } + } + + private fun givenSoundEnabled() { + prefs.putBoolean(context.getString(R.string.in_conversation_sound_pref_key), true) + } + + private fun givenConversationChannel(importance: Int) { + val channel = NotificationChannel(CONVERSATION_ID, "Alice", importance) + channel.setConversationId(NotificationChannelUtil.INCOMING_MESSAGES, CONVERSATION_ID) + notificationManager().createNotificationChannel(channel) + } + + private fun notificationManager(): NotificationManager { + return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private companion object { + private const val CONVERSATION_ID = "194" + private const val MAX_DURATION_MS = 5_000L + private val RINGTONE_URI: Uri = Uri.parse("content://settings/system/notification_sound") + } +} diff --git a/res/values/constants.xml b/res/values/constants.xml index 6ca93b918..01e328ede 100644 --- a/res/values/constants.xml +++ b/res/values/constants.xml @@ -19,6 +19,8 @@ notification_sound send_sound true + in_conversation_sound + false youtube_link_previews false diff --git a/res/values/strings.xml b/res/values/strings.xml index 9449ed0d2..acdf5e6c8 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -389,6 +389,8 @@ Not a valid phone number. Try the international format, starting with +. Outgoing message sounds + In-conversation message sounds + Play a quiet sound for a message that arrives in the conversation you are viewing. Muted and snoozed conversations stay silent. Dump SMS Dump received SMS raw data into external storage file diff --git a/src/com/android/messaging/data/appsettings/model/AppBooleanPref.kt b/src/com/android/messaging/data/appsettings/model/AppBooleanPref.kt index 4b11c76f5..5ae6d3271 100644 --- a/src/com/android/messaging/data/appsettings/model/AppBooleanPref.kt +++ b/src/com/android/messaging/data/appsettings/model/AppBooleanPref.kt @@ -7,6 +7,7 @@ internal enum class AppBooleanPref( @param:StringRes val keyResId: Int, ) { SEND_SOUND(R.string.send_sound_pref_key), + IN_CONVERSATION_SOUND(R.string.in_conversation_sound_pref_key), YOUTUBE_LINK_PREVIEWS(R.string.youtube_link_previews_pref_key), DUMP_SMS(R.string.dump_sms_pref_key), DUMP_MMS(R.string.dump_mms_pref_key), diff --git a/src/com/android/messaging/data/appsettings/model/AppSettings.kt b/src/com/android/messaging/data/appsettings/model/AppSettings.kt index 863563430..98227fc3b 100644 --- a/src/com/android/messaging/data/appsettings/model/AppSettings.kt +++ b/src/com/android/messaging/data/appsettings/model/AppSettings.kt @@ -4,6 +4,7 @@ internal data class AppSettings( val isDefaultSmsApp: Boolean, val defaultSmsAppLabel: String, val sendSoundEnabled: Boolean, + val inConversationSoundEnabled: Boolean, val youTubeLinkPreviewsEnabled: Boolean, val isDebugEnabled: Boolean, val dumpSmsEnabled: Boolean, diff --git a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt index 4096c4a95..1042ededd 100644 --- a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt +++ b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt @@ -38,6 +38,10 @@ internal class AppSettingsRepositoryImpl @Inject constructor( context.getString(R.string.send_sound_pref_key), resources.getBoolean(R.bool.send_sound_pref_default), ), + inConversationSoundEnabled = appPrefs.getBoolean( + context.getString(R.string.in_conversation_sound_pref_key), + resources.getBoolean(R.bool.in_conversation_sound_pref_default), + ), youTubeLinkPreviewsEnabled = readYouTubeLinkPreviewsEnabled(), isDebugEnabled = debugFeaturesProvider.isEnabled(), dumpSmsEnabled = appPrefs.getBoolean( diff --git a/src/com/android/messaging/datamodel/BugleNotifications.java b/src/com/android/messaging/datamodel/BugleNotifications.java index 9e7197682..c6a28afa6 100644 --- a/src/com/android/messaging/datamodel/BugleNotifications.java +++ b/src/com/android/messaging/datamodel/BugleNotifications.java @@ -22,7 +22,6 @@ import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Matrix; -import android.media.AudioManager; import android.net.Uri; import android.service.notification.StatusBarNotification; import android.text.TextUtils; @@ -61,12 +60,9 @@ import com.android.messaging.util.AvatarUriUtil; import com.android.messaging.util.LogUtil; import com.android.messaging.util.NotificationChannelUtil; -import com.android.messaging.util.NotificationPlayer; import com.android.messaging.util.OsUtil; import com.android.messaging.util.PendingIntentConstants; import com.android.messaging.util.PhoneUtils; -import com.android.messaging.util.RingtoneUtil; -import com.android.messaging.util.ThreadUtil; import com.android.messaging.util.UriUtil; import com.android.messaging.util.exif.ExifInterface; @@ -132,12 +128,6 @@ public class BugleNotifications { private static final AtomicLong sLastNotificationImageSweep = new AtomicLong(); - /** - * This is the volume at which to play the observable-conversation notification sound, - * expressed as a fraction of the system notification volume. - */ - private static final float OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME = 0.25f; - /** * Entry point for posting notifications. * Don't call this on the UI thread. @@ -244,12 +234,7 @@ static void createMessageNotification(final String conversationId) { } final MessageNotificationState state = MessageNotificationState.getNotificationState(); - final boolean softSound = DataModel.get().isNewMessageObservable(conversationId); if (state == null) { - if (softSound && !TextUtils.isEmpty(conversationId)) { - final Uri ringtoneUri = getNotificationRingtoneUriForConversationId(conversationId); - playObservableConversationNotificationSound(ringtoneUri); - } updateOverflowNotification(0); return; } @@ -336,15 +321,7 @@ public static synchronized void cancel(final int type, final String conversation } } - private static Uri getNotificationRingtoneUriForConversationId(final String conversationId) { - final DatabaseWrapper db = DataModel.get().getDatabase(); - final ConversationListItemData convData = - ConversationListItemData.getExistingConversation(db, conversationId); - return RingtoneUtil.getNotificationRingtoneUri(conversationId, - convData != null ? convData.getNotificationSoundUri() : null); - } - - private static boolean isConversationBlocked(final String conversationId) { + public static boolean isConversationBlocked(final String conversationId) { final DatabaseWrapper db = DataModel.get().getDatabase(); final ConversationListItemData convData = ConversationListItemData.getExistingConversation(db, conversationId); @@ -626,34 +603,6 @@ static boolean postNotification(final NotificationManagerCompat notificationMana } } - /** - * Play the observable conversation notification sound (it's the regular notification sound, but - * played at half-volume) - */ - private static void playObservableConversationNotificationSound(final Uri ringtoneUri) { - final Context context = Factory.get().getApplicationContext(); - final AudioManager audioManager = (AudioManager) context - .getSystemService(Context.AUDIO_SERVICE); - final boolean silenced = - audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL; - if (silenced) { - return; - } - - final NotificationPlayer player = new NotificationPlayer(LogUtil.BUGLE_TAG); - player.play(ringtoneUri, false, - AudioManager.STREAM_NOTIFICATION, - OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME); - - // Stop the sound after five seconds to handle continuous ringtones - ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { - @Override - public void run() { - player.stop(); - } - }, 5000); - } - /** * When we go to the conversation list, call this to mark all messages as seen. That means * we won't show a notification again for the same message. diff --git a/src/com/android/messaging/datamodel/action/BugleActionToasts.java b/src/com/android/messaging/datamodel/action/BugleActionToasts.java index bb448bcf2..c4d3f7e62 100644 --- a/src/com/android/messaging/datamodel/action/BugleActionToasts.java +++ b/src/com/android/messaging/datamodel/action/BugleActionToasts.java @@ -26,6 +26,7 @@ import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.sms.MmsUtils; import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.InConversationSound; import com.android.messaging.util.PhoneUtils; import com.android.messaging.util.ThreadUtil; @@ -98,6 +99,9 @@ static void onSendMessageOrManualDownloadActionCompleted( public static void onMessageReceived(final String conversationId, @Nullable final ParticipantData sender, @Nullable final MessageData message) { final Context context = Factory.get().getApplicationContext(); + if (DataModel.get().isNewMessageObservable(conversationId)) { + InConversationSound.playIfEnabled(conversationId); + } if (AccessibilityUtil.isTouchExplorationEnabled(context)) { final boolean isFocusedConversation = DataModel.get().isFocusedConversation( conversationId); diff --git a/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java index a5fcc33d9..0617c8a62 100644 --- a/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java +++ b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java @@ -98,8 +98,8 @@ protected Object executeAction() { // Only the primary user gets to insert the message into the telephony db and into bugle's // db. The secondary user goes through this path, but skips doing the actual insert. It // goes through this path because it needs to compute messageInFocusedConversation in order - // to calculate whether to skip the notification and play a soft sound if the user is - // already in the conversation. + // to calculate whether to skip the notification if the user is already in the + // conversation. if (!OsUtil.isSecondaryUser()) { final boolean read = messageValues.getAsBoolean(Sms.Inbox.READ) || messageInFocusedConversation; diff --git a/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt b/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt index f09061499..534a2a235 100644 --- a/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt +++ b/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt @@ -41,6 +41,10 @@ internal class AppSettingsViewModel @Inject constructor( is Action.DumpSmsChanged -> appSettingsDelegate.onDumpSmsChanged(action.enabled) is Action.SendSoundChanged -> appSettingsDelegate.onSendSoundChanged(action.enabled) + is Action.InConversationSoundChanged -> { + appSettingsDelegate.onInConversationSoundChanged(action.enabled) + } + is Action.YouTubeLinkPreviewsChanged -> { appSettingsDelegate.onYouTubeLinkPreviewsChanged(action.enabled) } diff --git a/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt b/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt index 67ab4e1b4..06f0b878e 100644 --- a/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt +++ b/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch internal interface AppSettingsDelegate : SettingsScreenDelegate { fun onSendSoundChanged(enabled: Boolean) + fun onInConversationSoundChanged(enabled: Boolean) fun onYouTubeLinkPreviewsChanged(enabled: Boolean) fun onDumpSmsChanged(enabled: Boolean) fun onDumpMmsChanged(enabled: Boolean) @@ -64,6 +65,13 @@ internal class AppSettingsDelegateImpl @Inject constructor( ) } + override fun onInConversationSoundChanged(enabled: Boolean) { + setBooleanPref( + pref = AppBooleanPref.IN_CONVERSATION_SOUND, + enabled = enabled, + ) + } + override fun onYouTubeLinkPreviewsChanged(enabled: Boolean) { setBooleanPref( pref = AppBooleanPref.YOUTUBE_LINK_PREVIEWS, diff --git a/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt b/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt index d314b7a59..2ca88e2a5 100644 --- a/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt +++ b/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt @@ -23,6 +23,7 @@ internal class AppSettingsUiStateMapperImpl @Inject constructor( appSettings.defaultSmsAppLabel, ), sendSoundEnabled = appSettings.sendSoundEnabled, + inConversationSoundEnabled = appSettings.inConversationSoundEnabled, youTubeLinkPreviewsEnabled = appSettings.youTubeLinkPreviewsEnabled, isDebugEnabled = appSettings.isDebugEnabled, dumpSmsEnabled = appSettings.dumpSmsEnabled, diff --git a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt index be1cedc5e..beac393d7 100644 --- a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt +++ b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt @@ -17,6 +17,10 @@ internal sealed interface AppSettingsAction { val enabled: Boolean, ) : AppSettingsAction + data class InConversationSoundChanged( + val enabled: Boolean, + ) : AppSettingsAction + data class YouTubeLinkPreviewsChanged( val enabled: Boolean, ) : AppSettingsAction diff --git a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt index cce5f03ee..9a7cdae82 100644 --- a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt +++ b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt @@ -7,6 +7,7 @@ internal data class AppSettingsUiState( val isDefaultSmsApp: Boolean = false, val defaultSmsAppLabel: String = "", val sendSoundEnabled: Boolean = true, + val inConversationSoundEnabled: Boolean = false, val youTubeLinkPreviewsEnabled: Boolean = false, val isDebugEnabled: Boolean = false, val dumpSmsEnabled: Boolean = false, diff --git a/src/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreen.kt b/src/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreen.kt index cc9d1423f..bb3c70e4c 100644 --- a/src/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreen.kt +++ b/src/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreen.kt @@ -109,6 +109,17 @@ private fun LazyListScope.coreSettingsItems( ) } + item(key = "in_conversation_sound") { + SettingsSwitchItem( + title = stringResource(R.string.in_conversation_sound_pref_title), + summary = stringResource(R.string.in_conversation_sound_pref_summary), + checked = appSettings.inConversationSoundEnabled, + onCheckedChange = { + onAction(Action.InConversationSoundChanged(it)) + }, + ) + } + item(key = "privacy") { SettingsClickableItem( title = stringResource(R.string.privacy_settings_activity_title), diff --git a/src/com/android/messaging/util/InConversationSound.kt b/src/com/android/messaging/util/InConversationSound.kt new file mode 100644 index 000000000..51b4e8a37 --- /dev/null +++ b/src/com/android/messaging/util/InConversationSound.kt @@ -0,0 +1,148 @@ +package com.android.messaging.util + +import android.app.NotificationManager +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.Ringtone +import android.media.RingtoneManager +import com.android.messaging.Factory +import com.android.messaging.R +import com.android.messaging.data.conversationsettings.repository.ConversationSnoozeQuery +import com.android.messaging.datamodel.BugleNotifications +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class InConversationSound internal constructor( + private val dispatcher: CoroutineDispatcher, +) { + + private val scope = CoroutineScope( + SupervisorJob() + dispatcher + CoroutineExceptionHandler { _, throwable -> + LogUtil.e( + LogUtil.BUGLE_TAG, + "InConversationSound: failed to play the chime", + throwable, + ) + }, + ) + + private var playing: Ringtone? = null + + private var stopJob: Job? = null + + internal fun post(conversationId: String?) { + if (conversationId.isNullOrEmpty()) { + return + } + + scope.launch(dispatcher) { + play(conversationId) + } + } + + private fun play(conversationId: String) { + if (!releasePreviousChime()) { + return + } + + val ringtone = prepare(conversationId) ?: return + + playing = ringtone + + stopJob = scope.launch(dispatcher) { + delay(MAX_DURATION_MS.milliseconds) + stopPlaying() + } + + ringtone.play() + } + + private fun releasePreviousChime(): Boolean { + val current = playing + if (current != null && current.isPlaying) { + return false + } + + stopJob?.cancel() + stopPlaying() + return true + } + + private fun prepare(conversationId: String): Ringtone? { + val context = Factory.get().applicationContext + if (!isEnabled(context) || !isAudible(context, conversationId)) { + return null + } + + return RingtoneUtil.getNotificationRingtoneUri(conversationId, null) + ?.let { RingtoneManager.getRingtone(context, it) } + ?.apply { + audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build() + volume = VOLUME + isLooping = false + } + } + + private fun stopPlaying() { + val current = playing + playing = null + current?.stop() + } + + private fun isEnabled(context: Context): Boolean { + return BuglePrefs.getApplicationPrefs().getBoolean( + context.getString(R.string.in_conversation_sound_pref_key), + context.resources.getBoolean(R.bool.in_conversation_sound_pref_default), + ) + } + + private fun isAudible(context: Context, conversationId: String): Boolean { + val notificationManager = NotificationChannelUtil.getNotificationManager() + val interruptionFilter = notificationManager.currentInterruptionFilter + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + // A conversation gets its own channel only once a notification has been posted for it, + // so fall back to the channel it would have inherited from. + val channel = NotificationChannelUtil.getConversationChannel(conversationId) + ?: notificationManager + .getNotificationChannel(NotificationChannelUtil.INCOMING_MESSAGES) + + return notificationManager.areNotificationsEnabled() && + interruptionFilter == NotificationManager.INTERRUPTION_FILTER_ALL && + audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL && + // IMPORTANCE_NONE means blocked, below IMPORTANCE_DEFAULT means silent. + (channel == null || channel.importance >= NotificationManager.IMPORTANCE_DEFAULT) && + !BugleNotifications.isConversationBlocked(conversationId) && + !ConversationSnoozeQuery.isConversationSnoozed(conversationId) + } + + companion object { + + private const val VOLUME = 0.25f + private const val MAX_DURATION_MS = 5_000L + + private val instance = InConversationSound( + dispatcher = Dispatchers.IO.limitedParallelism( + parallelism = 1, + name = "InConversationSound", + ), + ) + + /** Entry point for `BugleActionToasts`, which stays Java. */ + @JvmStatic + fun playIfEnabled(conversationId: String?) { + instance.post(conversationId) + } + } +} diff --git a/src/com/android/messaging/util/NotificationPlayer.java b/src/com/android/messaging/util/NotificationPlayer.java deleted file mode 100644 index a4ed44e36..000000000 --- a/src/com/android/messaging/util/NotificationPlayer.java +++ /dev/null @@ -1,363 +0,0 @@ -/* - * Copyright (C) 2015 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.messaging.util; - -import android.content.Context; -import android.media.AudioManager; -import android.media.MediaPlayer; -import android.media.MediaPlayer.OnCompletionListener; -import android.net.Uri; -import android.os.Looper; -import android.os.PowerManager; -import android.os.SystemClock; - -import com.android.messaging.Factory; - -import java.util.LinkedList; - -/** - * This class is provides the same interface and functionality as android.media.AsyncPlayer - * with the following differences: - * - whenever audio is played, audio focus is requested, - * - whenever audio playback is stopped or the playback completed, audio focus is abandoned. - * - * This file has been copied from com.android.server.NotificationPlayer. The only modification is - * the addition of a volume parameter. Hopefully the framework will adapt AsyncPlayer to support - * all the functionality in this class, at which point this one can be deleted. - */ -public class NotificationPlayer implements OnCompletionListener { - private static final int PLAY = 1; - private static final int STOP = 2; - private static final boolean mDebug = false; - - private static final class Command { - int code; - Uri uri; - boolean looping; - int stream; - float volume; - long requestTime; - boolean releaseFocus; - - @Override - public String toString() { - return "{ code=" + code + " looping=" + looping + " stream=" + stream - + " uri=" + uri + " }"; - } - } - - private final LinkedList mCmdQueue = new LinkedList(); - - private Looper mLooper; - - /* - * Besides the use of audio focus, the only implementation difference between AsyncPlayer and - * NotificationPlayer resides in the creation of the MediaPlayer. For the completion callback, - * OnCompletionListener, to be called at the end of the playback, the MediaPlayer needs to - * be created with a looper running so its event handler is not null. - */ - private final class CreationAndCompletionThread extends Thread { - public Command mCmd; - public CreationAndCompletionThread(final Command cmd) { - super(); - mCmd = cmd; - } - - @Override - public void run() { - Looper.prepare(); - mLooper = Looper.myLooper(); - synchronized (this) { - final AudioManager audioManager = - (AudioManager) Factory.get().getApplicationContext() - .getSystemService(Context.AUDIO_SERVICE); - try { - final MediaPlayer player = new MediaPlayer(); - player.setAudioStreamType(mCmd.stream); - player.setDataSource(Factory.get().getApplicationContext(), mCmd.uri); - player.setLooping(mCmd.looping); - player.setVolume(mCmd.volume, mCmd.volume); - player.prepare(); - if ((mCmd.uri != null) && (mCmd.uri.getEncodedPath() != null) - && (mCmd.uri.getEncodedPath().length() > 0)) { - audioManager.requestAudioFocus(null, mCmd.stream, - mCmd.looping ? AudioManager.AUDIOFOCUS_GAIN_TRANSIENT - : AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); - } - player.setOnCompletionListener(NotificationPlayer.this); - player.start(); - if (mPlayer != null) { - mPlayer.release(); - } - mPlayer = player; - } catch (final Exception e) { - LogUtil.w(mTag, "error loading sound for " + mCmd.uri, e); - } - mAudioManager = audioManager; - this.notify(); - } - Looper.loop(); - } - } - - private void startSound(final Command cmd) { - // Preparing can be slow, so if there is something else - // is playing, let it continue until we're done, so there - // is less of a glitch. - try { - if (mDebug) { - LogUtil.d(mTag, "Starting playback"); - } - //----------------------------------- - // This is were we deviate from the AsyncPlayer implementation and create the - // MediaPlayer in a new thread with which we're synchronized - synchronized (mCompletionHandlingLock) { - // if another sound was already playing, it doesn't matter we won't get notified - // of the completion, since only the completion notification of the last sound - // matters - if ((mLooper != null) - && (mLooper.getThread().getState() != Thread.State.TERMINATED)) { - mLooper.quit(); - } - mCompletionThread = new CreationAndCompletionThread(cmd); - synchronized (mCompletionThread) { - mCompletionThread.start(); - mCompletionThread.wait(); - } - } - //----------------------------------- - - final long delay = SystemClock.elapsedRealtime() - cmd.requestTime; - if (delay > 1000) { - LogUtil.w(mTag, "Notification sound delayed by " + delay + "msecs"); - } - } catch (final Exception e) { - LogUtil.w(mTag, "error loading sound for " + cmd.uri, e); - } - } - - private void stopSound(final Command cmd) { - if (mPlayer == null) { - return; - } - final long delay = SystemClock.elapsedRealtime() - cmd.requestTime; - if (delay > 1000) { - LogUtil.w(mTag, "Notification stop delayed by " + delay + "msecs"); - } - mPlayer.stop(); - mPlayer.release(); - mPlayer = null; - if (cmd.releaseFocus && mAudioManager != null) { - mAudioManager.abandonAudioFocus(null); - } - mAudioManager = null; - if ((mLooper != null) && (mLooper.getThread().getState() != Thread.State.TERMINATED)) { - mLooper.quit(); - } - } - - private final class CmdThread extends java.lang.Thread { - CmdThread() { - super("NotificationPlayer-" + mTag); - } - - @Override - public void run() { - while (true) { - Command cmd = null; - - synchronized (mCmdQueue) { - if (mDebug) { - LogUtil.d(mTag, "RemoveFirst"); - } - cmd = mCmdQueue.removeFirst(); - } - - switch (cmd.code) { - case PLAY: - if (mDebug) { - LogUtil.d(mTag, "PLAY"); - } - startSound(cmd); - break; - case STOP: - if (mDebug) { - LogUtil.d(mTag, "STOP"); - } - stopSound(cmd); - break; - } - - synchronized (mCmdQueue) { - if (mCmdQueue.size() == 0) { - // nothing left to do, quit - // doing this check after we're done prevents the case where they - // added it during the operation from spawning two threads and - // trying to do them in parallel. - mThread = null; - releaseWakeLock(); - return; - } - } - } - } - } - - @Override - public void onCompletion(final MediaPlayer mp) { - if (mAudioManager != null) { - mAudioManager.abandonAudioFocus(null); - } - // if there are no more sounds to play, end the Looper to listen for media completion - synchronized (mCmdQueue) { - if (mCmdQueue.size() == 0) { - synchronized (mCompletionHandlingLock) { - if (mLooper != null) { - mLooper.quit(); - } - mCompletionThread = null; - } - } - } - } - - private String mTag; - private CmdThread mThread; - private CreationAndCompletionThread mCompletionThread; - private final Object mCompletionHandlingLock = new Object(); - private MediaPlayer mPlayer; - private PowerManager.WakeLock mWakeLock; - private AudioManager mAudioManager; - - // The current state according to the caller. Reality lags behind - // because of the asynchronous nature of this class. - private int mState = STOP; - - /** - * Construct a NotificationPlayer object. - * - * @param tag a string to use for debugging - */ - public NotificationPlayer(final String tag) { - if (tag != null) { - mTag = tag; - } else { - mTag = "NotificationPlayer"; - } - } - - /** - * Start playing the sound. It will actually start playing at some - * point in the future. There are no guarantees about latency here. - * Calling this before another audio file is done playing will stop - * that one and start the new one. - * - * @param uri The URI to play. (see {@link MediaPlayer#setDataSource(Context, Uri)}) - * @param looping Whether the audio should loop forever. - * (see {@link MediaPlayer#setLooping(boolean)}) - * @param stream the AudioStream to use. - * (see {@link MediaPlayer#setAudioStreamType(int)}) - * @param volume The volume at which to play this sound, as a fraction of the system volume for - * the relevant stream type. A value of 1 is the maximum and means play at the system - * volume with no attenuation. - */ - public void play(final Uri uri, final boolean looping, final int stream, final float volume) { - final Command cmd = new Command(); - cmd.requestTime = SystemClock.elapsedRealtime(); - cmd.code = PLAY; - cmd.uri = uri; - cmd.looping = looping; - cmd.stream = stream; - cmd.volume = volume; - synchronized (mCmdQueue) { - enqueueLocked(cmd); - mState = PLAY; - } - } - - /** Same as calling stop(true) */ - public void stop() { - stop(true); - } - - /** - * Stop a previously played sound. It can't be played again or unpaused - * at this point. Calling this multiple times has no ill effects. - * @param releaseAudioFocus whether to release audio focus - */ - public void stop(final boolean releaseAudioFocus) { - synchronized (mCmdQueue) { - // This check allows stop to be called multiple times without starting - // a thread that ends up doing nothing. - if (mState != STOP) { - final Command cmd = new Command(); - cmd.requestTime = SystemClock.elapsedRealtime(); - cmd.code = STOP; - cmd.releaseFocus = releaseAudioFocus; - enqueueLocked(cmd); - mState = STOP; - } - } - } - - private void enqueueLocked(final Command cmd) { - mCmdQueue.add(cmd); - if (mThread == null) { - acquireWakeLock(); - mThread = new CmdThread(); - mThread.start(); - } - } - - /** - * We want to hold a wake lock while we do the prepare and play. The stop probably is - * optional, but it won't hurt to have it too. The problem is that if you start a sound - * while you're holding a wake lock (e.g. an alarm starting a notification), you want the - * sound to play, but if the CPU turns off before mThread gets to work, it won't. The - * simplest way to deal with this is to make it so there is a wake lock held while the - * thread is starting or running. You're going to need the WAKE_LOCK permission if you're - * going to call this. - * - * This must be called before the first time play is called. - * - * @hide - */ - public void setUsesWakeLock() { - if (mWakeLock != null || mThread != null) { - // if either of these has happened, we've already played something. - // and our releases will be out of sync. - throw new RuntimeException("assertion failed mWakeLock=" + mWakeLock - + " mThread=" + mThread); - } - final PowerManager pm = (PowerManager) Factory.get().getApplicationContext() - .getSystemService(Context.POWER_SERVICE); - mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, mTag); - } - - private void acquireWakeLock() { - if (mWakeLock != null) { - mWakeLock.acquire(); - } - } - - private void releaseWakeLock() { - if (mWakeLock != null) { - mWakeLock.release(); - } - } -} -