转载

一点小小的经验: Android 上的「安全音量」

一个小问题

在做Android MediaPlayer相关的开发时,可能都会遇到一个需求:自定义音量控制条,如触摸屏幕某块区域则随之调整媒体播放音量并显示自定义的提示条。

设置音量的代码一般形如下:

AudioManager audioManager = (AudioManager)context.getSystemService(AUDIO_SERVICE);
audioManager.setStreamVolume(STREAM_MUSIC, newIndex, 0); // flags 为0因为我们要自己做提示

但当 Android 设备插上耳机,为了避免音量过高伤害用户听力,会触发其“安全音量”(Safe Media Volume)机制,如果在未经用户确认允许使用大音量时,且这时设置音量 newIndex 超过其推荐阈值,则这段代码执行完你会发现毫无反应,播放的声音依然不会很大。

如何处理?

其实很简单,关键在于往往被人忽略的最后一个参数 flags

只要在设置音量后,复查一次当前值是否相当,如果比较小,则交由系统来显示音量提示对话框。而此时因欲设定的值超过推荐值,一般会触发「音量过高警告」提示用户,用户确认后即可设置成功。

代码如下:

audioManager.setStreamVolume(STREAM_MUSIC, newIndex, 0);
if (Build.VERSION.SDK_INT >= 18 && audioManager.getStreamVolume(STREAM_MUSIC) < newIndex) {
    audioManager.setStreamVolume(STREAM_MUSIC, newIndex, FLAG_SHOW_UI);
}

效果如图:(用哔哩哔哩Android 客户端播放视频时滑动屏幕调整音量)

一点小小的经验: Android 上的「安全音量」

ps:这个对话框实现各个ROM厂商不一定一致。

pps:这个对话框原生Android M 只会在20小时内提示一次,如果你点过了确定。

下面为废话时间。

何谓“安全音量”

说欧州学者发现越来越多的小年轻失聪,其中大都因为喜欢用便携式设备长时间听音乐,想想当年几乎人手一个的随声听、MP3播放器。

心怀天下的组织(欧盟)针对这一类便携式音乐播放器提出了对限制输出声压级(SPL)的要求和指标(EN 60950等),并指出需要在合适的地方有音量过高有损听力的警告语。

手机属于便携式设备也可以播放音乐,自然也在此列。

想了解更多可以阅读另一个组织WHO的一篇相关调查报告: Situation analysis for safe listening devices’ standards

AudioService 中关于Safe Media Volume的实现

扯回 Android 系统上来,AudioService 会在系统启动后初始化对于媒体播放“安全音量”相关的设置(下面的源码都来自于Android M源码):

一些与之相关的常量定义:

AudioService.java
public class AudioService extends IAudioService.Stub {
    // mSafeMediaVolumeState 当前媒体音量是否被限制的状态标识.
    // 设备启动时为 SAFE_MEDIA_VOLUME_NOT_CONFIGURED. 
    // 根据国家不同或是 SAFE_MEDIA_VOLUME_ACTIVE 或是 SAFE_MEDIA_VOLUME_DISABLED.
    // 当用户确认过后会调用 disableSafeMediaVolume() 设为 SAFE_MEDIA_VOLUME_INACTIVE.
    private static final int SAFE_MEDIA_VOLUME_NOT_CONFIGURED = 0;
    private static final int SAFE_MEDIA_VOLUME_DISABLED = 1;
    private static final int SAFE_MEDIA_VOLUME_INACTIVE = 2;
    private static final int SAFE_MEDIA_VOLUME_ACTIVE = 3;
    private Integer mSafeMediaVolumeState;
    // Mobile country code,这里主要用来区分国家
    private int mMcc = 0;
    // 推荐的安全音量
    private int mSafeMediaVolumeIndex;
    // mSafeMediaVolumeDevices 强制开启SafeVolume的输出设备(耳机)
    private final int mSafeMediaVolumeDevices = AudioSystem.DEVICE_OUT_WIRED_HEADSET |
                                                AudioSystem.DEVICE_OUT_WIRED_HEADPHONE;
    // mMusicActiveMs 在禁用SafeVolume下的使用耳机的累计时长.
    // 当累计达到 UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX 时会自动开启SafeVolume
    private int mMusicActiveMs;
    private static final int UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX = (20 * 3600 * 1000); // 20 hours
    private static final int MUSIC_ACTIVE_POLL_PERIOD_MS = 60000;  // 1 minute polling interval
    private static final int SAFE_VOLUME_CONFIGURE_TIMEOUT_MS = 30000;  // 30s after boot completed
    ...
}

