前言
Android15即将release,由于没权限看Android15的源码(除了GPL部分,只有合作厂商可以在release之前看到代码),只好先下载beta版模拟器镜像,导出framework.jar,确认一下AudioManager类是否被final修饰,或者构造方法不再是public。
我以前做过浏览器项目,直接包装系统WebView组件的那种。如果是照着官方文档调用WebView提供的公开api去做,毫无技术壁垒,市场效果可能还不如新手直接修改开源项目做出的产品。
所以个人追求研究一些与众不同的东西,比如之前博客分享过了的InlineHook实现WebView无损内录。
再比如,用户把浏览器放到后台,打开游戏或其他有声App,网页里的视频和音频会暂停。这是用户手机WebView的Chromium内核做的处理,WebView并没有提供控制这个行为的接口,但一样有奇巧淫技能做到。
这个浏览器产品已经没兴趣更新了。接下来就慢慢把一些GPT-4o想不到的思路分享出来。
音频焦点简述
Android系统为了避免不同App同时播放音频,引入了音频焦点的概念。
简单来说,例如App A开始播放音乐之后,用户使用App B播放音乐,那么Android系统就会通知App A“音频焦点丢失”,从而让App A可以做出相应的响应。
了解到这里即可,具体的行为也和Android版本有关,音频焦点更多细节参考文档。
WebView的AudioManager
Android的WebView组件源码属于Chromium项目,AOSP中只有编译好的WebView.apk。
WebView也是基于AndroidSDK去开发的,并且他的代码会被加载进App,和普通App一样,它内部要通过Context获取AudioManager,才能调用相关API。可以直接在chromium项目搜索requestAudioFocus
,定位到获取AudioManager的相关代码。比如126.0.6429.1版本content\public\android\java\src\org\chromium\content\browser\AudioFocusDelegate.java
:
private boolean requestAudioFocusInternal() {
AudioManager am =
(AudioManager)
ContextUtils.getApplicationContext()
.getSystemService(Context.AUDIO_SERVICE);
int result;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
AudioAttributes playbackAttributes =
new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN)
.build();
mFocusRequest =
new AudioFocusRequest.Builder(mFocusType)
.setAudioAttributes(playbackAttributes)
.setAcceptsDelayedFocusGain(false)
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener(this, mHandler)
.build();
result = am.requestAudioFocus(mFocusRequest);
} else {
result = am.requestAudioFocus(this, AudioManager.STREAM_MUSIC, mFocusType);
}
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
}
扩展AudioManager
有多种修改WebView调用的AudioManager函数参数/返回值的办法,Java层实现Binder Hook是最常见、最成熟的解决方案,也是各种分身/沙箱类产品必备技术。
但是对于这种小问题,还是直接继承AudioManager比较简单,也不用考虑绕过反射黑名单的问题(尽管使用FreeReflection
只需要一行代码)。
已经确定了WebView是通过Application获取的AudioManager
,我们可以直接重写Application的getSystemService
,向WebView返回我们自己的AudioManager子类就好了。如果有需要,可以用调用栈来判断是否来自WebView。
AudioManager有两个构造方法(本文仅考虑API26-API35beta4),分别是无参数和一个context参数。SDK目录里的framework.jar就提供了AudioManager无参构造方法的Stub:
AudioManager() {
throw new RuntimeException("Stub!");
}
可惜没什么用,因为AudioManager有些方法是需要context的(比如adjustStreamVolume
),如果通过无参函数创建AudioManager,还要调用setContext
,但setContext
对于target34及更高版本的app属于反射黑名单,再去绕过反射限制,倒不如直接代理IAudioService了。
/**
* @hide
* For test purposes only, will throw NPE with some methods that require a Context.
*/
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
public AudioManager() {
}
/**
* @hide
*/
@UnsupportedAppUsage
public AudioManager(Context context) {
setContext(context);
}
//...
private void setContext(Context context) {
mOriginalContextDeviceId = context.getDeviceId();
mApplicationContext = context.getApplicationContext();
if (mApplicationContext != null) {
mOriginalContext = null;
} else {
mOriginalContext = context;
}
sContext = new WeakReference<>(context);
}
//...
public void adjustStreamVolume(int streamType, int direction, @PublicVolumeFlags int flags) {
final IAudioService service = getService();
try {
service.adjustStreamVolumeWithAttribution(streamType, direction, flags,
getContext().getOpPackageName(), getContext().getAttributionTag());
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
另一方面,framewrok.jar里面的这个构造方法没有标记public,在编译期间,java编译器认为它不能被继承,也就不能成功编译。
所以接下来想办法继承一下带Context类参数的构造方法。
如何继承AudioManager
Android SDK目录的framework.jar,其实就是各种可以让开发者直接调用的类和方法的Stub,而AOSP源码中被@Hide
等注解修饰的一些方法不会出现在这个framework.jar中。Github有个叫做"aosp-android-jar"仓库,目的是为了便于开发系统级App更方便调用更多的接口。可以把SDK目录的jar替换为这个项目的jar。
但是,这个项目去除了一些被用于避免空指针的注解@NonNull
、@Nullable
, 很可能导致我们调用系统API时忘了判空,比如android.webkit.WebView
的loadUrl
本来是声明了传参判空职责交给调用者的:
public void loadUrl(@NonNull String url) {
checkThread();
mProvider.loadUrl(url);
}
还有一个办法是直接在项目中创建一个同包名同类名的AudioManager类(其他不存在于SDK的类或者函数,并且无需反射的方法,也可以用这个技巧去调用)。
可以在gradle进行一些配置避免我们的类打入到APK中。
但是即使打入了APK也无所谓,在App进程创建时已经加载了系统自己的framework.jar的AudioManager,对AudioManager的调用不会调到我们创建的空的AudioManager类。(或者从双亲委派模型的角度考虑,加载AudioManager时,会优先通过父加载器/启动加载器尝试加载)
这样虽然可以正常编译,但AndroidStudio的代码检查,似乎只看得到framework.jar中的AudioManager,所以会把文件标记出语法错误:
framework.jar文件实际上就是个压缩包,所以我最终的解决方法是,把自己创建AudioManager Stub类编译为class文件,然后用压缩软件,把它替换进framework.jar。
重写关键函数
回到标题,要实现WebView视频不会由于失去音频焦点而中断,只需要让WebView感知不到自己失去了音频焦点就行了,也就是直接拦截换掉焦点回调OnAudioFocusChangeListener
。一个最简单的代码如下:
public class MyAudioManager extends AudioManager{
public MyAudioManager(Context context){
super(context);
}
@Override
public int requestAudioFocus(AudioFocusRequest focusRequest) {
AudioFocusRequest build = new AudioFocusRequest.Builder(focusRequest)
.setOnAudioFocusChangeListener(focusChange -> { })
.build();
return super.requestAudioFocus(build);
}
}
这种思路也可以用到其他各种Service,以及除了WebView的其他第三方库的场景。
上文涉及到的各种做法,或许有更优雅的办法?欢迎评论区补充。