Android 开发太难了,去除 Toast前面的应用名

刘望舒

共 5827字,需浏览 12分钟

 · 2021-11-14

作者:Camellia666

https://www.jianshu.com/p/a47bcf62109c

1.背景


这是个沙雕操作,原因是:在小米手机的部分机型上,弹Toast时会在吐司内容前面带上app名称,如下:




此时产品经理发话了:为了统一风格,在小米手机上去掉Toast前的应用名。


网上有以下解决方案,比如:先给toast的message设置为空,然后再设置需要提示的message,如下:



Toast toast = Toast.makeText(context, “”, Toast.LENGTH_LONG);

toast.setText(message);
toast.show();


但这些都不能从根本上解决问题,于是Hook Toast的方案诞生了。

2.分析

首先分析一下Toast的创建过程。


Toast的简单使用如下:



Toast.makeText(this,"abc",Toast.LENGTH_LONG).show();


1,构造toast


通过makeText()构造一个Toast,具体代码如下:


public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
        @NonNull CharSequence text, @Duration int duration) {
    if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
        Toast result = new Toast(context, looper);
        result.mText = text;
        result.mDuration = duration;
        return result;
    } else {
        Toast result = new Toast(context, looper);
        View v = ToastPresenter.getTextToastView(context, text);
        result.mNextView = v;
        result.mDuration = duration;

        return result;
    }
}


makeText()中也就是设置了时长以及要显示的文本或自定义布局,对Hook没有什么帮助。


2,展示toast


接着看下Toast的show():



public void show() {
    ...

    INotificationManager service = getService();
    String pkg = mContext.getOpPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;
    final int displayId = mContext.getDisplayId();

    try {
        if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
            if (mNextView != null) {
                // It's a custom toast
                service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
            } else {
                // It's a text toast
                ITransientNotificationCallback callback =
                        new CallbackBinder(mCallbacks, mHandler);
                service.enqueueTextToast(pkg, mToken, mText, mDuration, displayId, callback);
            }
        } else {
            // 展示toast
            service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
        }
    } catch (RemoteException e) {
        // Empty
    }
}


代码很简单,主要是通过service的enqueueToast()enqueueTextToast()两种方式显示toast。


service是一个INotificationManager类型的对象,INotificationManager是一个接口,这就为动态代理提供了可能。


service是在每次show()时通过getService()获取,那就来看看getService():



@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private static INotificationManager sService;

@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
static private INotificationManager getService() {
    if (sService != null) {
        return sService;
    }
    sService = INotificationManager.Stub.asInterface(
            ServiceManager.getService(Context.NOTIFICATION_SERVICE));
    return sService;
}


getService()最终返回的是sService,是一个懒汉式单例,因此可以通过反射获取到其实例。


3,小结


sService是一个单例,可以反射获取到其实例。


sService实现了INotificationManager接口,因此可以动态代理。


因此可以通过Hook来干预Toast的展示。

3.撸码

理清了上面的过程,实现就很简单了,直接撸码:


1,获取sService的Field



Class toastClass = Toast.class;

Field sServiceField = toastClass.getDeclaredField("sService");
sServiceField.setAccessible(true);


2,动态代理替换


Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        return null;
    }
});
// 用代理对象给sService赋值
sServiceField.set(null, proxy);


3,获取sService原始对象


因为动态代理不能影响被代理对象的原有流程,因此需要在第二步的InvocationHandler()invoke()中需要执行原有的逻辑,这就需要获取sService的原始对象。


前面已经获取到了sService的Field,它是静态的,那直接通过sServiceField.get(null)获取不就可以了?然而并不能获取到,这是因为整个Hook操作是在应用初始化时,整个应用还没有执行过Toast.show()的操作,因此sService还没有初始化(因为它是一个懒汉单例)。


既然不能直接获取,那就通过反射调用一下:



Method getServiceMethod = toastClass.getDeclaredMethod("getService"null);
getServiceMethod.setAccessible(true);
Object service = getServiceMethod.invoke(null);


接着完善一下第二步代码:



Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        return method.invoke(service, args);
    }
});


到此,已经实现了对Toast的代理,Toast可以按照原始逻辑正常执行,但还没有加入额外逻辑。


4,添加Hook逻辑


InvocationHandlerinvoke()方法中添加额外逻辑:



Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 判断enqueueToast()方法时执行操作
        if (method.getName().equals("enqueueToast")) {
            Log.e("hook", method.getName());
            getContent(args[1]);
        }
        return method.invoke(service, args);
    }
});


args数组的第二个是TN类型的对象,其中有一个LinearLayout类型的mNextView对象,mNextView中有一个TextView类型的childView,这个childView就是展示toast文本的那个TextView,可以直接获取其文本内容,也可以对其赋值,因此代码如下:



private static void getContent(Object arg) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    // 获取TN的class
    Class tnClass = Class.forName(Toast.class.getName() + "$TN");
    // 获取mNextView的Field
    Field mNextViewField = tnClass.getDeclaredField("mNextView");
    mNextViewField.setAccessible(true);
    // 获取mNextView实例
    LinearLayout mNextView = (LinearLayout) mNextViewField.get(arg);
    // 获取textview
    TextView childView = (TextView) mNextView.getChildAt(0);
    // 获取文本内容
    CharSequence text = childView.getText();
    // 替换文本并赋值
    childView.setText(text.toString().replace("HookToast:"""));
    Log.e("hook""content: " + childView.getText());
}


最后看一下效果:




总结


这个一个沙雕操作,实际应用中这种需求也比较少见。通过Hook的方式可以统一控制,而且没有侵入性。大佬勿喷!!!




耗时2年,Android进阶三部曲第三部《Android进阶指北》出版!

『BATcoder』做了多年安卓还没编译过源码?一个视频带你玩转!

『BATcoder』我去!安装Ubuntu还有坑?

重生!进阶三部曲第一部《Android进阶之光》第2版 出版!


 BATcoder技术群,让一部分人先进大厂

大家,我是刘望舒,腾讯TVP,著有三本业内知名畅销书,连续四年蝉联电子工业出版社年度优秀作者,百度百科收录的资深技术专家。


想要加入 BATcoder技术群,公号回复BAT 即可。

为了防止失联,欢迎关注我的小号

  微信改了推送机制,真爱请星标本公号👇
浏览 138
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报