在Android系统中有一个窗口的概念,我们所看到的所有界面都是窗口,由系统的WindowManagerService(WMS)管理着。App在没有悬浮窗权限下,不能添加系统级窗口,但却可以显示Toast。这说明Toast不在悬浮窗权限控制下,系统中某些机制保证了Toast的显示。
1、打开Toast源码,在调用Toast.show()后,Toast取出了系统服务NWS,与通知栏一样,Toast算是通知的一种。
/**
* Show the view for the specified duration.
*/
public void show() {
if (mNextView == null) {
throw new RuntimeException("setView must have been called");
}
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
try {
service.enqueueToast(pkg, tn, mDuration);
} catch (RemoteException e) {
// Empty
}
}
其中TN是一个Binder对象,用于App与NMS间通信,对于NMS来说一个TN代表了一个Toast;mNextView是用于显示Toast内容的View,在使用Toast.makeText方法创建的Toast中,nNextView是一个包含TextView的LinearLayout。
/**
* Make a standard toast to display using the specified looper.
* If looper is null, Looper.myLooper() is used.
* @hide
*/
public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
Toast result = new Toast(context, looper);
LayoutInflater inflate = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);
result.mNextView = v;
result.mDuration = duration;
return result;
}
在show()中调用了NMS.enqueueToast(),相当于将Toast都集合到NMS中进行调度。当NMS调度到当次Toast时,则通过TN的Binder代理调用TN.show()方法。其中参数IBinder是一个窗口Token,用于添加窗口的鉴权。
/**
* schedule handleShow into the right thread
*/
@Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}
上述通过mHandler调度最终执行到TN.handleShow()方法,通过WMS.addView将mNextView添加到新的窗口中,并设置该窗口属性,达到Toast显示的目的。
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
mParams.packageName = packageName;
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
// Since the notification manager service cancels the token right
// after it notifies us to cancel the toast there is an inherent
// race and we may attempt to add a window after the token has been
// invalidated. Let us hedge against that.
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
Toast时长到达后,由NMS调度并调用TN.hide()
/**
* schedule handleHide into the right thread
*/
@Override
public void hide() {
if (localLOGV) Log.v(TAG, "HIDE: " + this);
mHandler.obtainMessage(HIDE).sendToTarget();
}
最终也是通过WMS.removeView()达到Toast消失的目的。
public void handleHide() {
if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
if (mView != null) {
// note: checking parent() just to make sure the view has
// been added... i have seen cases where we get here when
// the view isn't yet added, so let's try not to crash.
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeViewImmediate(mView);
}
// Now that we've removed the view it's safe for the server to release
// the resources.
try {
getService().finishToken(mPackageName, this);
} catch (RemoteException e) {
}
mView = null;
}
}
我们再看Toast与一般悬浮窗窗口的区别:Toast的窗口类型是TYPE_TOAST,其他使用悬浮窗使用的时TYPE_PHONE/TYPE_SYSTEM_ALTER。在系统侧WMS中针对不同的窗口类型有不同的限制:由源码中可以看到,在处理TYPE_TOAST类型的窗口时直接跳过了悬浮窗权限检查(在Android O及以上则需要一个有NMS分配的token),而TYPE_PHONE/TYPE_SYSYTEM_ALTER则需要权限检查。
@Override
public int checkAddPermission(WindowManager.LayoutParams attrs, int[] outAppOp) {
final int type = attrs.type;
final boolean isRoundedCornerOverlay =
(attrs.privateFlags & PRIVATE_FLAG_IS_ROUNDED_CORNERS_OVERLAY) != 0;
if (isRoundedCornerOverlay && mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
!= PERMISSION_GRANTED) {
return ADD_PERMISSION_DENIED;
}
outAppOp[0] = AppOpsManager.OP_NONE;
if (!((type >= FIRST_APPLICATION_WINDOW && type <= LAST_APPLICATION_WINDOW)
|| (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW)
|| (type >= FIRST_SYSTEM_WINDOW && type <= LAST_SYSTEM_WINDOW))) {
return WindowManagerGlobal.ADD_INVALID_TYPE;
}
if (type < FIRST_SYSTEM_WINDOW || type > LAST_SYSTEM_WINDOW) {
// Window manager will make sure these are okay.
return ADD_OKAY;
}
if (!isSystemAlertWindowType(type)) {
switch (type) {
case TYPE_TOAST:
// Only apps that target older than O SDK can add window without a token, after
// that we require a token so apps cannot add toasts directly as the token is
// added by the notification system.
// Window manager does the checking for this.
outAppOp[0] = OP_TOAST_WINDOW;
return ADD_OKAY;
case TYPE_DREAM:
case TYPE_INPUT_METHOD:
case TYPE_WALLPAPER:
case TYPE_PRESENTATION:
case TYPE_PRIVATE_PRESENTATION:
case TYPE_VOICE_INTERACTION:
case TYPE_ACCESSIBILITY_OVERLAY:
case TYPE_QS_DIALOG:
// The window manager will check these.
return ADD_OKAY;
}
return mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
== PERMISSION_GRANTED ? ADD_OKAY : ADD_PERMISSION_DENIED;
}
// Things get a little more interesting for alert windows...
outAppOp[0] = OP_SYSTEM_ALERT_WINDOW;
final int callingUid = Binder.getCallingUid();
// system processes will be automatically granted privilege to draw
if (UserHandle.getAppId(callingUid) == Process.SYSTEM_UID) {
return ADD_OKAY;
}
ApplicationInfo appInfo;
try {
appInfo = mContext.getPackageManager().getApplicationInfoAsUser(
attrs.packageName,
0 /* flags */,
UserHandle.getUserId(callingUid));
} catch (PackageManager.NameNotFoundException e) {
appInfo = null;
}
if (appInfo == null || (type != TYPE_APPLICATION_OVERLAY && appInfo.targetSdkVersion >= O)) {
/**
* Apps targeting >= {@link Build.VERSION_CODES#O} are required to hold
* {@link android.Manifest.permission#INTERNAL_SYSTEM_WINDOW} (system signature apps)
* permission to add alert windows that aren't
* {@link android.view.WindowManager.LayoutParams#TYPE_APPLICATION_OVERLAY}.
*/
return (mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
== PERMISSION_GRANTED) ? ADD_OKAY : ADD_PERMISSION_DENIED;
}
// check if user has enabled this operation. SecurityException will be thrown if this app
// has not been allowed by the user
final int mode = mAppOpsManager.noteOpNoThrow(outAppOp[0], callingUid, attrs.packageName);
switch (mode) {
case AppOpsManager.MODE_ALLOWED:
case AppOpsManager.MODE_IGNORED:
// although we return ADD_OKAY for MODE_IGNORED, the added window will
// actually be hidden in WindowManagerService
return ADD_OKAY;
case AppOpsManager.MODE_ERRORED:
// Don't crash legacy apps
if (appInfo.targetSdkVersion < M) {
return ADD_OKAY;
}
return ADD_PERMISSION_DENIED;
default:
// in the default mode, we will make a decision here based on
// checkCallingPermission()
return (mContext.checkCallingOrSelfPermission(SYSTEM_ALERT_WINDOW)
== PERMISSION_GRANTED) ? ADD_OKAY : ADD_PERMISSION_DENIED;
}
}
从上述的观察中可以发现,Toast机制是由NMS和WMS共同完成。但NMS和WMS两者间除了一个token外其实关联性并不强,从调度到最终展示需要App作为桥梁来完成,这给开发者提供了介入空间。虽然Toast的整个过程被封装在SDK中且公开接口很少,但还是发现了可切入的点:在TN.handleShow()方法中,有一段获取WindowManager的代码
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
1、如果可以替换WindowManager,替换成自己实现的WindowManager,则可以在后续addView()时获取到token,并且修改Window属性和替换View;在removeView()时延迟执行,达到长时间显示效果;
2、想要替换WindowManager,首先需要替换context;
3、想要替换context,首先需要对mNextView的context进行替换.。
刚好SDK API的支持第三点,使得整个过程可以实现。
1、需要实现一个WindowManager的Proxy,功能还是由系统的WindowManager提供,但在调用API的时候可以进行拦截、替换、监控。
public class FakeWindowService implements WindowManager {
private WindowManager mWindowManager;
public FakeWindowService(WindowManager windowManager) {
mWindowManager = windowManager;
}
@Override
public Display getDefaultDisplay() {
return mWindowManager.getDefaultDisplay();
}
@Override
public void removeViewImmediate(View view) {
Log.i(TAG, "removeViewImmediate");
// 进行拦截、监控、
mWindowManager.removeViewImmediate(view);
}
@Override
public void addView(View view, ViewGroup.LayoutParams params) {
Log.i(TAG, "addView");
// 替换View,修改params的属性
// WindowManager.LayoutParams lp1 = (WindowManager.LayoutParams) params;
// 加上可点击
// lp1.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
// 改变位置
// lp1.gravity = Gravity.TOP;
// 改变窗口高度
// lp1.height = xx
mWindowManager.addView(view, params);
}
@Override
public void updateViewLayout(View view, ViewGroup.LayoutParams params) {
mWindowManager.updateViewLayout(view, params);
}
@Override
public void removeView(View view) {
Log.i(TAG, "removeView");
// 进行拦截、监控、
mWindowManager.removeView(view);
}
}
2、构造Context,并重写getSystemService(),在获取WINDOW_SERVICE时返回上述WindowManager。
public class FakeContext extends ContextWrapper {
private static final String TAG = "FakeContext";
public FakeContext(Context base) {
super(base);
}
@Override
public Context getApplicationContext() {
Log.i(TAG, "getApplicationContext");
return this;
}
@Override
public Object getSystemService(String name) {
Log.i(TAG, "getSystemService, name=" + name);
if (TextUtils.equals(name, Context.WINDOW_SERVICE)) {
WindowManager windowManager = (WindowManager) getBaseContext().getSystemService(name);
return new FakeWindowService(windowManager);
}
return super.getSystemService(name);
}
}
3、使用FakeContext创建一个View,并以该View发起一个Toast展示。
public static void tryUseToast(final Context context) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
FakeContext fakeContext = new FakeContext(context.getApplicationContext());
ToastLayout toastLayout = new ToastLayout(fakeContext);
Toast toast = new Toast(fakeContext);
toast.setView(toastLayout);
toast.setDuration(Toast.LENGTH_LONG);
toast.show();
}
});
}
1、Android4.4以下WMS针对TYPE_TOAST类型窗口会去掉焦点和触摸属性,只能作显示用;
2、Android7.1及以上,不能同时存在两个TYPE_TOAST窗口,且每个窗口只能最长停留3.5秒,超时后即使在代码拦截removeView()操作,窗口也会变成不可见。
1、由于Toast机制依赖于NMS,部分机型需要开启通知栏权限才能使用Toast;
2、部分机型在App界面外无法使用Toast,如:OPPO;
3、部分机型在App界面外Toast流程正常执行,但Toast窗口不会显示,如:Vivo;
4、之前也有直接使用TYPE_TOAST进行addView()添加窗口的做法,但Android8.0之后TYPE_TOAS窗口需要NMS发放的一个token,所以不能直接添加窗口。本方案可以覆盖Android8.0机型上。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。