构造方法:

AudioService.java
    public AudioService(Context context) {
        ...
        // 初始化当前SafeVolume的状态
        mSafeMediaVolumeState = new Integer(Settings.Global.getInt(mContentResolver, Settings.Global.AUDIO_SAFE_VOLUME_STATE, SAFE_MEDIA_VOLUME_NOT_CONFIGURED));
        // 真正的安全阈值会在后面的onConfigureSafeVolume()方法中赋予
        mSafeMediaVolumeIndex = mContext.getResources().getInteger(com.android.internal.R.integer.config_safe_media_volume_index) * 10;
        ...
    }
    ...

onSystemReady()

系统进程初始化时会调用 systemReady() ,最终走到 onSystemReady()

AudioService.java
    public void onSystemReady() {
        mSystemReady = true;
        ...
        // 往mAudioHandler中发安全音量相关的延时消息,消息会在 30s 后 onConfigureSafeVolume()方法中处理
        sendMsg(mAudioHandler,
                MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED,
                SENDMSG_REPLACE,
                0,
                0,
                TAG,
                SAFE_VOLUME_CONFIGURE_TIMEOUT_MS);
        ...
    }
    ...
}

onConfigureSafeVolume()

针对各个国家的各自标准配置推荐值初始化:

AudioService.java
private void onConfigureSafeVolume(boolean force, String caller) {
    synchronized (mSafeMediaVolumeState) {
        int mcc = mContext.getResources().getConfiguration().mcc;
        if ((mMcc != mcc) || ((mMcc == 0) && force)) {
            // 从 values 中获取具体的安全音量阈值
            mSafeMediaVolumeIndex =  mContext.getResources().getInteger(com.android.internal.R.integer.config_safe_media_volume_index) * 10;
            boolean safeMediaVolumeEnabled =  SystemProperties.getBoolean("audio.safemedia.force", false) || mContext.getResources().getBoolean(com.android.internal.R.bool.config_safe_media_volume_enabled);

            boolean safeMediaVolumeBypass =                     SystemProperties.getBoolean("audio.safemedia.bypass", false);

            // persitedSate 只会是 "disabled" 或者 "active",这个变量值作用于下次启动且不能是 "inactive"
            int persistedState;
            if (safeMediaVolumeEnabled && !safeMediaVolumeBypass) {
                persistedState = SAFE_MEDIA_VOLUME_ACTIVE;
                // 有可能在这个方法被调用之前用户已经设为"inactive"了.
                if (mSafeMediaVolumeState != SAFE_MEDIA_VOLUME_INACTIVE) {
                    if (mMusicActiveMs == 0) {
                        mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_ACTIVE;
                        // 降低音量到安全阈值
                        enforceSafeMediaVolume(caller);
                    } else {
                        // 用户已经确认过
                        mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_INACTIVE;
                    }
                }
            } else {
                persistedState = SAFE_MEDIA_VOLUME_DISABLED;
                mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_DISABLED;
            }
            mMcc = mcc;
            // 发送保存 persistedState 的消息
            sendMsg(mAudioHandler,
                    MSG_PERSIST_SAFE_VOLUME_STATE,
                    SENDMSG_QUEUE,
                    persistedState,
                    0,
                    null,
                    0);
        }
    }
}

enforceSafeMediaVolume()

强制将输出到耳机的媒体音量降低到推荐值:

AudioService.java
private void enforceSafeMediaVolume(String caller) {
    VolumeStreamState streamState = mStreamStates[AudioSystem.STREAM_MUSIC];
    int devices = mSafeMediaVolumeDevices; // 0x4|0x8 = 0xC
    int i = 0;
    while (devices != 0) {
        // device 的定义从0x0开始,见 android.media.AudioSystem
        int device = 1 << i++;
        // 判断设备需不需要应用SafeVolume
        if ((device & devices) == 0) {
            continue;
        }
        // 获取当前设备的音量状态
        int index = streamState.getIndex(device);
        if (index > mSafeMediaVolumeIndex) {
            // 设置内存缓存的音量值为推荐值 mSafeMediaVolumeIndex
            streamState.setIndex(mSafeMediaVolumeIndex, device, caller);
            // 发送“设置当前音量值到设备”的消息
            sendMsg(mAudioHandler,
                    MSG_SET_DEVICE_VOLUME,
                    SENDMSG_QUEUE,
                    device,
                    0,
                    streamState,
                    0);
        }
        devices &= ~device;
    }
}

