diff options
| author | 2023-09-22 11:11:42 +0000 | |
|---|---|---|
| committer | 2023-09-22 11:11:42 +0000 | |
| commit | f079ed18f9ec9aa46112a8001e043105dd5089fd (patch) | |
| tree | 9bb5d20d4eaab9247c0ad867112531b615574b3e | |
| parent | ac50f74fd87320f04f05fabf398c11bac15764b0 (diff) | |
| parent | 8a1dacc004932283081ad49f2558bea074bbf5bf (diff) | |
Merge "Extract alert handling into NotificationAttentionHelper" into main
6 files changed, 3141 insertions, 60 deletions
diff --git a/core/java/com/android/internal/config/sysui/SystemUiSystemPropertiesFlags.java b/core/java/com/android/internal/config/sysui/SystemUiSystemPropertiesFlags.java index 4a848f68abab..cb2d93474971 100644 --- a/core/java/com/android/internal/config/sysui/SystemUiSystemPropertiesFlags.java +++ b/core/java/com/android/internal/config/sysui/SystemUiSystemPropertiesFlags.java @@ -81,6 +81,11 @@ public class SystemUiSystemPropertiesFlags { public static final Flag PROPAGATE_CHANNEL_UPDATES_TO_CONVERSATIONS = releasedFlag( "persist.sysui.notification.propagate_channel_updates_to_conversations"); + // TODO: b/291907312 - remove feature flags + /** Gating the NMS->NotificationAttentionHelper buzzBeepBlink refactor */ + public static final Flag ENABLE_ATTENTION_HELPER_REFACTOR = devFlag( + "persist.debug.sysui.notification.enable_attention_helper_refactor"); + /** b/301242692: Visit extra URIs used in notifications to prevent security issues. */ public static final Flag VISIT_RISKY_URIS = devFlag( "persist.sysui.notification.visit_risky_uris"); diff --git a/services/core/java/com/android/server/notification/NotificationAttentionHelper.java b/services/core/java/com/android/server/notification/NotificationAttentionHelper.java new file mode 100644 index 000000000000..75a0cf521a1d --- /dev/null +++ b/services/core/java/com/android/server/notification/NotificationAttentionHelper.java @@ -0,0 +1,951 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.notification; + +import static android.app.Notification.FLAG_INSISTENT; +import static android.app.Notification.FLAG_ONLY_ALERT_ONCE; +import static android.app.NotificationManager.IMPORTANCE_MIN; +import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_LIGHTS; +import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_STATUS_BAR; +import static android.media.AudioAttributes.USAGE_NOTIFICATION_RINGTONE; +import static android.service.notification.NotificationListenerService.HINT_HOST_DISABLE_CALL_EFFECTS; +import static android.service.notification.NotificationListenerService.HINT_HOST_DISABLE_EFFECTS; +import static android.service.notification.NotificationListenerService.HINT_HOST_DISABLE_NOTIFICATION_EFFECTS; + +import android.annotation.IntDef; +import android.app.ActivityManager; +import android.app.KeyguardManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.StatusBarManager; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.database.ContentObserver; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.IRingtonePlayer; +import android.net.Uri; +import android.os.Binder; +import android.os.RemoteException; +import android.os.SystemProperties; +import android.os.UserHandle; +import android.os.VibrationEffect; +import android.provider.Settings; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Log; +import android.util.Slog; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; + +import com.android.internal.R; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.config.sysui.SystemUiSystemPropertiesFlags; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.server.EventLogTags; +import com.android.server.lights.LightsManager; +import com.android.server.lights.LogicalLight; + +import java.io.PrintWriter; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * NotificationManagerService helper for handling notification attention effects: + * make noise, vibrate, or flash the LED. + * @hide + */ +public final class NotificationAttentionHelper { + static final String TAG = "NotifAttentionHelper"; + static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + static final boolean DEBUG_INTERRUPTIVENESS = SystemProperties.getBoolean( + "debug.notification.interruptiveness", false); + + private final Context mContext; + private final PackageManager mPackageManager; + private final TelephonyManager mTelephonyManager; + private final NotificationManagerPrivate mNMP; + private final SystemUiSystemPropertiesFlags.FlagResolver mFlagResolver; + private AccessibilityManager mAccessibilityManager; + private KeyguardManager mKeyguardManager; + private AudioManager mAudioManager; + private final LightsManager mLightsManager; + private final NotificationUsageStats mUsageStats; + private final ZenModeHelper mZenModeHelper; + + private VibratorHelper mVibratorHelper; + // The last key in this list owns the hardware. + ArrayList<String> mLights = new ArrayList<>(); + private LogicalLight mNotificationLight; + private LogicalLight mAttentionLight; + + private final boolean mUseAttentionLight; + boolean mHasLight = true; + + private final SettingsObserver mSettingsObserver; + + private boolean mIsAutomotive; + private boolean mNotificationEffectsEnabledForAutomotive; + private boolean mDisableNotificationEffects; + private int mCallState; + private String mSoundNotificationKey; + private String mVibrateNotificationKey; + private boolean mSystemReady; + private boolean mInCallStateOffHook = false; + private boolean mScreenOn = true; + private boolean mUserPresent = false; + boolean mNotificationPulseEnabled; + private final Uri mInCallNotificationUri; + private final AudioAttributes mInCallNotificationAudioAttributes; + private final float mInCallNotificationVolume; + private Binder mCallNotificationToken = null; + + + public NotificationAttentionHelper(Context context, LightsManager lightsManager, + AccessibilityManager accessibilityManager, PackageManager packageManager, + NotificationUsageStats usageStats, + NotificationManagerPrivate notificationManagerPrivate, + ZenModeHelper zenModeHelper, SystemUiSystemPropertiesFlags.FlagResolver flagResolver) { + mContext = context; + mPackageManager = packageManager; + mTelephonyManager = context.getSystemService(TelephonyManager.class); + mAccessibilityManager = accessibilityManager; + mLightsManager = lightsManager; + mNMP = notificationManagerPrivate; + mUsageStats = usageStats; + mZenModeHelper = zenModeHelper; + mFlagResolver = flagResolver; + + mVibratorHelper = new VibratorHelper(context); + + mNotificationLight = mLightsManager.getLight(LightsManager.LIGHT_ID_NOTIFICATIONS); + mAttentionLight = mLightsManager.getLight(LightsManager.LIGHT_ID_ATTENTION); + + Resources resources = context.getResources(); + mUseAttentionLight = resources.getBoolean(R.bool.config_useAttentionLight); + mHasLight = + resources.getBoolean(com.android.internal.R.bool.config_intrusiveNotificationLed); + + // Don't start allowing notifications until the setup wizard has run once. + // After that, including subsequent boots, init with notifications turned on. + // This works on the first boot because the setup wizard will toggle this + // flag at least once and we'll go back to 0 after that. + if (Settings.Global.getInt(context.getContentResolver(), + Settings.Global.DEVICE_PROVISIONED, 0) == 0) { + mDisableNotificationEffects = true; + } + + mInCallNotificationUri = Uri.parse( + "file://" + resources.getString(R.string.config_inCallNotificationSound)); + mInCallNotificationAudioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build(); + mInCallNotificationVolume = resources.getFloat(R.dimen.config_inCallNotificationVolume); + + mSettingsObserver = new SettingsObserver(); + } + + public void onSystemReady() { + mSystemReady = true; + + mIsAutomotive = mPackageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE, 0); + mNotificationEffectsEnabledForAutomotive = mContext.getResources().getBoolean( + R.bool.config_enableServerNotificationEffectsForAutomotive); + + mAudioManager = mContext.getSystemService(AudioManager.class); + mKeyguardManager = mContext.getSystemService(KeyguardManager.class); + + registerBroadcastListeners(); + } + + private void registerBroadcastListeners() { + if (mPackageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { + mTelephonyManager.listen(new PhoneStateListener() { + @Override + public void onCallStateChanged(int state, String incomingNumber) { + if (mCallState == state) return; + if (DEBUG) Slog.d(TAG, "Call state changed: " + callStateToString(state)); + mCallState = state; + } + }, PhoneStateListener.LISTEN_CALL_STATE); + } + + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_SCREEN_ON); + filter.addAction(Intent.ACTION_SCREEN_OFF); + filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED); + filter.addAction(Intent.ACTION_USER_PRESENT); + mContext.registerReceiverAsUser(mIntentReceiver, UserHandle.ALL, filter, null, null); + + mContext.getContentResolver().registerContentObserver( + SettingsObserver.NOTIFICATION_LIGHT_PULSE_URI, false, mSettingsObserver, + UserHandle.USER_ALL); + } + + @VisibleForTesting + /** + * Determine whether this notification should attempt to make noise, vibrate, or flash the LED + * @return buzzBeepBlink - bitfield (buzz ? 1 : 0) | (beep ? 2 : 0) | (blink ? 4 : 0) | + * (polite_attenuated ? 8 : 0) | (polite_muted ? 16 : 0) + */ + int buzzBeepBlinkLocked(NotificationRecord record, Signals signals) { + if (mIsAutomotive && !mNotificationEffectsEnabledForAutomotive) { + return 0; + } + boolean buzz = false; + boolean beep = false; + boolean blink = false; + + final String key = record.getKey(); + + if (DEBUG) { + Log.d(TAG, "buzzBeepBlinkLocked " + record); + } + + // Should this notification make noise, vibe, or use the LED? + final boolean aboveThreshold = + mIsAutomotive + ? record.getImportance() > NotificationManager.IMPORTANCE_DEFAULT + : record.getImportance() >= NotificationManager.IMPORTANCE_DEFAULT; + // Remember if this notification already owns the notification channels. + boolean wasBeep = key != null && key.equals(mSoundNotificationKey); + boolean wasBuzz = key != null && key.equals(mVibrateNotificationKey); + // These are set inside the conditional if the notification is allowed to make noise. + boolean hasValidVibrate = false; + boolean hasValidSound = false; + boolean sentAccessibilityEvent = false; + + // If the notification will appear in the status bar, it should send an accessibility event + final boolean suppressedByDnd = record.isIntercepted() + && (record.getSuppressedVisualEffects() & SUPPRESSED_EFFECT_STATUS_BAR) != 0; + if (!record.isUpdate + && record.getImportance() > IMPORTANCE_MIN + && !suppressedByDnd + && isNotificationForCurrentUser(record, signals)) { + sendAccessibilityEvent(record); + sentAccessibilityEvent = true; + } + + if (aboveThreshold && isNotificationForCurrentUser(record, signals)) { + if (mSystemReady && mAudioManager != null) { + Uri soundUri = record.getSound(); + hasValidSound = soundUri != null && !Uri.EMPTY.equals(soundUri); + VibrationEffect vibration = record.getVibration(); + // Demote sound to vibration if vibration missing & phone in vibration mode. + if (vibration == null + && hasValidSound + && (mAudioManager.getRingerModeInternal() + == AudioManager.RINGER_MODE_VIBRATE) + && mAudioManager.getStreamVolume( + AudioAttributes.toLegacyStreamType(record.getAudioAttributes())) == 0) { + boolean insistent = (record.getFlags() & Notification.FLAG_INSISTENT) != 0; + vibration = mVibratorHelper.createFallbackVibration(insistent); + } + hasValidVibrate = vibration != null; + boolean hasAudibleAlert = hasValidSound || hasValidVibrate; + if (hasAudibleAlert && !shouldMuteNotificationLocked(record, signals)) { + if (!sentAccessibilityEvent) { + sendAccessibilityEvent(record); + sentAccessibilityEvent = true; + } + if (DEBUG) Slog.v(TAG, "Interrupting!"); + boolean isInsistentUpdate = isInsistentUpdate(record); + if (hasValidSound) { + if (isInsistentUpdate) { + // don't reset insistent sound, it's jarring + beep = true; + } else { + if (isInCall()) { + playInCallNotification(); + beep = true; + } else { + beep = playSound(record, soundUri); + } + if (beep) { + mSoundNotificationKey = key; + } + } + } + + final boolean ringerModeSilent = + mAudioManager.getRingerModeInternal() + == AudioManager.RINGER_MODE_SILENT; + if (!isInCall() && hasValidVibrate && !ringerModeSilent) { + if (isInsistentUpdate) { + buzz = true; + } else { + buzz = playVibration(record, vibration, hasValidSound); + if (buzz) { + mVibrateNotificationKey = key; + } + } + } + + // Try to start flash notification event whenever an audible and non-suppressed + // notification is received + mAccessibilityManager.startFlashNotificationEvent(mContext, + AccessibilityManager.FLASH_REASON_NOTIFICATION, + record.getSbn().getPackageName()); + + } else if ((record.getFlags() & Notification.FLAG_INSISTENT) != 0) { + hasValidSound = false; + } + } + } + // If a notification is updated to remove the actively playing sound or vibrate, + // cancel that feedback now + if (wasBeep && !hasValidSound) { + clearSoundLocked(); + } + if (wasBuzz && !hasValidVibrate) { + clearVibrateLocked(); + } + + // light + // release the light + boolean wasShowLights = mLights.remove(key); + if (canShowLightsLocked(record, signals, aboveThreshold)) { + mLights.add(key); + updateLightsLocked(); + if (mUseAttentionLight && mAttentionLight != null) { + mAttentionLight.pulse(); + } + blink = true; + } else if (wasShowLights) { + updateLightsLocked(); + } + final int buzzBeepBlink = + (buzz ? 1 : 0) | (beep ? 2 : 0) | (blink ? 4 : 0); + if (buzzBeepBlink > 0) { + // Ignore summary updates because we don't display most of the information. + if (record.getSbn().isGroup() && record.getSbn().getNotification().isGroupSummary()) { + if (DEBUG_INTERRUPTIVENESS) { + Slog.v(TAG, "INTERRUPTIVENESS: " + + record.getKey() + " is not interruptive: summary"); + } + } else if (record.canBubble()) { + if (DEBUG_INTERRUPTIVENESS) { + Slog.v(TAG, "INTERRUPTIVENESS: " + + record.getKey() + " is not interruptive: bubble"); + } + } else { + record.setInterruptive(true); + if (DEBUG_INTERRUPTIVENESS) { + Slog.v(TAG, "INTERRUPTIVENESS: " + + record.getKey() + " is interruptive: alerted"); + } + } + MetricsLogger.action(record.getLogMaker() + .setCategory(MetricsEvent.NOTIFICATION_ALERT) + .setType(MetricsEvent.TYPE_OPEN) + .setSubtype(buzzBeepBlink)); + EventLogTags.writeNotificationAlert(key, buzz ? 1 : 0, beep ? 1 : 0, blink ? 1 : 0); + } + record.setAudiblyAlerted(buzz || beep); + + return buzzBeepBlink; + } + + boolean isInsistentUpdate(final NotificationRecord record) { + return (Objects.equals(record.getKey(), mSoundNotificationKey) + || Objects.equals(record.getKey(), mVibrateNotificationKey)) + && isCurrentlyInsistent(); + } + + boolean isCurrentlyInsistent() { + return isLoopingRingtoneNotification(mNMP.getNotificationByKey(mSoundNotificationKey)) + || isLoopingRingtoneNotification( + mNMP.getNotificationByKey(mVibrateNotificationKey)); + } + + boolean shouldMuteNotificationLocked(final NotificationRecord record, final Signals signals) { + // Suppressed because it's a silent update + final Notification notification = record.getNotification(); + if (record.isUpdate && (notification.flags & FLAG_ONLY_ALERT_ONCE) != 0) { + return true; + } + + // Suppressed because a user manually unsnoozed something (or similar) + if (record.shouldPostSilently()) { + return true; + } + + // muted by listener + final String disableEffects = disableNotificationEffects(record, signals.listenerHints); + if (disableEffects != null) { + ZenLog.traceDisableEffects(record, disableEffects); + return true; + } + + // suppressed due to DND + if (record.isIntercepted()) { + return true; + } + + // Suppressed because another notification in its group handles alerting + if (record.getSbn().isGroup()) { + if (notification.suppressAlertingDueToGrouping()) { + return true; + } + } + + // Suppressed for being too recently noisy + final String pkg = record.getSbn().getPackageName(); + if (mUsageStats.isAlertRateLimited(pkg)) { + Slog.e(TAG, "Muting recently noisy " + record.getKey()); + return true; + } + + // A different looping ringtone, such as an incoming call is playing + if (isCurrentlyInsistent() && !isInsistentUpdate(record)) { + return true; + } + + // Suppressed since it's a non-interruptive update to a bubble-suppressed notification + final boolean isBubbleOrOverflowed = record.canBubble() && (record.isFlagBubbleRemoved() + || record.getNotification().isBubbleNotification()); + if (record.isUpdate && !record.isInterruptive() && isBubbleOrOverflowed + && record.getNotification().getBubbleMetadata() != null) { + if (record.getNotification().getBubbleMetadata().isNotificationSuppressed()) { + return true; + } + } + + return false; + } + + private boolean isLoopingRingtoneNotification(final NotificationRecord playingRecord) { + if (playingRecord != null) { + if (playingRecord.getAudioAttributes().getUsage() == USAGE_NOTIFICATION_RINGTONE + && (playingRecord.getNotification().flags & FLAG_INSISTENT) != 0) { + return true; + } + } + return false; + } + + private boolean playSound(final NotificationRecord record, Uri soundUri) { + boolean looping = (record.getNotification().flags & FLAG_INSISTENT) != 0; + // play notifications if there is no user of exclusive audio focus + // and the stream volume is not 0 (non-zero volume implies not silenced by SILENT or + // VIBRATE ringer mode) + if (!mAudioManager.isAudioFocusExclusive() + && (mAudioManager.getStreamVolume( + AudioAttributes.toLegacyStreamType(record.getAudioAttributes())) != 0)) { + final long identity = Binder.clearCallingIdentity(); + try { + final IRingtonePlayer player = mAudioManager.getRingtonePlayer(); + if (player != null) { + if (DEBUG) { + Slog.v(TAG, "Playing sound " + soundUri + " with attributes " + + record.getAudioAttributes()); + } + player.playAsync(soundUri, record.getSbn().getUser(), looping, + record.getAudioAttributes()); + return true; + } + } catch (RemoteException e) { + Log.e(TAG, "Failed playSound: " + e); + } finally { + Binder.restoreCallingIdentity(identity); + } + } + return false; + } + + private boolean playVibration(final NotificationRecord record, final VibrationEffect effect, + boolean delayVibForSound) { + // Escalate privileges so we can use the vibrator even if the + // notifying app does not have the VIBRATE permission. + final long identity = Binder.clearCallingIdentity(); + try { + if (delayVibForSound) { + new Thread(() -> { + // delay the vibration by the same amount as the notification sound + final int waitMs = mAudioManager.getFocusRampTimeMs( + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, + record.getAudioAttributes()); + if (DEBUG) { + Slog.v(TAG, "Delaying vibration for notification " + + record.getKey() + " by " + waitMs + "ms"); + } + try { + Thread.sleep(waitMs); + } catch (InterruptedException e) { } + // Notifications might be canceled before it actually vibrates due to waitMs, + // so need to check that the notification is still valid for vibrate. + if (mNMP.getNotificationByKey(record.getKey()) != null) { + if (record.getKey().equals(mVibrateNotificationKey)) { + vibrate(record, effect, true); + } else { + if (DEBUG) { + Slog.v(TAG, "No vibration for notification " + + record.getKey() + ": a new notification is " + + "vibrating, or effects were cleared while waiting"); + } + } + } else { + Slog.w(TAG, "No vibration for canceled notification " + + record.getKey()); + } + }).start(); + } else { + vibrate(record, effect, false); + } + return true; + } finally { + Binder.restoreCallingIdentity(identity); + } + } + + private void vibrate(NotificationRecord record, VibrationEffect effect, boolean delayed) { + // We need to vibrate as "android" so we can breakthrough DND. VibratorManagerService + // doesn't have a concept of vibrating on an app's behalf, so add the app information + // to the reason so we can still debug from bugreports + String reason = "Notification (" + record.getSbn().getOpPkg() + " " + + record.getSbn().getUid() + ") " + (delayed ? "(Delayed)" : ""); + mVibratorHelper.vibrate(effect, record.getAudioAttributes(), reason); + } + + void playInCallNotification() { + // TODO: Should we apply politeness to mInCallNotificationVolume ? + final ContentResolver cr = mContext.getContentResolver(); + if (mAudioManager.getRingerModeInternal() == AudioManager.RINGER_MODE_NORMAL + && Settings.Secure.getIntForUser(cr, + Settings.Secure.IN_CALL_NOTIFICATION_ENABLED, 1, cr.getUserId()) != 0) { + new Thread() { + @Override + public void run() { + final long identity = Binder.clearCallingIdentity(); + try { + final IRingtonePlayer player = mAudioManager.getRingtonePlayer(); + if (player != null) { + if (mCallNotificationToken != null) { + player.stop(mCallNotificationToken); + } + mCallNotificationToken = new Binder(); + player.play(mCallNotificationToken, mInCallNotificationUri, + mInCallNotificationAudioAttributes, + mInCallNotificationVolume, false); + } + } catch (RemoteException e) { + Log.e(TAG, "Failed playInCallNotification: " + e); + } finally { + Binder.restoreCallingIdentity(identity); + } + } + }.start(); + } + } + + void clearSoundLocked() { + mSoundNotificationKey = null; + final long identity = Binder.clearCallingIdentity(); + try { + final IRingtonePlayer player = mAudioManager.getRingtonePlayer(); + if (player != null) { + player.stopAsync(); + } + } catch (RemoteException e) { + Log.e(TAG, "Failed clearSoundLocked: " + e); + } finally { + Binder.restoreCallingIdentity(identity); + } + } + + void clearVibrateLocked() { + mVibrateNotificationKey = null; + final long identity = Binder.clearCallingIdentity(); + try { + mVibratorHelper.cancelVibration(); + } finally { + Binder.restoreCallingIdentity(identity); + } + } + + private void clearLightsLocked() { + // light + mLights.clear(); + updateLightsLocked(); + } + + public void clearEffectsLocked(String key) { + if (key.equals(mSoundNotificationKey)) { + clearSoundLocked(); + } + if (key.equals(mVibrateNotificationKey)) { + clearVibrateLocked(); + } + boolean removed = mLights.remove(key); + if (removed) { + updateLightsLocked(); + } + } + + public void clearAttentionEffects() { + clearSoundLocked(); + clearVibrateLocked(); + clearLightsLocked(); + } + + void updateLightsLocked() { + if (mNotificationLight == null) { + return; + } + + // handle notification lights + NotificationRecord ledNotification = null; + while (ledNotification == null && !mLights.isEmpty()) { + final String owner = mLights.get(mLights.size() - 1); + ledNotification = mNMP.getNotificationByKey(owner); + if (ledNotification == null) { + Slog.wtfStack(TAG, "LED Notification does not exist: " + owner); + mLights.remove(owner); + } + } + + // Don't flash while we are in a call or screen is on + if (ledNotification == null || isInCall() || mScreenOn) { + mNotificationLight.turnOff(); + } else { + NotificationRecord.Light light = ledNotification.getLight(); + if (light != null && mNotificationPulseEnabled) { + // pulse repeatedly + mNotificationLight.setFlashing(light.color, LogicalLight.LIGHT_FLASH_TIMED, + light.onMs, light.offMs); + } + } + } + + boolean canShowLightsLocked(final NotificationRecord record, final Signals signals, + boolean aboveThreshold) { + // device lacks light + if (!mHasLight) { + return false; + } + // user turned lights off globally + if (!mNotificationPulseEnabled) { + return false; + } + // the notification/channel has no light + if (record.getLight() == null) { + return false; + } + // unimportant notification + if (!aboveThreshold) { + return false; + } + // suppressed due to DND + if ((record.getSuppressedVisualEffects() & SUPPRESSED_EFFECT_LIGHTS) != 0) { + return false; + } + // Suppressed because it's a silent update + final Notification notification = record.getNotification(); + if (record.isUpdate && (notification.flags & FLAG_ONLY_ALERT_ONCE) != 0) { + return false; + } + // Suppressed because another notification in its group handles alerting + if (record.getSbn().isGroup() && record.getNotification().suppressAlertingDueToGrouping()) { + return false; + } + // not if in call + if (isInCall()) { + return false; + } + // check current user + if (!isNotificationForCurrentUser(record, signals)) { + return false; + } + // Light, but only when the screen is off + return true; + } + + private String disableNotificationEffects(NotificationRecord record, int listenerHints) { + if (mDisableNotificationEffects) { + return "booleanState"; + } + + if ((listenerHints & HINT_HOST_DISABLE_EFFECTS) != 0) { + return "listenerHints"; + } + if (record != null && record.getAudioAttributes() != null) { + if ((listenerHints & HINT_HOST_DISABLE_NOTIFICATION_EFFECTS) != 0) { + if (record.getAudioAttributes().getUsage() + != AudioAttributes.USAGE_NOTIFICATION_RINGTONE) { + return "listenerNoti"; + } + } + if ((listenerHints & HINT_HOST_DISABLE_CALL_EFFECTS) != 0) { + if (record.getAudioAttributes().getUsage() + == AudioAttributes.USAGE_NOTIFICATION_RINGTONE) { + return "listenerCall"; + } + } + } + if (mCallState != TelephonyManager.CALL_STATE_IDLE && !mZenModeHelper.isCall(record)) { + return "callState"; + } + + return null; + } + + public void updateDisableNotificationEffectsLocked(int status) { + mDisableNotificationEffects = + (status & StatusBarManager.DISABLE_NOTIFICATION_ALERTS) != 0; + //if (disableNotificationEffects(null) != null) { + if (mDisableNotificationEffects) { + // cancel whatever is going on + clearAttentionEffects(); + } + } + + private boolean isInCall() { + if (mInCallStateOffHook) { + return true; + } + int audioMode = mAudioManager.getMode(); + if (audioMode == AudioManager.MODE_IN_CALL + || audioMode == AudioManager.MODE_IN_COMMUNICATION) { + return true; + } + return false; + } + + private static String callStateToString(int state) { + switch (state) { + case TelephonyManager.CALL_STATE_IDLE: return "CALL_STATE_IDLE"; + case TelephonyManager.CALL_STATE_RINGING: return "CALL_STATE_RINGING"; + case TelephonyManager.CALL_STATE_OFFHOOK: return "CALL_STATE_OFFHOOK"; + default: return "CALL_STATE_UNKNOWN_" + state; + } + } + + private boolean isNotificationForCurrentUser(final NotificationRecord record, + final Signals signals) { + final int currentUser; + final long token = Binder.clearCallingIdentity(); + try { + currentUser = ActivityManager.getCurrentUser(); + } finally { + Binder.restoreCallingIdentity(token); + } + return (record.getUserId() == UserHandle.USER_ALL || record.getUserId() == currentUser + || signals.isCurrentProfile); + } + + void sendAccessibilityEvent(NotificationRecord record) { + if (!mAccessibilityManager.isEnabled()) { + return; + } + + final Notification notification = record.getNotification(); + final CharSequence packageName = record.getSbn().getPackageName(); + final AccessibilityEvent event = + AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED); + event.setPackageName(packageName); + event.setClassName(Notification.class.getName()); + final int visibilityOverride = record.getPackageVisibilityOverride(); + final int notifVisibility = visibilityOverride == NotificationManager.VISIBILITY_NO_OVERRIDE + ? notification.visibility : visibilityOverride; + final int userId = record.getUser().getIdentifier(); + final boolean needPublic = userId >= 0 && mKeyguardManager.isDeviceLocked(userId); + if (needPublic && notifVisibility != Notification.VISIBILITY_PUBLIC) { + // Emit the public version if we're on the lockscreen and this notification isn't + // publicly visible. + event.setParcelableData(notification.publicVersion); + } else { + event.setParcelableData(notification); + } + final CharSequence tickerText = notification.tickerText; + if (!TextUtils.isEmpty(tickerText)) { + event.getText().add(tickerText); + } + + mAccessibilityManager.sendAccessibilityEvent(event); + } + + public void dump(PrintWriter pw, String prefix, NotificationManagerService.DumpFilter filter) { + pw.println("\n Notification attention state:"); + pw.print(prefix); + pw.println(" mSoundNotificationKey=" + mSoundNotificationKey); + pw.print(prefix); + pw.println(" mVibrateNotificationKey=" + mVibrateNotificationKey); + pw.print(prefix); + pw.println(" mDisableNotificationEffects=" + mDisableNotificationEffects); + pw.print(prefix); + pw.println(" mCallState=" + callStateToString(mCallState)); + pw.print(prefix); + pw.println(" mSystemReady=" + mSystemReady); + pw.print(prefix); + pw.println(" mNotificationPulseEnabled=" + mNotificationPulseEnabled); + + int N = mLights.size(); + if (N > 0) { + pw.print(prefix); + pw.println(" Lights List:"); + for (int i=0; i<N; i++) { + if (i == N - 1) { + pw.print(" > "); + } else { + pw.print(" "); + } + pw.println(mLights.get(i)); + } + pw.println(" "); + } + + } + + // External signals set from NMS + public static class Signals { + private final boolean isCurrentProfile; + private final int listenerHints; + + public Signals(boolean isCurrentProfile, int listenerHints) { + this.isCurrentProfile = isCurrentProfile; + this.listenerHints = listenerHints; + } + } + + //====================== Observers ============================= + private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + + if (action.equals(Intent.ACTION_SCREEN_ON)) { + // Keep track of screen on/off state, but do not turn off the notification light + // until user passes through the lock screen or views the notification. + mScreenOn = true; + updateLightsLocked(); + } else if (action.equals(Intent.ACTION_SCREEN_OFF)) { + mScreenOn = false; + mUserPresent = false; + updateLightsLocked(); + } else if (action.equals(TelephonyManager.ACTION_PHONE_STATE_CHANGED)) { + mInCallStateOffHook = TelephonyManager.EXTRA_STATE_OFFHOOK + .equals(intent.getStringExtra(TelephonyManager.EXTRA_STATE)); + updateLightsLocked(); + } else if (action.equals(Intent.ACTION_USER_PRESENT)) { + mUserPresent = true; + // turn off LED when user passes through lock screen + if (mNotificationLight != null) { + mNotificationLight.turnOff(); + } + } + } + }; + + private final class SettingsObserver extends ContentObserver { + + private static final Uri NOTIFICATION_LIGHT_PULSE_URI = Settings.System.getUriFor( + Settings.System.NOTIFICATION_LIGHT_PULSE); + public SettingsObserver() { + super(null); + } + + @Override + public void onChange(boolean selfChange, Uri uri) { + if (NOTIFICATION_LIGHT_PULSE_URI.equals(uri)) { + boolean pulseEnabled = Settings.System.getIntForUser( + mContext.getContentResolver(), + Settings.System.NOTIFICATION_LIGHT_PULSE, 0, + UserHandle.USER_CURRENT) + != 0; + if (mNotificationPulseEnabled != pulseEnabled) { + mNotificationPulseEnabled = pulseEnabled; + updateLightsLocked(); + } + } + } + } + + + //TODO: cleanup most (all?) of these + //======================= FOR TESTS ===================== + @VisibleForTesting + void setIsAutomotive(boolean isAutomotive) { + mIsAutomotive = isAutomotive; + } + + @VisibleForTesting + void setNotificationEffectsEnabledForAutomotive(boolean isEnabled) { + mNotificationEffectsEnabledForAutomotive = isEnabled; + } + + @VisibleForTesting + void setSystemReady(boolean systemReady) { + mSystemReady = systemReady; + } + + @VisibleForTesting + void setKeyguardManager(KeyguardManager keyguardManager) { + mKeyguardManager = keyguardManager; + } + + @VisibleForTesting + void setAccessibilityManager(AccessibilityManager am) { + mAccessibilityManager = am; + } + + @VisibleForTesting + VibratorHelper getVibratorHelper() { + return mVibratorHelper; + } + + @VisibleForTesting + void setVibratorHelper(VibratorHelper helper) { + mVibratorHelper = helper; + } + + @VisibleForTesting + void setScreenOn(boolean on) { + mScreenOn = on; + } + + @VisibleForTesting + void setLights(LogicalLight light) { + mNotificationLight = light; + mAttentionLight = light; + mNotificationPulseEnabled = true; + mHasLight = true; + } + + @VisibleForTesting + void setAudioManager(AudioManager audioManager) { + mAudioManager = audioManager; + } + + @VisibleForTesting + void setInCallStateOffHook(boolean inCallStateOffHook) { + mInCallStateOffHook = inCallStateOffHook; + } + +} diff --git a/services/core/java/com/android/server/notification/NotificationManagerPrivate.java b/services/core/java/com/android/server/notification/NotificationManagerPrivate.java new file mode 100644 index 000000000000..2cc63ebfc962 --- /dev/null +++ b/services/core/java/com/android/server/notification/NotificationManagerPrivate.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.notification; + +import android.annotation.Nullable; + +/** + * Interface that allows components (helpers) to access NotificationRecords + * without an explicit reference to NotificationManagerService. + */ +interface NotificationManagerPrivate { + @Nullable + NotificationRecord getNotificationByKey(String key); +} diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java index bada8165766c..802dfb182297 100755 --- a/services/core/java/com/android/server/notification/NotificationManagerService.java +++ b/services/core/java/com/android/server/notification/NotificationManagerService.java @@ -287,6 +287,7 @@ import com.android.internal.annotations.VisibleForTesting; import com.android.internal.compat.IPlatformCompat; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.config.sysui.SystemUiSystemPropertiesFlags; +import com.android.internal.config.sysui.SystemUiSystemPropertiesFlags.NotificationFlags; import com.android.internal.logging.InstanceId; import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; @@ -315,6 +316,7 @@ import com.android.server.SystemService; import com.android.server.job.JobSchedulerInternal; import com.android.server.lights.LightsManager; import com.android.server.lights.LogicalLight; +import com.android.server.notification.Flags; import com.android.server.notification.ManagedServices.ManagedServiceInfo; import com.android.server.notification.ManagedServices.UserProfiles; import com.android.server.notification.toast.CustomToastRecord; @@ -684,6 +686,7 @@ public class NotificationManagerService extends SystemService { private boolean mIsAutomotive; private boolean mNotificationEffectsEnabledForAutomotive; private DeviceConfig.OnPropertiesChangedListener mDeviceConfigChangedListener; + protected NotificationAttentionHelper mAttentionHelper; private int mWarnRemoteViewsSizeBytes; private int mStripRemoteViewsSizeBytes; @@ -1167,12 +1170,16 @@ public class NotificationManagerService extends SystemService { @Override public void onSetDisabled(int status) { synchronized (mNotificationLock) { - mDisableNotificationEffects = - (status & StatusBarManager.DISABLE_NOTIFICATION_ALERTS) != 0; - if (disableNotificationEffects(null) != null) { - // cancel whatever's going on - clearSoundLocked(); - clearVibrateLocked(); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.updateDisableNotificationEffectsLocked(status); + } else { + mDisableNotificationEffects = + (status & StatusBarManager.DISABLE_NOTIFICATION_ALERTS) != 0; + if (disableNotificationEffects(null) != null) { + // cancel whatever's going on + clearSoundLocked(); + clearVibrateLocked(); + } } } } @@ -1309,9 +1316,13 @@ public class NotificationManagerService extends SystemService { public void clearEffects() { synchronized (mNotificationLock) { if (DBG) Slog.d(TAG, "clearEffects"); - clearSoundLocked(); - clearVibrateLocked(); - clearLightsLocked(); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.clearAttentionEffects(); + } else { + clearSoundLocked(); + clearVibrateLocked(); + clearLightsLocked(); + } } } @@ -1534,7 +1545,12 @@ public class NotificationManagerService extends SystemService { int changedFlags = data.getFlags() ^ flags; if ((changedFlags & FLAG_SUPPRESS_NOTIFICATION) != 0) { // Suppress notification flag changed, clear any effects - clearEffectsLocked(key); + if (mFlagResolver.isEnabled( + NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.clearEffectsLocked(key); + } else { + clearEffectsLocked(key); + } } data.setFlags(flags); // Shouldn't alert again just because of a flag change. @@ -1626,6 +1642,12 @@ public class NotificationManagerService extends SystemService { }; + NotificationManagerPrivate mNotificationManagerPrivate = key -> { + synchronized (mNotificationLock) { + return mNotificationsByKey.get(key); + } + }; + @VisibleForTesting void logSmartSuggestionsVisible(NotificationRecord r, int notificationLocation) { // If the newly visible notification has smart suggestions @@ -1873,19 +1895,28 @@ public class NotificationManagerService extends SystemService { public void onReceive(Context context, Intent intent) { String action = intent.getAction(); - if (action.equals(Intent.ACTION_SCREEN_ON)) { - // Keep track of screen on/off state, but do not turn off the notification light - // until user passes through the lock screen or views the notification. - mScreenOn = true; - updateNotificationPulse(); - } else if (action.equals(Intent.ACTION_SCREEN_OFF)) { - mScreenOn = false; - updateNotificationPulse(); - } else if (action.equals(TelephonyManager.ACTION_PHONE_STATE_CHANGED)) { - mInCallStateOffHook = TelephonyManager.EXTRA_STATE_OFFHOOK + if (!mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + if (action.equals(Intent.ACTION_SCREEN_ON)) { + // Keep track of screen on/off state, but do not turn off the notification light + // until user passes through the lock screen or views the notification. + mScreenOn = true; + updateNotificationPulse(); + } else if (action.equals(Intent.ACTION_SCREEN_OFF)) { + mScreenOn = false; + updateNotificationPulse(); + } else if (action.equals(TelephonyManager.ACTION_PHONE_STATE_CHANGED)) { + mInCallStateOffHook = TelephonyManager.EXTRA_STATE_OFFHOOK .equals(intent.getStringExtra(TelephonyManager.EXTRA_STATE)); - updateNotificationPulse(); - } else if (action.equals(Intent.ACTION_USER_STOPPED)) { + updateNotificationPulse(); + } else if (action.equals(Intent.ACTION_USER_PRESENT)) { + // turn off LED when user passes through lock screen + if (mNotificationLight != null) { + mNotificationLight.turnOff(); + } + } + } + + if (action.equals(Intent.ACTION_USER_STOPPED)) { int userHandle = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1); if (userHandle >= 0) { cancelAllNotificationsInt(MY_UID, MY_PID, null, null, 0, 0, userHandle, @@ -1898,11 +1929,6 @@ public class NotificationManagerService extends SystemService { REASON_PROFILE_TURNED_OFF); mSnoozeHelper.clearData(userHandle); } - } else if (action.equals(Intent.ACTION_USER_PRESENT)) { - // turn off LED when user passes through lock screen - if (mNotificationLight != null) { - mNotificationLight.turnOff(); - } } else if (action.equals(Intent.ACTION_USER_SWITCHED)) { final int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, USER_NULL); mUserProfiles.updateCache(context); @@ -1976,8 +2002,10 @@ public class NotificationManagerService extends SystemService { ContentResolver resolver = getContext().getContentResolver(); resolver.registerContentObserver(NOTIFICATION_BADGING_URI, false, this, UserHandle.USER_ALL); - resolver.registerContentObserver(NOTIFICATION_LIGHT_PULSE_URI, + if (!mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + resolver.registerContentObserver(NOTIFICATION_LIGHT_PULSE_URI, false, this, UserHandle.USER_ALL); + } resolver.registerContentObserver(NOTIFICATION_RATE_LIMIT_URI, false, this, UserHandle.USER_ALL); resolver.registerContentObserver(NOTIFICATION_BUBBLES_URI, @@ -2000,13 +2028,15 @@ public class NotificationManagerService extends SystemService { public void update(Uri uri) { ContentResolver resolver = getContext().getContentResolver(); - if (uri == null || NOTIFICATION_LIGHT_PULSE_URI.equals(uri)) { - boolean pulseEnabled = Settings.System.getIntForUser(resolver, - Settings.System.NOTIFICATION_LIGHT_PULSE, 0, UserHandle.USER_CURRENT) + if (!mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + if (uri == null || NOTIFICATION_LIGHT_PULSE_URI.equals(uri)) { + boolean pulseEnabled = Settings.System.getIntForUser(resolver, + Settings.System.NOTIFICATION_LIGHT_PULSE, 0, UserHandle.USER_CURRENT) != 0; - if (mNotificationPulseEnabled != pulseEnabled) { - mNotificationPulseEnabled = pulseEnabled; - updateNotificationPulse(); + if (mNotificationPulseEnabled != pulseEnabled) { + mNotificationPulseEnabled = pulseEnabled; + updateNotificationPulse(); + } } } if (uri == null || NOTIFICATION_RATE_LIMIT_URI.equals(uri)) { @@ -2491,14 +2521,22 @@ public class NotificationManagerService extends SystemService { mToastRateLimiter = toastRateLimiter; + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper = new NotificationAttentionHelper(getContext(), lightsManager, + mAccessibilityManager, mPackageManagerClient, usageStats, + mNotificationManagerPrivate, mZenModeHelper, flagResolver); + } + // register for various Intents. // If this is called within a test, make sure to unregister the intent receivers by // calling onDestroy() IntentFilter filter = new IntentFilter(); - filter.addAction(Intent.ACTION_SCREEN_ON); - filter.addAction(Intent.ACTION_SCREEN_OFF); - filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED); - filter.addAction(Intent.ACTION_USER_PRESENT); + if (!mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + filter.addAction(Intent.ACTION_SCREEN_ON); + filter.addAction(Intent.ACTION_SCREEN_OFF); + filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED); + filter.addAction(Intent.ACTION_USER_PRESENT); + } filter.addAction(Intent.ACTION_USER_STOPPED); filter.addAction(Intent.ACTION_USER_SWITCHED); filter.addAction(Intent.ACTION_USER_ADDED); @@ -2818,6 +2856,9 @@ public class NotificationManagerService extends SystemService { } registerNotificationPreferencesPullers(); new LockPatternUtils(getContext()).registerStrongAuthTracker(mStrongAuthTracker); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.onSystemReady(); + } } else if (phase == SystemService.PHASE_THIRD_PARTY_APPS_CAN_START) { // This observer will force an update when observe is called, causing us to // bind to listener services. @@ -6375,6 +6416,9 @@ public class NotificationManagerService extends SystemService { pw.println(" mMaxPackageEnqueueRate=" + mMaxPackageEnqueueRate); pw.println(" hideSilentStatusBar=" + mPreferencesHelper.shouldHideSilentStatusIcons()); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.dump(pw, " ", filter); + } } pw.println(" mArchive=" + mArchive.toString()); mArchive.dumpImpl(pw, filter); @@ -7632,7 +7676,11 @@ public class NotificationManagerService extends SystemService { boolean wasPosted = removeFromNotificationListsLocked(r); cancelNotificationLocked(r, false, REASON_SNOOZED, wasPosted, null, SystemClock.elapsedRealtime()); - updateLightsLocked(); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.updateLightsLocked(); + } else { + updateLightsLocked(); + } if (isSnoozable(r)) { if (mSnoozeCriterionId != null) { mAssistants.notifyAssistantSnoozedLocked(r, mSnoozeCriterionId); @@ -7761,7 +7809,11 @@ public class NotificationManagerService extends SystemService { cancelGroupChildrenLocked(r, mCallingUid, mCallingPid, listenerName, mSendDelete, childrenFlagChecker, mReason, mCancellationElapsedTimeMs); - updateLightsLocked(); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.updateLightsLocked(); + } else { + updateLightsLocked(); + } if (mShortcutHelper != null) { mShortcutHelper.maybeListenForShortcutChangesForBubbles(r, true /* isRemoved */, @@ -8054,7 +8106,14 @@ public class NotificationManagerService extends SystemService { int buzzBeepBlinkLoggingCode = 0; if (!r.isHidden()) { - buzzBeepBlinkLoggingCode = buzzBeepBlinkLocked(r); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + buzzBeepBlinkLoggingCode = mAttentionHelper.buzzBeepBlinkLocked(r, + new NotificationAttentionHelper.Signals( + mUserProfiles.isCurrentProfile(r.getUserId()), + mListenerHints)); + } else { + buzzBeepBlinkLoggingCode = buzzBeepBlinkLocked(r); + } } if (notification.getSmallIcon() != null) { @@ -9034,7 +9093,13 @@ public class NotificationManagerService extends SystemService { || interruptiveChanged; if (interceptBefore && !record.isIntercepted() && record.isNewEnoughForAlerting(System.currentTimeMillis())) { - buzzBeepBlinkLocked(record); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.buzzBeepBlinkLocked(record, + new NotificationAttentionHelper.Signals( + mUserProfiles.isCurrentProfile(record.getUserId()), mListenerHints)); + } else { + buzzBeepBlinkLocked(record); + } // Log alert after change in intercepted state to Zen Log as well ZenLog.traceAlertOnUpdatedIntercept(record); @@ -9408,18 +9473,22 @@ public class NotificationManagerService extends SystemService { }); } - // sound - if (canceledKey.equals(mSoundNotificationKey)) { - clearSoundLocked(); - } + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.clearEffectsLocked(canceledKey); + } else { + // sound + if (canceledKey.equals(mSoundNotificationKey)) { + clearSoundLocked(); + } - // vibrate - if (canceledKey.equals(mVibrateNotificationKey)) { - clearVibrateLocked(); - } + // vibrate + if (canceledKey.equals(mVibrateNotificationKey)) { + clearVibrateLocked(); + } - // light - mLights.remove(canceledKey); + // light + mLights.remove(canceledKey); + } } // Record usage stats @@ -9768,7 +9837,11 @@ public class NotificationManagerService extends SystemService { cancellationElapsedTimeMs); } } - updateLightsLocked(); + if (mFlagResolver.isEnabled(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR)) { + mAttentionHelper.updateLightsLocked(); + } else { + updateLightsLocked(); + } } } diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationAttentionHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationAttentionHelperTest.java new file mode 100644 index 000000000000..81867df74abd --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationAttentionHelperTest.java @@ -0,0 +1,1973 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://`www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.server.notification; + +import static android.app.Notification.FLAG_BUBBLE; +import static android.app.Notification.GROUP_ALERT_ALL; +import static android.app.Notification.GROUP_ALERT_CHILDREN; +import static android.app.Notification.GROUP_ALERT_SUMMARY; +import static android.app.NotificationManager.IMPORTANCE_HIGH; +import static android.app.NotificationManager.IMPORTANCE_LOW; +import static android.app.NotificationManager.IMPORTANCE_MIN; +import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_LIGHTS; +import static android.media.AudioAttributes.USAGE_NOTIFICATION; +import static android.media.AudioAttributes.USAGE_NOTIFICATION_RINGTONE; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyObject; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.KeyguardManager; +import android.app.Notification; +import android.app.Notification.Builder; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.drawable.Icon; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Handler; +import android.os.Process; +import android.os.RemoteException; +import android.os.UserHandle; +import android.os.VibrationAttributes; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.provider.Settings; +import android.service.notification.NotificationListenerService; +import android.service.notification.StatusBarNotification; +import android.test.suitebuilder.annotation.SmallTest; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.IAccessibilityManager; +import android.view.accessibility.IAccessibilityManagerClient; +import androidx.test.runner.AndroidJUnit4; +import com.android.internal.config.sysui.SystemUiSystemPropertiesFlags.NotificationFlags; +import com.android.internal.config.sysui.TestableFlagResolver; +import com.android.internal.logging.InstanceIdSequence; +import com.android.internal.logging.InstanceIdSequenceFake; +import com.android.internal.util.IntPair; +import com.android.server.UiServiceTestCase; +import com.android.server.lights.LightsManager; +import com.android.server.lights.LogicalLight; +import com.android.server.pm.PackageManagerService; +import java.util.Objects; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatcher; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.verification.VerificationMode; + +@SmallTest +@RunWith(AndroidJUnit4.class) +@SuppressLint("GuardedBy") +public class NotificationAttentionHelperTest extends UiServiceTestCase { + + @Mock AudioManager mAudioManager; + @Mock Vibrator mVibrator; + @Mock android.media.IRingtonePlayer mRingtonePlayer; + @Mock LogicalLight mLight; + @Mock + NotificationManagerService.WorkerHandler mHandler; + @Mock + NotificationUsageStats mUsageStats; + @Mock + IAccessibilityManager mAccessibilityService; + @Mock + KeyguardManager mKeyguardManager; + NotificationRecordLoggerFake mNotificationRecordLogger = new NotificationRecordLoggerFake(); + private InstanceIdSequence mNotificationInstanceIdSequence = new InstanceIdSequenceFake( + 1 << 30); + + private NotificationManagerService mService; + private String mPkg = "com.android.server.notification"; + private int mId = 1001; + private int mOtherId = 1002; + private String mTag = null; + private int mUid = 1000; + private int mPid = 2000; + private android.os.UserHandle mUser = UserHandle.of(ActivityManager.getCurrentUser()); + private NotificationChannel mChannel; + + private NotificationAttentionHelper mAttentionHelper; + private TestableFlagResolver mTestFlagResolver = new TestableFlagResolver(); + private AccessibilityManager mAccessibilityManager; + private static final NotificationAttentionHelper.Signals DEFAULT_SIGNALS = + new NotificationAttentionHelper.Signals(false, 0); + + private VibrateRepeatMatcher mVibrateOnceMatcher = new VibrateRepeatMatcher(-1); + private VibrateRepeatMatcher mVibrateLoopMatcher = new VibrateRepeatMatcher(0); + + private static final long[] CUSTOM_VIBRATION = new long[] { + 300, 400, 300, 400, 300, 400, 300, 400, 300, 400, 300, 400, + 300, 400, 300, 400, 300, 400, 300, 400, 300, 400, 300, 400, + 300, 400, 300, 400, 300, 400, 300, 400, 300, 400, 300, 400 }; + private static final Uri CUSTOM_SOUND = Settings.System.DEFAULT_ALARM_ALERT_URI; + private static final AudioAttributes CUSTOM_ATTRIBUTES = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build(); + private static final int CUSTOM_LIGHT_COLOR = Color.BLACK; + private static final int CUSTOM_LIGHT_ON = 10000; + private static final int CUSTOM_LIGHT_OFF = 10000; + private static final int MAX_VIBRATION_DELAY = 1000; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + getContext().addMockSystemService(Vibrator.class, mVibrator); + + when(mAudioManager.isAudioFocusExclusive()).thenReturn(false); + when(mAudioManager.getRingtonePlayer()).thenReturn(mRingtonePlayer); + when(mAudioManager.getStreamVolume(anyInt())).thenReturn(10); + when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_NORMAL); + when(mAudioManager.getFocusRampTimeMs(anyInt(), any(AudioAttributes.class))).thenReturn(50); + when(mUsageStats.isAlertRateLimited(any())).thenReturn(false); + when(mVibrator.hasFrequencyControl()).thenReturn(false); + when(mKeyguardManager.isDeviceLocked(anyInt())).thenReturn(false); + + long serviceReturnValue = IntPair.of( + AccessibilityManager.STATE_FLAG_ACCESSIBILITY_ENABLED, + AccessibilityEvent.TYPES_ALL_MASK); + when(mAccessibilityService.addClient(any(), anyInt())).thenReturn(serviceReturnValue); + mAccessibilityManager = + new AccessibilityManager(getContext(), Handler.getMain(), mAccessibilityService, 0, + true); + verify(mAccessibilityService).addClient(any(IAccessibilityManagerClient.class), anyInt()); + assertTrue(mAccessibilityManager.isEnabled()); + + // TODO (b/291907312): remove feature flag + mTestFlagResolver.setFlagOverride(NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR, true); + + mService = spy(new NotificationManagerService(getContext(), mNotificationRecordLogger, + mNotificationInstanceIdSequence)); + + initAttentionHelper(mTestFlagResolver); + + mChannel = new NotificationChannel("test", "test", IMPORTANCE_HIGH); + } + + private void initAttentionHelper(TestableFlagResolver flagResolver) { + mAttentionHelper = new NotificationAttentionHelper(getContext(), mock(LightsManager.class), + mAccessibilityManager, getContext().getPackageManager(), mUsageStats, + mService.mNotificationManagerPrivate, mock(ZenModeHelper.class), flagResolver); + mAttentionHelper.setVibratorHelper(new VibratorHelper(getContext())); + mAttentionHelper.setAudioManager(mAudioManager); + mAttentionHelper.setSystemReady(true); + mAttentionHelper.setLights(mLight); + mAttentionHelper.setScreenOn(false); + mAttentionHelper.setAccessibilityManager(mAccessibilityManager); + mAttentionHelper.setKeyguardManager(mKeyguardManager); + mAttentionHelper.setScreenOn(false); + mAttentionHelper.setInCallStateOffHook(false); + mAttentionHelper.mNotificationPulseEnabled = true; + } + + // + // Convenience functions for creating notification records + // + + private NotificationRecord getNoisyOtherNotification() { + return getNotificationRecord(mOtherId, false /* insistent */, false /* once */, + true /* noisy */, true /* buzzy*/, false /* lights */); + } + + private NotificationRecord getBeepyNotification() { + return getNotificationRecord(mId, false /* insistent */, false /* once */, + true /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getBeepyOtherNotification() { + return getNotificationRecord(mOtherId, false /* insistent */, false /* once */, + true /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getBeepyOnceNotification() { + return getNotificationRecord(mId, false /* insistent */, true /* once */, + true /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getQuietNotification() { + return getNotificationRecord(mId, false /* insistent */, true /* once */, + false /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getQuietOtherNotification() { + return getNotificationRecord(mOtherId, false /* insistent */, false /* once */, + false /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getQuietOnceNotification() { + return getNotificationRecord(mId, false /* insistent */, true /* once */, + false /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getInsistentBeepyNotification() { + return getNotificationRecord(mId, true /* insistent */, false /* once */, + true /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getInsistentBeepyOnceNotification() { + return getNotificationRecord(mId, true /* insistent */, true /* once */, + true /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getInsistentBeepyLeanbackNotification() { + return getLeanbackNotificationRecord(mId, true /* insistent */, false /* once */, + true /* noisy */, false /* buzzy*/, false /* lights */); + } + + private NotificationRecord getBuzzyNotification() { + return getNotificationRecord(mId, false /* insistent */, false /* once */, + false /* noisy */, true /* buzzy*/, false /* lights */); + } + + private NotificationRecord getBuzzyOtherNotification() { + return getNotificationRecord(mOtherId, false /* insistent */, false /* once */, + false /* noisy */, true /* buzzy*/, false /* lights */); + } + + private NotificationRecord getBuzzyOnceNotification() { + return getNotificationRecord(mId, false /* insistent */, true /* once */, + false /* noisy */, true /* buzzy*/, false /* lights */); + } + + private NotificationRecord getInsistentBuzzyNotification() { + return getNotificationRecord(mId, true /* insistent */, false /* once */, + false /* noisy */, true /* buzzy*/, false /* lights */); + } + + private NotificationRecord getBuzzyBeepyNotification() { + return getNotificationRecord(mId, false /* insistent */, false /* once */, + true /* noisy */, true /* buzzy*/, false /* lights */); + } + + private NotificationRecord getLightsNotification() { + return getNotificationRecord(mId, false /* insistent */, false /* once */, + false /* noisy */, false /* buzzy*/, true /* lights */); + } + + private NotificationRecord getLightsOnceNotification() { + return getNotificationRecord(mId, false /* insistent */, true /* once */, + false /* noisy */, false /* buzzy*/, true /* lights */); + } + + private NotificationRecord getCallRecord(int id, NotificationChannel channel, boolean looping) { + final Builder builder = new Builder(getContext()) + .setContentTitle("foo") + .setSmallIcon(android.R.drawable.sym_def_app_icon) + .setPriority(Notification.PRIORITY_HIGH); + Notification n = builder.build(); + if (looping) { + n.flags |= Notification.FLAG_INSISTENT; + } + StatusBarNotification sbn = new StatusBarNotification(mPkg, mPkg, id, mTag, mUid, + mPid, n, mUser, null, System.currentTimeMillis()); + NotificationRecord r = new NotificationRecord(getContext(), sbn, channel); + mService.addNotification(r); + + return r; + } + + private NotificationRecord getNotificationRecord(int id, boolean insistent, boolean once, + boolean noisy, boolean buzzy, boolean lights) { + return getNotificationRecord(id, insistent, once, noisy, buzzy, lights, buzzy, noisy, + lights, null, Notification.GROUP_ALERT_ALL, false); + } + + private NotificationRecord getLeanbackNotificationRecord(int id, boolean insistent, + boolean once, + boolean noisy, boolean buzzy, boolean lights) { + return getNotificationRecord(id, insistent, once, noisy, buzzy, lights, true, true, + true, + null, Notification.GROUP_ALERT_ALL, true); + } + + private NotificationRecord getBeepyNotificationRecord(String groupKey, int groupAlertBehavior) { + return getNotificationRecord(mId, false, false, true, false, false, true, true, true, + groupKey, groupAlertBehavior, false); + } + + private NotificationRecord getLightsNotificationRecord(String groupKey, + int groupAlertBehavior) { + return getNotificationRecord(mId, false, false, false, false, true /*lights*/, true, + true, true, groupKey, groupAlertBehavior, false); + } + + private NotificationRecord getNotificationRecord(int id, + boolean insistent, boolean once, + boolean noisy, boolean buzzy, boolean lights, boolean defaultVibration, + boolean defaultSound, boolean defaultLights, String groupKey, int groupAlertBehavior, + boolean isLeanback) { + + final Builder builder = new Builder(getContext()) + .setContentTitle("foo") + .setSmallIcon(android.R.drawable.sym_def_app_icon) + .setPriority(Notification.PRIORITY_HIGH) + .setOnlyAlertOnce(once); + + int defaults = 0; + if (noisy) { + if (defaultSound) { + defaults |= Notification.DEFAULT_SOUND; + mChannel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, + Notification.AUDIO_ATTRIBUTES_DEFAULT); + } else { + builder.setSound(CUSTOM_SOUND); + mChannel.setSound(CUSTOM_SOUND, CUSTOM_ATTRIBUTES); + } + } else { + mChannel.setSound(null, null); + } + if (buzzy) { + if (defaultVibration) { + defaults |= Notification.DEFAULT_VIBRATE; + } else { + builder.setVibrate(CUSTOM_VIBRATION); + mChannel.setVibrationPattern(CUSTOM_VIBRATION); + } + mChannel.enableVibration(true); + } else { + mChannel.setVibrationPattern(null); + mChannel.enableVibration(false); + } + + if (lights) { + if (defaultLights) { + defaults |= Notification.DEFAULT_LIGHTS; + } else { + builder.setLights(CUSTOM_LIGHT_COLOR, CUSTOM_LIGHT_ON, CUSTOM_LIGHT_OFF); + } + mChannel.enableLights(true); + } else { + mChannel.enableLights(false); + } + builder.setDefaults(defaults); + + builder.setGroup(groupKey); + builder.setGroupAlertBehavior(groupAlertBehavior); + + Notification n = builder.build(); + if (insistent) { + n.flags |= Notification.FLAG_INSISTENT; + } + + Context context = spy(getContext()); + PackageManager packageManager = spy(context.getPackageManager()); + when(context.getPackageManager()).thenReturn(packageManager); + when(packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) + .thenReturn(isLeanback); + + StatusBarNotification sbn = new StatusBarNotification(mPkg, mPkg, id, mTag, mUid, + mPid, n, mUser, null, System.currentTimeMillis()); + NotificationRecord r = new NotificationRecord(context, sbn, mChannel); + mService.addNotification(r); + return r; + } + + // + // Convenience functions for interacting with mocks + // + + private void verifyNeverBeep() throws RemoteException { + verify(mRingtonePlayer, never()).playAsync(any(), any(), anyBoolean(), any()); + } + + private void verifyBeepUnlooped() throws RemoteException { + verify(mRingtonePlayer, times(1)).playAsync(any(), any(), eq(false), any()); + } + + private void verifyBeepLooped() throws RemoteException { + verify(mRingtonePlayer, times(1)).playAsync(any(), any(), eq(true), any()); + } + + private void verifyBeep(int times) throws RemoteException { + verify(mRingtonePlayer, times(times)).playAsync(any(), any(), anyBoolean(), any()); + } + + private void verifyNeverStopAudio() throws RemoteException { + verify(mRingtonePlayer, never()).stopAsync(); + } + + private void verifyStopAudio() throws RemoteException { + verify(mRingtonePlayer, times(1)).stopAsync(); + } + + private void verifyNeverVibrate() { + verify(mVibrator, never()).vibrate(anyInt(), anyString(), any(), anyString(), + any(VibrationAttributes.class)); + } + + private void verifyVibrate() { + verifyVibrate(/* times= */ 1); + } + + private void verifyVibrate(int times) { + verifyVibrate(mVibrateOnceMatcher, times(times)); + } + + private void verifyVibrateLooped() { + verifyVibrate(mVibrateLoopMatcher, times(1)); + } + + private void verifyDelayedVibrateLooped() { + verifyVibrate(mVibrateLoopMatcher, timeout(MAX_VIBRATION_DELAY).times(1)); + } + + private void verifyDelayedVibrate(VibrationEffect effect) { + verifyVibrate(argument -> Objects.equals(effect, argument), + timeout(MAX_VIBRATION_DELAY).times(1)); + } + + private void verifyDelayedNeverVibrate() { + verify(mVibrator, after(MAX_VIBRATION_DELAY).never()).vibrate(anyInt(), anyString(), any(), + anyString(), any(VibrationAttributes.class)); + } + + private void verifyVibrate(ArgumentMatcher<VibrationEffect> effectMatcher, + VerificationMode verification) { + ArgumentCaptor<VibrationAttributes> captor = + ArgumentCaptor.forClass(VibrationAttributes.class); + verify(mVibrator, verification).vibrate(eq(Process.SYSTEM_UID), + eq(PackageManagerService.PLATFORM_PACKAGE_NAME), argThat(effectMatcher), + anyString(), captor.capture()); + assertEquals(0, (captor.getValue().getFlags() + & VibrationAttributes.FLAG_BYPASS_INTERRUPTION_POLICY)); + } + + private void verifyStopVibrate() { + int alarmClassUsageFilter = + VibrationAttributes.USAGE_CLASS_ALARM | ~VibrationAttributes.USAGE_CLASS_MASK; + verify(mVibrator, times(1)).cancel(eq(alarmClassUsageFilter)); + } + + private void verifyNeverStopVibrate() { + verify(mVibrator, never()).cancel(); + verify(mVibrator, never()).cancel(anyInt()); + } + + private void verifyNeverLights() { + verify(mLight, never()).setFlashing(anyInt(), anyInt(), anyInt(), anyInt()); + } + + private void verifyLights() { + verify(mLight, times(1)).setFlashing(anyInt(), anyInt(), anyInt(), anyInt()); + } + + // + // Tests + // + + @Test + public void testLights() throws Exception { + NotificationRecord r = getLightsNotification(); + r.setSystemImportance(NotificationManager.IMPORTANCE_DEFAULT); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyLights(); + assertTrue(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testBeep() throws Exception { + NotificationRecord r = getBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + verifyNeverVibrate(); + verify(mAccessibilityService, times(1)).sendAccessibilityEvent(any(), anyInt()); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLockedPrivateA11yRedaction() throws Exception { + NotificationRecord r = getBeepyNotification(); + r.setPackageVisibilityOverride(NotificationManager.VISIBILITY_NO_OVERRIDE); + r.getNotification().visibility = Notification.VISIBILITY_PRIVATE; + when(mKeyguardManager.isDeviceLocked(anyInt())).thenReturn(true); + AccessibilityManager accessibilityManager = Mockito.mock(AccessibilityManager.class); + when(accessibilityManager.isEnabled()).thenReturn(true); + mAttentionHelper.setAccessibilityManager(accessibilityManager); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + ArgumentCaptor<AccessibilityEvent> eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + + verify(accessibilityManager, times(1)) + .sendAccessibilityEvent(eventCaptor.capture()); + + AccessibilityEvent event = eventCaptor.getValue(); + assertEquals(r.getNotification().publicVersion, event.getParcelableData()); + } + + @Test + public void testLockedOverridePrivateA11yRedaction() throws Exception { + NotificationRecord r = getBeepyNotification(); + r.setPackageVisibilityOverride(Notification.VISIBILITY_PRIVATE); + r.getNotification().visibility = Notification.VISIBILITY_PUBLIC; + when(mKeyguardManager.isDeviceLocked(anyInt())).thenReturn(true); + AccessibilityManager accessibilityManager = Mockito.mock(AccessibilityManager.class); + when(accessibilityManager.isEnabled()).thenReturn(true); + mAttentionHelper.setAccessibilityManager(accessibilityManager); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + ArgumentCaptor<AccessibilityEvent> eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + + verify(accessibilityManager, times(1)) + .sendAccessibilityEvent(eventCaptor.capture()); + + AccessibilityEvent event = eventCaptor.getValue(); + assertEquals(r.getNotification().publicVersion, event.getParcelableData()); + } + + @Test + public void testLockedPublicA11yNoRedaction() throws Exception { + NotificationRecord r = getBeepyNotification(); + r.setPackageVisibilityOverride(NotificationManager.VISIBILITY_NO_OVERRIDE); + r.getNotification().visibility = Notification.VISIBILITY_PUBLIC; + when(mKeyguardManager.isDeviceLocked(anyInt())).thenReturn(true); + AccessibilityManager accessibilityManager = Mockito.mock(AccessibilityManager.class); + when(accessibilityManager.isEnabled()).thenReturn(true); + mAttentionHelper.setAccessibilityManager(accessibilityManager); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + ArgumentCaptor<AccessibilityEvent> eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + + verify(accessibilityManager, times(1)) + .sendAccessibilityEvent(eventCaptor.capture()); + + AccessibilityEvent event = eventCaptor.getValue(); + assertEquals(r.getNotification(), event.getParcelableData()); + } + + @Test + public void testUnlockedPrivateA11yNoRedaction() throws Exception { + NotificationRecord r = getBeepyNotification(); + r.setPackageVisibilityOverride(NotificationManager.VISIBILITY_NO_OVERRIDE); + r.getNotification().visibility = Notification.VISIBILITY_PRIVATE; + when(mKeyguardManager.isDeviceLocked(anyInt())).thenReturn(false); + AccessibilityManager accessibilityManager = Mockito.mock(AccessibilityManager.class); + when(accessibilityManager.isEnabled()).thenReturn(true); + mAttentionHelper.setAccessibilityManager(accessibilityManager); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + ArgumentCaptor<AccessibilityEvent> eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + + verify(accessibilityManager, times(1)) + .sendAccessibilityEvent(eventCaptor.capture()); + + AccessibilityEvent event = eventCaptor.getValue(); + assertEquals(r.getNotification(), event.getParcelableData()); + } + + @Test + public void testBeepInsistently() throws Exception { + NotificationRecord r = getInsistentBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyBeepLooped(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoLeanbackBeep() throws Exception { + NotificationRecord r = getInsistentBeepyLeanbackNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoBeepForAutomotiveIfEffectsDisabled() throws Exception { + mAttentionHelper.setIsAutomotive(true); + mAttentionHelper.setNotificationEffectsEnabledForAutomotive(false); + + NotificationRecord r = getBeepyNotification(); + r.setSystemImportance(NotificationManager.IMPORTANCE_HIGH); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + assertFalse(r.isInterruptive()); + } + + @Test + public void testNoBeepForImportanceDefaultInAutomotiveIfEffectsEnabled() throws Exception { + mAttentionHelper.setIsAutomotive(true); + mAttentionHelper.setNotificationEffectsEnabledForAutomotive(true); + + NotificationRecord r = getBeepyNotification(); + r.setSystemImportance(NotificationManager.IMPORTANCE_DEFAULT); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + assertFalse(r.isInterruptive()); + } + + @Test + public void testBeepForImportanceHighInAutomotiveIfEffectsEnabled() throws Exception { + mAttentionHelper.setIsAutomotive(true); + mAttentionHelper.setNotificationEffectsEnabledForAutomotive(true); + + NotificationRecord r = getBeepyNotification(); + r.setSystemImportance(NotificationManager.IMPORTANCE_HIGH); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + assertTrue(r.isInterruptive()); + } + + @Test + public void testNoInterruptionForMin() throws Exception { + NotificationRecord r = getBeepyNotification(); + r.setSystemImportance(NotificationManager.IMPORTANCE_MIN); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + verifyNeverVibrate(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoInterruptionForIntercepted() throws Exception { + NotificationRecord r = getBeepyNotification(); + r.setIntercepted(true); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + verifyNeverVibrate(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testBeepTwice() throws Exception { + NotificationRecord r = getBeepyNotification(); + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + Mockito.reset(mRingtonePlayer); + + // update should beep + r.isUpdate = true; + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyBeepUnlooped(); + verify(mAccessibilityService, times(2)).sendAccessibilityEvent(any(), anyInt()); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testHonorAlertOnlyOnceForBeep() throws Exception { + NotificationRecord r = getBeepyNotification(); + NotificationRecord s = getBeepyOnceNotification(); + s.isUpdate = true; + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + Mockito.reset(mRingtonePlayer); + + // update should not beep + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + verifyNeverBeep(); + verify(mAccessibilityService, times(1)).sendAccessibilityEvent(any(), anyInt()); + } + + @Test + public void testNoisyUpdateDoesNotCancelAudio() throws Exception { + NotificationRecord r = getBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + r.isUpdate = true; + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverStopAudio(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoisyOnceUpdateDoesNotCancelAudio() throws Exception { + NotificationRecord r = getBeepyNotification(); + NotificationRecord s = getBeepyOnceNotification(); + s.isUpdate = true; + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + + verifyNeverStopAudio(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + /** + * Tests the case where the user re-posts a {@link Notification} with looping sound where + * {@link Notification.Builder#setOnlyAlertOnce(true)} has been called. This should silence + * the sound associated with the notification. + * @throws Exception + */ + @Test + public void testNoisyOnceUpdateDoesCancelAudio() throws Exception { + NotificationRecord r = getInsistentBeepyNotification(); + NotificationRecord s = getInsistentBeepyOnceNotification(); + s.isUpdate = true; + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + + verifyStopAudio(); + } + + @Test + public void testQuietUpdateDoesNotCancelAudioFromOther() throws Exception { + NotificationRecord r = getBeepyNotification(); + NotificationRecord s = getQuietNotification(); + s.isUpdate = true; + NotificationRecord other = getNoisyOtherNotification(); + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + mAttentionHelper.buzzBeepBlinkLocked(other, DEFAULT_SIGNALS); // this takes the audio stream + Mockito.reset(mRingtonePlayer); + + // should not stop noise, since we no longer own it + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); // this no longer owns the stream + verifyNeverStopAudio(); + assertTrue(other.isInterruptive()); + assertNotEquals(-1, other.getLastAudiblyAlertedMs()); + } + + @Test + public void testQuietInterloperDoesNotCancelAudio() throws Exception { + NotificationRecord r = getBeepyNotification(); + NotificationRecord other = getQuietOtherNotification(); + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + Mockito.reset(mRingtonePlayer); + + // should not stop noise, since it does not own it + mAttentionHelper.buzzBeepBlinkLocked(other, DEFAULT_SIGNALS); + verifyNeverStopAudio(); + } + + @Test + public void testQuietUpdateCancelsAudio() throws Exception { + NotificationRecord r = getBeepyNotification(); + NotificationRecord s = getQuietNotification(); + s.isUpdate = true; + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + Mockito.reset(mRingtonePlayer); + + // quiet update should stop making noise + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + verifyStopAudio(); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testQuietOnceUpdateCancelsAudio() throws Exception { + NotificationRecord r = getBeepyNotification(); + NotificationRecord s = getQuietOnceNotification(); + s.isUpdate = true; + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + Mockito.reset(mRingtonePlayer); + + // stop making noise - this is a weird corner case, but quiet should override once + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + verifyStopAudio(); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testInCallNotification() throws Exception { + NotificationRecord r = getBeepyNotification(); + + mAttentionHelper = spy(mAttentionHelper); + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + Mockito.reset(mRingtonePlayer); + + mAttentionHelper.setInCallStateOffHook(true); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verify(mAttentionHelper, times(1)).playInCallNotification(); + verifyNeverBeep(); // doesn't play normal beep + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoDemoteSoundToVibrateIfVibrateGiven() throws Exception { + NotificationRecord r = getBuzzyBeepyNotification(); + assertTrue(r.getSound() != null); + + // the phone is quiet + when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_VIBRATE); + when(mAudioManager.getStreamVolume(anyInt())).thenReturn(0); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyDelayedVibrate(r.getVibration()); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoDemoteSoundToVibrateIfNonNotificationStream() throws Exception { + NotificationRecord r = getBeepyNotification(); + assertTrue(r.getSound() != null); + assertNull(r.getVibration()); + + // the phone is quiet + when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_VIBRATE); + when(mAudioManager.getStreamVolume(anyInt())).thenReturn(1); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverVibrate(); + verifyBeepUnlooped(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testDemoteSoundToVibrate() throws Exception { + NotificationRecord r = getBeepyNotification(); + assertTrue(r.getSound() != null); + assertNull(r.getVibration()); + + // the phone is quiet + when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_VIBRATE); + when(mAudioManager.getStreamVolume(anyInt())).thenReturn(0); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyDelayedVibrate( + mAttentionHelper.getVibratorHelper().createFallbackVibration( + /* insistent= */ false)); + verify(mRingtonePlayer, never()).playAsync(anyObject(), anyObject(), anyBoolean(), + anyObject()); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testDemoteInsistentSoundToVibrate() throws Exception { + NotificationRecord r = getInsistentBeepyNotification(); + assertTrue(r.getSound() != null); + assertNull(r.getVibration()); + + // the phone is quiet + when(mAudioManager.getStreamVolume(anyInt())).thenReturn(0); + when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_VIBRATE); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyDelayedVibrateLooped(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testVibrate() throws Exception { + NotificationRecord r = getBuzzyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + verifyVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testInsistentVibrate() { + NotificationRecord r = getInsistentBuzzyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyVibrateLooped(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testVibrateTwice() { + NotificationRecord r = getBuzzyNotification(); + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + Mockito.reset(mVibrator); + + // update should vibrate + r.isUpdate = true; + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testPostSilently() throws Exception { + NotificationRecord r = getBuzzyNotification(); + r.setPostSilently(true); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertSummarySilenceChild() throws Exception { + NotificationRecord child = getBeepyNotificationRecord("a", GROUP_ALERT_SUMMARY); + + mAttentionHelper.buzzBeepBlinkLocked(child, DEFAULT_SIGNALS); + + verifyNeverBeep(); + assertFalse(child.isInterruptive()); + assertEquals(-1, child.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertSummaryNoSilenceSummary() throws Exception { + NotificationRecord summary = getBeepyNotificationRecord("a", GROUP_ALERT_SUMMARY); + summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; + + mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + // summaries are never interruptive for notification counts + assertFalse(summary.isInterruptive()); + assertNotEquals(-1, summary.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertSummaryNoSilenceNonGroupChild() throws Exception { + NotificationRecord nonGroup = getBeepyNotificationRecord(null, GROUP_ALERT_SUMMARY); + + mAttentionHelper.buzzBeepBlinkLocked(nonGroup, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + assertTrue(nonGroup.isInterruptive()); + assertNotEquals(-1, nonGroup.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertChildSilenceSummary() throws Exception { + NotificationRecord summary = getBeepyNotificationRecord("a", GROUP_ALERT_CHILDREN); + summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; + + mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS); + + verifyNeverBeep(); + assertFalse(summary.isInterruptive()); + assertEquals(-1, summary.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertChildNoSilenceChild() throws Exception { + NotificationRecord child = getBeepyNotificationRecord("a", GROUP_ALERT_CHILDREN); + + mAttentionHelper.buzzBeepBlinkLocked(child, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + assertTrue(child.isInterruptive()); + assertNotEquals(-1, child.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertChildNoSilenceNonGroupSummary() throws Exception { + NotificationRecord nonGroup = getBeepyNotificationRecord(null, GROUP_ALERT_CHILDREN); + + mAttentionHelper.buzzBeepBlinkLocked(nonGroup, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + assertTrue(nonGroup.isInterruptive()); + assertNotEquals(-1, nonGroup.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertAllNoSilenceGroup() throws Exception { + NotificationRecord group = getBeepyNotificationRecord("a", GROUP_ALERT_ALL); + + mAttentionHelper.buzzBeepBlinkLocked(group, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + assertTrue(group.isInterruptive()); + assertNotEquals(-1, group.getLastAudiblyAlertedMs()); + } + + @Test + public void testHonorAlertOnlyOnceForBuzz() throws Exception { + NotificationRecord r = getBuzzyNotification(); + NotificationRecord s = getBuzzyOnceNotification(); + s.isUpdate = true; + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + Mockito.reset(mVibrator); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + + // update should not beep + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + verifyNeverVibrate(); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoisyUpdateDoesNotCancelVibrate() throws Exception { + NotificationRecord r = getBuzzyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + r.isUpdate = true; + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverStopVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testNoisyOnceUpdateDoesNotCancelVibrate() throws Exception { + NotificationRecord r = getBuzzyNotification(); + NotificationRecord s = getBuzzyOnceNotification(); + s.isUpdate = true; + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + + verifyNeverStopVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testQuietUpdateDoesNotCancelVibrateFromOther() throws Exception { + NotificationRecord r = getBuzzyNotification(); + NotificationRecord s = getQuietNotification(); + s.isUpdate = true; + NotificationRecord other = getNoisyOtherNotification(); + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + // this takes the vibrate stream + mAttentionHelper.buzzBeepBlinkLocked(other, DEFAULT_SIGNALS); + Mockito.reset(mVibrator); + + // should not stop vibrate, since we no longer own it + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); // this no longer owns the stream + verifyNeverStopVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + assertTrue(other.isInterruptive()); + assertNotEquals(-1, other.getLastAudiblyAlertedMs()); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testQuietInterloperDoesNotCancelVibrate() throws Exception { + NotificationRecord r = getBuzzyNotification(); + NotificationRecord other = getQuietOtherNotification(); + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + Mockito.reset(mVibrator); + + // should not stop noise, since it does not own it + mAttentionHelper.buzzBeepBlinkLocked(other, DEFAULT_SIGNALS); + verifyNeverStopVibrate(); + assertFalse(other.isInterruptive()); + assertEquals(-1, other.getLastAudiblyAlertedMs()); + } + + @Test + public void testQuietUpdateCancelsVibrate() { + NotificationRecord r = getBuzzyNotification(); + NotificationRecord s = getQuietNotification(); + s.isUpdate = true; + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyVibrate(); + + // quiet update should stop making noise + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + verifyStopVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testQuietOnceUpdateCancelVibrate() throws Exception { + NotificationRecord r = getBuzzyNotification(); + NotificationRecord s = getQuietOnceNotification(); + s.isUpdate = true; + + // set up internal state + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyVibrate(); + + // stop making noise - this is a weird corner case, but quiet should override once + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + verifyStopVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testQuietUpdateCancelsDemotedVibrate() throws Exception { + NotificationRecord r = getBeepyNotification(); + NotificationRecord s = getQuietNotification(); + + // the phone is quiet + when(mAudioManager.getStreamVolume(anyInt())).thenReturn(0); + when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_VIBRATE); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyDelayedVibrate(mAttentionHelper.getVibratorHelper().createFallbackVibration(false)); + + // quiet update should stop making noise + mAttentionHelper.buzzBeepBlinkLocked(s, DEFAULT_SIGNALS); + verifyStopVibrate(); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + assertFalse(s.isInterruptive()); + assertEquals(-1, s.getLastAudiblyAlertedMs()); + } + + @Test + public void testEmptyUriSoundTreatedAsNoSound() throws Exception { + NotificationChannel channel = new NotificationChannel("test", "test", IMPORTANCE_HIGH); + channel.setSound(Uri.EMPTY, null); + final Notification n = new Builder(getContext(), "test") + .setSmallIcon(android.R.drawable.sym_def_app_icon).build(); + + StatusBarNotification sbn = new StatusBarNotification(mPkg, mPkg, 0, mTag, mUid, + mPid, n, mUser, null, System.currentTimeMillis()); + NotificationRecord r = new NotificationRecord(getContext(), sbn, channel); + mService.addNotification(r); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverBeep(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testRepeatedSoundOverLimitMuted() throws Exception { + when(mUsageStats.isAlertRateLimited(any())).thenReturn(true); + + NotificationRecord r = getBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverBeep(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testPostingSilentNotificationDoesNotAffectRateLimiting() throws Exception { + NotificationRecord r = getQuietNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verify(mUsageStats, never()).isAlertRateLimited(any()); + } + + @Test + public void testPostingGroupSuppressedDoesNotAffectRateLimiting() throws Exception { + NotificationRecord summary = getBeepyNotificationRecord("a", GROUP_ALERT_CHILDREN); + summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; + + mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS); + verify(mUsageStats, never()).isAlertRateLimited(any()); + } + + @Test + public void testGroupSuppressionFailureDoesNotAffectRateLimiting() { + NotificationRecord summary = getBeepyNotificationRecord("a", GROUP_ALERT_SUMMARY); + summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; + + mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS); + verify(mUsageStats, times(1)).isAlertRateLimited(any()); + } + + @Test + public void testCrossUserSoundMuted() throws Exception { + final Notification n = new Builder(getContext(), "test") + .setSmallIcon(android.R.drawable.sym_def_app_icon).build(); + + int userId = mUser.getIdentifier() + 1; + StatusBarNotification sbn = new StatusBarNotification(mPkg, mPkg, 0, mTag, mUid, + mPid, n, UserHandle.of(userId), null, System.currentTimeMillis()); + NotificationRecord r = new NotificationRecord(getContext(), sbn, + new NotificationChannel("test", "test", IMPORTANCE_HIGH)); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverBeep(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testA11yMinInitialPost() throws Exception { + NotificationRecord r = getQuietNotification(); + r.setSystemImportance(IMPORTANCE_MIN); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verify(mAccessibilityService, never()).sendAccessibilityEvent(any(), anyInt()); + } + + @Test + public void testA11yQuietInitialPost() throws Exception { + NotificationRecord r = getQuietNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verify(mAccessibilityService, times(1)).sendAccessibilityEvent(any(), anyInt()); + } + + @Test + public void testA11yQuietUpdate() throws Exception { + NotificationRecord r = getQuietNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + r.isUpdate = true; + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verify(mAccessibilityService, times(1)).sendAccessibilityEvent(any(), anyInt()); + } + + @Test + public void testA11yCrossUserEventNotSent() throws Exception { + final Notification n = new Builder(getContext(), "test") + .setSmallIcon(android.R.drawable.sym_def_app_icon).build(); + int userId = mUser.getIdentifier() + 1; + StatusBarNotification sbn = new StatusBarNotification(mPkg, mPkg, 0, mTag, mUid, + mPid, n, UserHandle.of(userId), null, System.currentTimeMillis()); + NotificationRecord r = new NotificationRecord(getContext(), sbn, + new NotificationChannel("test", "test", IMPORTANCE_HIGH)); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verify(mAccessibilityService, never()).sendAccessibilityEvent(any(), anyInt()); + } + + @Test + public void testLightsScreenOn() { + mAttentionHelper.setScreenOn(true); + NotificationRecord r = getLightsNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertTrue(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsInCall() { + mAttentionHelper.setInCallStateOffHook(true); + NotificationRecord r = getLightsNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsSilentUpdate() { + NotificationRecord r = getLightsOnceNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyLights(); + assertTrue(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + + r = getLightsOnceNotification(); + r.isUpdate = true; + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + // checks that lights happened once, i.e. this new call didn't trigger them again + verifyLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsUnimportant() { + NotificationRecord r = getLightsNotification(); + r.setSystemImportance(IMPORTANCE_LOW); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsNoLights() { + NotificationRecord r = getQuietNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsNoLightOnDevice() { + mAttentionHelper.mHasLight = false; + NotificationRecord r = getLightsNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsLightsOffGlobally() { + mAttentionHelper.mNotificationPulseEnabled = false; + NotificationRecord r = getLightsNotification(); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsDndIntercepted() { + NotificationRecord r = getLightsNotification(); + r.setSuppressedVisualEffects(SUPPRESSED_EFFECT_LIGHTS); + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertSummaryNoLightsChild() { + NotificationRecord child = getLightsNotificationRecord("a", GROUP_ALERT_SUMMARY); + + mAttentionHelper.buzzBeepBlinkLocked(child, DEFAULT_SIGNALS); + + verifyNeverLights(); + assertFalse(child.isInterruptive()); + assertEquals(-1, child.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertSummaryLightsSummary() { + NotificationRecord summary = getLightsNotificationRecord("a", GROUP_ALERT_SUMMARY); + summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; + + mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS); + + verifyLights(); + // summaries should never count for interruptiveness counts + assertFalse(summary.isInterruptive()); + assertEquals(-1, summary.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertSummaryLightsNonGroupChild() { + NotificationRecord nonGroup = getLightsNotificationRecord(null, GROUP_ALERT_SUMMARY); + + mAttentionHelper.buzzBeepBlinkLocked(nonGroup, DEFAULT_SIGNALS); + + verifyLights(); + assertTrue(nonGroup.isInterruptive()); + assertEquals(-1, nonGroup.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertChildNoLightsSummary() { + NotificationRecord summary = getLightsNotificationRecord("a", GROUP_ALERT_CHILDREN); + summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; + + mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS); + + verifyNeverLights(); + assertFalse(summary.isInterruptive()); + assertEquals(-1, summary.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertChildLightsChild() { + NotificationRecord child = getLightsNotificationRecord("a", GROUP_ALERT_CHILDREN); + + mAttentionHelper.buzzBeepBlinkLocked(child, DEFAULT_SIGNALS); + + verifyLights(); + assertTrue(child.isInterruptive()); + assertEquals(-1, child.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertChildLightsNonGroupSummary() { + NotificationRecord nonGroup = getLightsNotificationRecord(null, GROUP_ALERT_CHILDREN); + + mAttentionHelper.buzzBeepBlinkLocked(nonGroup, DEFAULT_SIGNALS); + + verifyLights(); + assertTrue(nonGroup.isInterruptive()); + assertEquals(-1, nonGroup.getLastAudiblyAlertedMs()); + } + + @Test + public void testGroupAlertAllLightsGroup() { + NotificationRecord group = getLightsNotificationRecord("a", GROUP_ALERT_ALL); + + mAttentionHelper.buzzBeepBlinkLocked(group, DEFAULT_SIGNALS); + + verifyLights(); + assertTrue(group.isInterruptive()); + assertEquals(-1, group.getLastAudiblyAlertedMs()); + } + + @Test + public void testLightsCheckCurrentUser() { + final Notification n = new Builder(getContext(), "test") + .setSmallIcon(android.R.drawable.sym_def_app_icon).build(); + int userId = mUser.getIdentifier() + 10; + StatusBarNotification sbn = new StatusBarNotification(mPkg, mPkg, 0, mTag, mUid, + mPid, n, UserHandle.of(userId), null, System.currentTimeMillis()); + NotificationRecord r = new NotificationRecord(getContext(), sbn, + new NotificationChannel("test", "test", IMPORTANCE_HIGH)); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + verifyNeverLights(); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testListenerHintCall() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Settings.System.DEFAULT_RINGTONE_URI, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + NotificationRecord r = getCallRecord(1, ringtoneChannel, true); + + mAttentionHelper.buzzBeepBlinkLocked(r, new NotificationAttentionHelper.Signals(false, + NotificationListenerService.HINT_HOST_DISABLE_CALL_EFFECTS)); + + verifyNeverBeep(); + } + + @Test + public void testListenerHintCall_notificationSound() throws Exception { + NotificationRecord r = getBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, new NotificationAttentionHelper.Signals(false, + NotificationListenerService.HINT_HOST_DISABLE_CALL_EFFECTS)); + + verifyBeepUnlooped(); + } + + @Test + public void testListenerHintNotification() throws Exception { + NotificationRecord r = getBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, new NotificationAttentionHelper.Signals(false, + NotificationListenerService.HINT_HOST_DISABLE_NOTIFICATION_EFFECTS)); + + verifyNeverBeep(); + } + + @Test + public void testListenerHintBoth() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Settings.System.DEFAULT_RINGTONE_URI, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + NotificationRecord r = getCallRecord(1, ringtoneChannel, true); + NotificationRecord s = getBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, new NotificationAttentionHelper.Signals(false, + NotificationListenerService.HINT_HOST_DISABLE_NOTIFICATION_EFFECTS + | NotificationListenerService.HINT_HOST_DISABLE_CALL_EFFECTS)); + mAttentionHelper.buzzBeepBlinkLocked(s, new NotificationAttentionHelper.Signals(false, + NotificationListenerService.HINT_HOST_DISABLE_NOTIFICATION_EFFECTS + | NotificationListenerService.HINT_HOST_DISABLE_CALL_EFFECTS)); + + verifyNeverBeep(); + } + + @Test + public void testListenerHintNotification_callSound() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Settings.System.DEFAULT_RINGTONE_URI, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + NotificationRecord r = getCallRecord(1, ringtoneChannel, true); + + mAttentionHelper.buzzBeepBlinkLocked(r, new NotificationAttentionHelper.Signals(false, + NotificationListenerService.HINT_HOST_DISABLE_NOTIFICATION_EFFECTS)); + + verifyBeepLooped(); + } + + @Test + public void testCannotInterruptRingtoneInsistentBeep() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Settings.System.DEFAULT_RINGTONE_URI, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, true); + mService.addNotification(ringtoneNotification); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyBeepLooped(); + + NotificationRecord interrupter = getBeepyOtherNotification(); + assertTrue(mAttentionHelper.shouldMuteNotificationLocked(interrupter, DEFAULT_SIGNALS)); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyBeep(1); + + assertFalse(interrupter.isInterruptive()); + assertEquals(-1, interrupter.getLastAudiblyAlertedMs()); + } + + @Test + public void testRingtoneInsistentBeep_canUpdate() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Uri.fromParts("a", "b", "c"), + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + ringtoneChannel.enableVibration(true); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, true); + mService.addNotification(ringtoneNotification); + assertFalse(mAttentionHelper.shouldMuteNotificationLocked(ringtoneNotification, + DEFAULT_SIGNALS)); + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyBeepLooped(); + verifyDelayedVibrateLooped(); + Mockito.reset(mVibrator); + Mockito.reset(mRingtonePlayer); + + assertFalse(mAttentionHelper.shouldMuteNotificationLocked(ringtoneNotification, + DEFAULT_SIGNALS)); + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + + // beep wasn't reset + verifyNeverBeep(); + verifyNeverVibrate(); + verifyNeverStopAudio(); + verifyNeverStopVibrate(); + } + + @Test + public void testRingtoneInsistentBeep_clearEffectsStopsSoundAndVibration() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Uri.fromParts("a", "b", "c"), + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + ringtoneChannel.enableVibration(true); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, true); + mService.addNotification(ringtoneNotification); + assertFalse(mAttentionHelper.shouldMuteNotificationLocked(ringtoneNotification, + DEFAULT_SIGNALS)); + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyBeepLooped(); + verifyDelayedVibrateLooped(); + + mAttentionHelper.clearSoundLocked(); + mAttentionHelper.clearVibrateLocked(); + + verifyStopAudio(); + verifyStopVibrate(); + } + + @Test + public void testRingtoneInsistentBeep_neverVibratesWhenEffectsClearedBeforeDelay() + throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Uri.fromParts("a", "b", "c"), + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + ringtoneChannel.enableVibration(true); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, true); + mService.addNotification(ringtoneNotification); + assertFalse(mAttentionHelper.shouldMuteNotificationLocked(ringtoneNotification, + DEFAULT_SIGNALS)); + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyBeepLooped(); + verifyNeverVibrate(); + + mAttentionHelper.clearSoundLocked(); + mAttentionHelper.clearVibrateLocked(); + + verifyStopAudio(); + verifyDelayedNeverVibrate(); + } + + @Test + public void testCannotInterruptRingtoneInsistentBuzz() { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Uri.EMPTY, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + ringtoneChannel.enableVibration(true); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, true); + assertFalse(mAttentionHelper.shouldMuteNotificationLocked(ringtoneNotification, + DEFAULT_SIGNALS)); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyVibrateLooped(); + + NotificationRecord interrupter = getBuzzyOtherNotification(); + assertTrue(mAttentionHelper.shouldMuteNotificationLocked(interrupter, DEFAULT_SIGNALS)); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyVibrate(1); + + assertFalse(interrupter.isInterruptive()); + assertEquals(-1, interrupter.getLastAudiblyAlertedMs()); + } + + @Test + public void testCanInterruptRingtoneNonInsistentBeep() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Settings.System.DEFAULT_RINGTONE_URI, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, false); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyBeepUnlooped(); + + NotificationRecord interrupter = getBeepyOtherNotification(); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyBeep(2); + + assertTrue(interrupter.isInterruptive()); + } + + @Test + public void testCanInterruptRingtoneNonInsistentBuzz() { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(null, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + ringtoneChannel.enableVibration(true); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, false); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyVibrate(); + + NotificationRecord interrupter = getBuzzyOtherNotification(); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyVibrate(2); + + assertTrue(interrupter.isInterruptive()); + } + + @Test + public void testRingtoneInsistentBeep_doesNotBlockFutureSoundsOnceStopped() throws Exception { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(Settings.System.DEFAULT_RINGTONE_URI, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, true); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyBeepLooped(); + + mAttentionHelper.clearSoundLocked(); + + NotificationRecord interrupter = getBeepyOtherNotification(); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyBeep(2); + + assertTrue(interrupter.isInterruptive()); + } + + @Test + public void testRingtoneInsistentBuzz_doesNotBlockFutureSoundsOnceStopped() { + NotificationChannel ringtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + ringtoneChannel.setSound(null, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION_RINGTONE).build()); + ringtoneChannel.enableVibration(true); + NotificationRecord ringtoneNotification = getCallRecord(1, ringtoneChannel, true); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyVibrateLooped(); + + mAttentionHelper.clearVibrateLocked(); + + NotificationRecord interrupter = getBuzzyOtherNotification(); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyVibrate(2); + + assertTrue(interrupter.isInterruptive()); + } + + @Test + public void testCanInterruptNonRingtoneInsistentBeep() throws Exception { + NotificationChannel fakeRingtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + NotificationRecord ringtoneNotification = getCallRecord(1, fakeRingtoneChannel, true); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + verifyBeepLooped(); + + NotificationRecord interrupter = getBeepyOtherNotification(); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyBeep(2); + + assertTrue(interrupter.isInterruptive()); + } + + @Test + public void testCanInterruptNonRingtoneInsistentBuzz() { + NotificationChannel fakeRingtoneChannel = + new NotificationChannel("ringtone", "", IMPORTANCE_HIGH); + fakeRingtoneChannel.enableVibration(true); + fakeRingtoneChannel.setSound(null, + new AudioAttributes.Builder().setUsage(USAGE_NOTIFICATION).build()); + NotificationRecord ringtoneNotification = getCallRecord(1, fakeRingtoneChannel, true); + + mAttentionHelper.buzzBeepBlinkLocked(ringtoneNotification, DEFAULT_SIGNALS); + + NotificationRecord interrupter = getBuzzyOtherNotification(); + mAttentionHelper.buzzBeepBlinkLocked(interrupter, DEFAULT_SIGNALS); + + verifyVibrate(2); + + assertTrue(interrupter.isInterruptive()); + } + + @Test + public void testBubbleSuppressedNotificationDoesntMakeSound() { + Notification.BubbleMetadata metadata = new Notification.BubbleMetadata.Builder( + mock(PendingIntent.class), mock(Icon.class)) + .build(); + + NotificationRecord record = getBuzzyNotification(); + metadata.setFlags(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); + record.getNotification().setBubbleMetadata(metadata); + record.setAllowBubble(true); + record.getNotification().flags |= FLAG_BUBBLE; + record.isUpdate = true; + record.setInterruptive(false); + + mAttentionHelper.buzzBeepBlinkLocked(record, DEFAULT_SIGNALS); + verifyNeverVibrate(); + } + + @Test + public void testOverflowBubbleSuppressedNotificationDoesntMakeSound() { + Notification.BubbleMetadata metadata = new Notification.BubbleMetadata.Builder( + mock(PendingIntent.class), mock(Icon.class)) + .build(); + + NotificationRecord record = getBuzzyNotification(); + metadata.setFlags(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); + record.getNotification().setBubbleMetadata(metadata); + record.setFlagBubbleRemoved(true); + record.setAllowBubble(true); + record.isUpdate = true; + record.setInterruptive(false); + + mAttentionHelper.buzzBeepBlinkLocked(record, DEFAULT_SIGNALS); + verifyNeverVibrate(); + } + + @Test + public void testBubbleUpdateMakesSound() { + Notification.BubbleMetadata metadata = new Notification.BubbleMetadata.Builder( + mock(PendingIntent.class), mock(Icon.class)) + .build(); + + NotificationRecord record = getBuzzyNotification(); + record.getNotification().setBubbleMetadata(metadata); + record.setAllowBubble(true); + record.getNotification().flags |= FLAG_BUBBLE; + record.isUpdate = true; + record.setInterruptive(true); + + mAttentionHelper.buzzBeepBlinkLocked(record, DEFAULT_SIGNALS); + verifyVibrate(1); + } + + @Test + public void testNewBubbleSuppressedNotifMakesSound() { + Notification.BubbleMetadata metadata = new Notification.BubbleMetadata.Builder( + mock(PendingIntent.class), mock(Icon.class)) + .build(); + + NotificationRecord record = getBuzzyNotification(); + metadata.setFlags(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); + record.getNotification().setBubbleMetadata(metadata); + record.setAllowBubble(true); + record.getNotification().flags |= FLAG_BUBBLE; + record.isUpdate = false; + record.setInterruptive(true); + + mAttentionHelper.buzzBeepBlinkLocked(record, DEFAULT_SIGNALS); + verifyVibrate(1); + } + + @Test + public void testStartFlashNotificationEvent_receiveBeepyNotification() throws Exception { + NotificationRecord r = getBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + verifyNeverVibrate(); + verify(mAccessibilityService).startFlashNotificationEvent(any(), anyInt(), + eq(r.getSbn().getPackageName())); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testStartFlashNotificationEvent_receiveBuzzyNotification() throws Exception { + NotificationRecord r = getBuzzyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + verifyVibrate(); + verify(mAccessibilityService).startFlashNotificationEvent(any(), anyInt(), + eq(r.getSbn().getPackageName())); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testStartFlashNotificationEvent_receiveBuzzyBeepyNotification() throws Exception { + NotificationRecord r = getBuzzyBeepyNotification(); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyBeepUnlooped(); + verifyDelayedVibrate(r.getVibration()); + verify(mAccessibilityService).startFlashNotificationEvent(any(), anyInt(), + eq(r.getSbn().getPackageName())); + assertTrue(r.isInterruptive()); + assertNotEquals(-1, r.getLastAudiblyAlertedMs()); + } + + @Test + public void testStartFlashNotificationEvent_receiveBuzzyBeepyNotification_ringerModeSilent() + throws Exception { + NotificationRecord r = getBuzzyBeepyNotification(); + when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_SILENT); + when(mAudioManager.getStreamVolume(anyInt())).thenReturn(0); + + mAttentionHelper.buzzBeepBlinkLocked(r, DEFAULT_SIGNALS); + + verifyNeverBeep(); + verifyNeverVibrate(); + verify(mAccessibilityService).startFlashNotificationEvent(any(), anyInt(), + eq(r.getSbn().getPackageName())); + assertFalse(r.isInterruptive()); + assertEquals(-1, r.getLastAudiblyAlertedMs()); + } + + static class VibrateRepeatMatcher implements ArgumentMatcher<VibrationEffect> { + private final int mRepeatIndex; + + VibrateRepeatMatcher(int repeatIndex) { + mRepeatIndex = repeatIndex; + } + + @Override + public boolean matches(VibrationEffect actual) { + if (actual instanceof VibrationEffect.Composed + && ((VibrationEffect.Composed) actual).getRepeatIndex() == mRepeatIndex) { + return true; + } + // All non-waveform effects are essentially one shots. + return mRepeatIndex == -1; + } + + @Override + public String toString() { + return "repeatIndex=" + mRepeatIndex; + } + } +} diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java index 4576e9be07ad..9543a2de1e13 100755 --- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java @@ -84,6 +84,7 @@ import static android.view.Display.DEFAULT_DISPLAY; import static android.view.Display.INVALID_DISPLAY; import static android.view.WindowManager.LayoutParams.TYPE_TOAST; +import static com.android.internal.config.sysui.SystemUiSystemPropertiesFlags.NotificationFlags.ENABLE_ATTENTION_HELPER_REFACTOR; import static com.android.internal.config.sysui.SystemUiSystemPropertiesFlags.NotificationFlags.FSI_FORCE_DEMOTE; import static com.android.internal.config.sysui.SystemUiSystemPropertiesFlags.NotificationFlags.SHOW_STICKY_HUN_FOR_DENIED_FSI; import static com.android.internal.widget.LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_USER_LOCKDOWN; @@ -272,6 +273,7 @@ import com.google.common.collect.ImmutableList; import org.junit.After; import org.junit.Assert; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; @@ -279,6 +281,7 @@ import org.mockito.ArgumentMatcher; import org.mockito.ArgumentMatchers; import org.mockito.InOrder; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -372,6 +375,8 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { private DevicePolicyManagerInternal mDevicePolicyManager; @Mock private PowerManager mPowerManager; + @Mock + private LightsManager mLightsManager; private final ArrayList<WakeLock> mAcquiredWakeLocks = new ArrayList<>(); private final TestPostNotificationTrackerFactory mPostNotificationTrackerFactory = new TestPostNotificationTrackerFactory(); @@ -503,9 +508,6 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { setDpmAppOppsExemptFromDismissal(false); - mService = new TestableNotificationManagerService(mContext, mNotificationRecordLogger, - mNotificationInstanceIdSequence); - // Use this testable looper. mTestableLooper = TestableLooper.get(this); // MockPackageManager - default returns ApplicationInfo with matching calling UID @@ -527,8 +529,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { Object[] args = invocation.getArguments(); return (int) args[1] == mUid; }); - final LightsManager mockLightsManager = mock(LightsManager.class); - when(mockLightsManager.getLight(anyInt())).thenReturn(mock(LogicalLight.class)); + when(mLightsManager.getLight(anyInt())).thenReturn(mock(LogicalLight.class)); when(mAudioManager.getRingerModeInternal()).thenReturn(AudioManager.RINGER_MODE_NORMAL); when(mPackageManagerClient.hasSystemFeature(FEATURE_WATCH)).thenReturn(false); when(mUgmInternal.newUriPermissionOwner(anyString())).thenReturn(mPermOwner); @@ -601,6 +602,15 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { return wl; }); + // TODO (b/291907312): remove feature flag + mTestFlagResolver.setFlagOverride(ENABLE_ATTENTION_HELPER_REFACTOR, false); + initNMS(); + } + + private void initNMS() throws Exception { + mService = new TestableNotificationManagerService(mContext, mNotificationRecordLogger, + mNotificationInstanceIdSequence); + // apps allowed as convos mService.setStringArrayResourceValue(PKG_O); @@ -617,7 +627,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { mWorkerHandler = spy(mService.new WorkerHandler(mTestableLooper.getLooper())); mService.init(mWorkerHandler, mRankingHandler, mPackageManager, mPackageManagerClient, - mockLightsManager, mListeners, mAssistants, mConditionProviders, mCompanionMgr, + mLightsManager, mListeners, mAssistants, mConditionProviders, mCompanionMgr, mSnoozeHelper, mUsageStats, mPolicyFile, mActivityManager, mGroupHelper, mAm, mAtm, mAppUsageStats, mDevicePolicyManager, mUgm, mUgmInternal, mAppOpsManager, mUm, mHistoryManager, mStatsManager, @@ -628,11 +638,17 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { // Return first true for RoleObserver main-thread check when(mMainLooper.isCurrentThread()).thenReturn(true).thenReturn(false); mService.onBootPhase(SystemService.PHASE_SYSTEM_SERVICES_READY, mMainLooper); + Mockito.reset(mHistoryManager); verify(mHistoryManager, never()).onBootPhaseAppsCanStart(); mService.onBootPhase(SystemService.PHASE_THIRD_PARTY_APPS_CAN_START, mMainLooper); verify(mHistoryManager).onBootPhaseAppsCanStart(); - mService.setAudioManager(mAudioManager); + // TODO b/291907312: remove feature flag + if (mTestFlagResolver.isEnabled(ENABLE_ATTENTION_HELPER_REFACTOR)) { + mService.mAttentionHelper.setAudioManager(mAudioManager); + } else { + mService.setAudioManager(mAudioManager); + } mStrongAuthTracker = mService.new StrongAuthTrackerFake(mContext); mService.setStrongAuthTracker(mStrongAuthTracker); @@ -1653,6 +1669,23 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { } @Test + public void testEnqueueNotificationWithTag_WritesExpectedLogs_NAHRefactor() throws Exception { + // TODO b/291907312: remove feature flag + mTestFlagResolver.setFlagOverride(ENABLE_ATTENTION_HELPER_REFACTOR, true); + // Cleanup NMS before re-initializing + if (mService != null) { + try { + mService.onDestroy(); + } catch (IllegalStateException | IllegalArgumentException e) { + // can throw if a broadcast receiver was never registered + } + } + initNMS(); + + testEnqueueNotificationWithTag_WritesExpectedLogs(); + } + + @Test public void testEnqueueNotificationWithTag_LogsOnMajorUpdates() throws Exception { final String tag = "testEnqueueNotificationWithTag_LogsOnMajorUpdates"; Notification original = new Notification.Builder(mContext, @@ -9015,6 +9048,24 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { } @Test + public void testOnBubbleMetadataChangedToSuppressNotification_soundStopped_NAHRefactor() + throws Exception { + // TODO b/291907312: remove feature flag + mTestFlagResolver.setFlagOverride(ENABLE_ATTENTION_HELPER_REFACTOR, true); + // Cleanup NMS before re-initializing + if (mService != null) { + try { + mService.onDestroy(); + } catch (IllegalStateException | IllegalArgumentException e) { + // can throw if a broadcast receiver was never registered + } + } + initNMS(); + + testOnBubbleMetadataChangedToSuppressNotification_soundStopped(); + } + + @Test public void testGrantInlineReplyUriPermission_recordExists() throws Exception { int userId = UserManager.isHeadlessSystemUserMode() ? UserHandle.getUserId(UID_HEADLESS) |