開(kāi)篇廢話
線上用戶遇到一個(gè)問(wèn)題,就是會(huì)經(jīng)常彈出一個(gè)Toast,但是這個(gè)Toast的文案在端上和后臺(tái)都沒(méi)有找到,只能懷疑是第三方SDK彈出的,但是又不能一個(gè)一個(gè)問(wèn),問(wèn)了也不一定幫你好好查,所以只能自食其力。
遇到的問(wèn)題
如何Hook Toast,線上用戶是Android 13。
開(kāi)始解決
所以有兩條路,一條是Hook所有調(diào)用Toast的地方,一條是通過(guò)Xposed框架解決,Xposed成本太高,所以采用Hook調(diào)用Toast的地方,方案采用站在巨人的肩膀上的第三方庫(kù)me.ele:lancet-plugin。這個(gè)第三方庫(kù),屬于在編譯期,動(dòng)態(tài)生成代碼,所以我們可以在所有調(diào)用Toast的方法前后添加我們的代碼,輸出堆棧信息。
添加第三方庫(kù)
在Porject的build.gradle中添加hook插件。
buildscript {
dependencies {
classpath 'com.bytedance.tools.lancet:lancet-plugin-asm6:1.0.0' //看情況添加,是為了解決asm6問(wèn)題
classpath 'me.ele:lancet-plugin:1.0.6' //hook框架,必須添加
}
}
在Module的build.gradle頂部中添加引用插件。
apply plugin: 'com.glazero.android.spi'
在Module的build.gradle中導(dǎo)包hook工程。
dependencies {
implementation 'me.ele:lancet-base:1.0.6'
}
找到Hook點(diǎn)
我們先來(lái)看一下Toast的源碼,Toast源碼還是比較簡(jiǎn)單的,我這里列舉一些關(guān)鍵代碼,方便我們進(jìn)行觀察。
public class Toast {
@Nullable
private View mNextView;
@Nullable
private CharSequence mText;
public static Toast makeText(Context context, CharSequence text, @Duration int duration) {
return makeText(context, null, text, duration);
}
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;
}
}
public Toast(Context context) {
this(context, null);
}
public Toast(@NonNull Context context, @Nullable Looper looper) {
}
public void show() {
}
public void setText(@StringRes int resId) {
setText(mContext.getText(resId));
}
public void setText(CharSequence s) {
if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
if (mNextView != null) {
throw new IllegalStateException(
"Text provided for custom toast, remove previous setView() calls if you "
+ "want a text toast instead.");
}
mText = s;
} else {
if (mNextView == null) {
throw new RuntimeException("This Toast was not created with Toast.makeText()");
}
TextView tv = mNextView.findViewById(com.android.internal.R.id.message);
if (tv == null) {
throw new RuntimeException("This Toast was not created with Toast.makeText()");
}
tv.setText(s);
}
}
@Deprecated
public void setView(View view) {
mNextView = view;
}
}
可以看出,我們需要注意的關(guān)鍵點(diǎn)是,用戶在makeText()、setText()、setView()方法、這些方法設(shè)置了彈出的Toast的內(nèi)容。重點(diǎn)來(lái)了,我們要對(duì)這些方法,用第三方庫(kù),進(jìn)行Hook。
Hook具體方法
先放出實(shí)現(xiàn)類,大家觀察一下。
public class ToastsLancet {
private static final String TAG = "HookToast";
@TargetClass("android.widget.Toast")
@Proxy("show")
public void show() {
Log.i(TAG, "Toast方法被調(diào)用----------> showToast()");
showStackTraceLog();
Origin.callVoid();
}
@TargetClass("android.widget.Toast")
@Proxy("makeText")
public static Toast makeText(Context context, CharSequence text, int duration) {
Log.i(TAG, "Toast方法被調(diào)用----------> makeText(text)");
Log.i(TAG, "text = " + text);
showStackTraceLog();
return (Toast) Origin.call();
}
@TargetClass("android.widget.Toast")
@Proxy("makeText")
public static Toast makeText(Context context, int res, int duration) {
Log.i(TAG, "Toast方法被調(diào)用----------> makeText(res)");
Log.i(TAG, "text = " + Application.getString(res));
showStackTraceLog();
return (Toast) Origin.call();
}
@TargetClass("android.widget.Toast")
@Proxy("setText")
public void setText(CharSequence text) {
Log.i(TAG, "Toast方法被調(diào)用----------> setText(text)");
Log.i(TAG, "text = " + text);
showStackTraceLog();
Origin.callVoid();
}
@TargetClass("android.widget.Toast")
@Proxy("setText")
public void setText(int res) {
Log.i(TAG, "Toast方法被調(diào)用----------> setText(res)");
Log.i(TAG, "text = " + Application.getString(res));
showStackTraceLog();
Origin.callVoid();
}
@TargetClass("android.widget.Toast")
@Proxy("setView")
public void setView(View view) {
List<TextView> textViewList = FindViewHelper.findTextView(view);
for (TextView textView : textViewList) {
Log.i(TAG, "Toast方法被調(diào)用----------> setView(view)");
Log.i(TAG, "text = " + textView.getText().toString());
showStackTraceLog();
}
Origin.callVoid();
}
private static void showStackTraceLog() {
//打印堆棧信息
Log.i(TAG, Log.getStackTraceString(new Throwable()));
}
}
Application.getString()方法,大家可以替換成自己的方法,去獲取到具體的Toast內(nèi)容,然后進(jìn)行打印內(nèi)容和堆棧信息。
@TargetClass代表Hook的類名,@Proxy代表是的Hook的方法名,Origin.callVoid();代表調(diào)用原來(lái)的代碼,并且無(wú)返回值,如果不調(diào)用則不會(huì)顯示Toast的了,Origin.call()是有返回值的調(diào)用方法。
在setView()方法之后,我們需要通過(guò)遍歷View的方式,找到TextView,再拿到Toast的內(nèi)容,但是如果調(diào)用的地方是先進(jìn)行setView(),再進(jìn)行TextView.setText(),那么現(xiàn)在是拿不到的,只能通過(guò)相同方法去Hook TextView.setText()方法了,就不展開(kāi)了。
找到所有TextView的方法我也列出來(lái)。
public class FindViewHelper {
public static List<TextView> findTextView(View view) {
if (view == null) {
return new ArrayList<>();
}
List<TextView> visited = new ArrayList<>();
List<View> unvisited = new ArrayList<>();
unvisited.add(view);
while (!unvisited.isEmpty()) {
View child = unvisited.remove(0);
if (child instanceof TextView) {
visited.add((TextView) child);
}
if (!(child instanceof ViewGroup)){
continue;
}
ViewGroup group = (ViewGroup) child;
final int childCount = group.getChildCount();
for (int i=0; i < childCount; i++) {
unvisited.add(group.getChildAt(i));
}
}
return visited;
}
}
寫在最后
這里只提供一種解決問(wèn)題的思路,除了這種方案,還可以通過(guò)上面提到的Xposed框架解決,這種方案是可以直接Hook到系統(tǒng)源碼的,只不過(guò)我現(xiàn)在了解的在自己工程中使用,最高支持到Android 11,具體可以參考github-epic,不過(guò)作者主要精力在Xposed框架太極上,所以這里的文檔都沒(méi)有更新,類名方法名有小調(diào)整。