为免废话过长就不详解 AudioService 其它无关本文主题的方法了。

下面进入正题,来看一下“安全音量警告”的弹窗是怎么触发的。

setStreamVolume()

通过 AudioManager.setStreamVolume() 会调到 AudioService 中的同名方法:

AudioService.java
private void setStreamVolume(int streamType, int index, int flags, String callingPackage,
        String caller, int uid) {
    ...
    ensureValidStreamType(streamType);
    // 对于 streamType = STREAM_MUSIC 来说 ,streamTypeAlias = streamType = STREAM_MUSIC
    int streamTypeAlias = mStreamVolumeAlias[streamType];
    VolumeStreamState streamState = mStreamStates[streamTypeAlias];

    final int device = getDeviceForStream(streamType);
    int oldIndex;

    ...

    synchronized (mSafeMediaVolumeState) {
        // reset any pending volume command
        mPendingVolumeCommand = null;

        oldIndex = streamState.getIndex(device);

        index = rescaleIndex(index * 10, streamType, streamTypeAlias);

        ...
        // 当前用户未确认,且媒体音量超过推荐值,则会返回false
        if (!checkSafeMediaVolume(streamTypeAlias, index, device)) {
            // 通知 IVolumeController 处理安全音量的警告
            mVolumeController.postDisplaySafeVolumeWarning(flags);
            // 会在用户确认后执行真正的更新音量操作
            mPendingVolumeCommand = new StreamVolumeCommand(
                                                streamType, index, flags, device);
        } else {
            // 
            onSetStreamVolume(streamType, index, flags, device, caller);
            index = mStreamStates[streamType].getIndex(device);
        }
    }
    // 会调用IVolumeController.volumeChanged() 进行音量变更的相关提示
    sendVolumeUpdate(streamType, oldIndex, index, flags);
}

IVolumeController

音量相关控制逻辑会在 IVolumeController 这个远程服务中处理,因为涉及到 UI交互所以实现的比较“绕”,避免篇幅太长,就不一一详解了(想看更多请翻阅 com.android.systemui.volume 包下源码 )。

只需要知道 mVolumeController 其实是 VolumeDialogController 类中一个的内部类 VC 实例即可,而 VC 则与 VolumeDialog 之间通过 Callback 纠缠在一起。

SafetyWarningDialog.show()

通过 mVolumeController.postDisplaySafeVolumeWarning(flags) 传递的 flags 最终会走到 VolumeDialog 类中用来处理 SafetyWarningDialog 的显示逻辑

VolumeDialog.java
public class VolumeDialog {
    private void showSafetyWarningH(int flags) {
        if ((flags & (AudioManager.FLAG_SHOW_UI | AudioManager.FLAG_SHOW_UI_WARNINGS)) != 0
                || mShowing) {
            synchronized (mSafetyWarningLock) {
                if (mSafetyWarning != null) {
                    return;
                }
                mSafetyWarning = new SafetyWarningDialog(mContext, mController.getAudioManager()) {
                    @Override
                    protected void cleanUp() {
                        synchronized (mSafetyWarningLock) {
                            mSafetyWarning = null;
                        }
                        recheckH(null);
                    }
                };
                mSafetyWarning.show();
            }
            recheckH(null);
        }
        rescheduleTimeoutH();
    }
}

AudioManager.disableSafeMediaVolume()

当用户在 SafetyWarningDialog 确认后,会通过 AudioManager 回调到 AudioService 关闭安全音量模式,并执行上次遗留的 mPendingVolumeCommand

AudioService.java
public void disableSafeMediaVolume(String callingPackage) {
    enforceVolumeController("disable the safe media volume");
    synchronized (mSafeMediaVolumeState) {
        setSafeMediaVolumeEnabled(false, callingPackage);
        if (mPendingVolumeCommand != null) {
            onSetStreamVolume(mPendingVolumeCommand.mStreamType,
                              mPendingVolumeCommand.mIndex,
                              mPendingVolumeCommand.mFlags,
                              mPendingVolumeCommand.mDevice,
                              callingPackage);
            mPendingVolumeCommand = null;
        }
    }
}

最后的废话

絮絮叨叨这么多,不知道你有没有真的学会这一点小小的「人生经验」<(▰˘◡˘▰)>

原文  http://www.yrom.net/blog/2016/08/06/one-little-tip-to-handle-safe-media-volume-level-on-android/
正文到此结束
Loading...