现在有很多应用都有悬浮窗功能,直播类应用的小窗播放,安全类应用的加速球等等,其实现方式都是通过WindowManager.addView()
来添加的,最近公司也要求在产品中加入小窗功能,在此记录一下开发中遇到的问题。
为什么有些应用可以不请求悬浮窗权限就显示悬浮窗
这个问题在这两篇文章(Android无需权限显示悬浮窗, 兼谈逆向分析app,Android悬浮窗TYPE_TOAST小结: 源码分析)中已经做了很好的解释。
简单来说就是设置WindowManager.LayoutParams.type = TYPE_TOAST
即可绕过权限,因为在view添加之前系统执行了一个检查权限的操作PhoneWindowManager.checkAddPermission()
,虽然经历了很多Android版本,但是我们关心的那部分一直没有什么大变化,就是当type == TYPE_TOAST
的时候switch语句直接break了,从而跳过了接下来的权限检查。
源码版本Android 7.0
public int checkAddPermission(WindowManager.LayoutParams attrs, int[] outAppOp) {
··· ···
String permission = null;
switch (type) {
//TYPE_TOAST作为高于ApplicationWindow的类型,却跳过了权限检查
case TYPE_TOAST:
// XXX right now the app process has complete control over
// this... should introduce a token to let the system
// monitor/control what they are doing.
outAppOp[0] = AppOpsManager.OP_TOAST_WINDOW;
break;
case TYPE_DREAM:
case TYPE_INPUT_METHOD:
case TYPE_WALLPAPER:
case TYPE_PRIVATE_PRESENTATION:
case TYPE_VOICE_INTERACTION:
case TYPE_ACCESSIBILITY_OVERLAY:
case TYPE_QS_DIALOG:
// The window manager will check these.
break;
case TYPE_PHONE:
case TYPE_PRIORITY_PHONE:
case TYPE_SYSTEM_ALERT:
case TYPE_SYSTEM_ERROR:
case TYPE_SYSTEM_OVERLAY:
permission = android.Manifest.permission.SYSTEM_ALERT_WINDOW;
outAppOp[0] = AppOpsManager.OP_SYSTEM_ALERT_WINDOW;
break;
default:
permission = android.Manifest.permission.INTERNAL_SYSTEM_WINDOW;
}
if (permission != null) {
if (android.Manifest.permission.SYSTEM_ALERT_WINDOW.equals(permission)) {
··· ···
//在这里使用了AppOpsManager去检查权限
final int mode = mAppOpsManager.checkOpNoThrow(outAppOp[0], callingUid,
attrs.packageName);
··· ···
}
··· ···
}
return WindowManagerGlobal.ADD_OKAY;
}
这里需要注意的一点是TYPE_TOAST在最新的Android 7.1.1上已经被Google制裁了,只允许添加一个,并且在API 25之后会直接崩溃,具体代码可以查看这里,看一下WindowManager
的diff就知道了,不过6.0以上Google已经提供了通用方法来开启悬浮窗权限,下文会提到,推荐大家去引导用户开启,不要使用暴力的解决方式。
Android 8.0对于悬浮窗又有所修改,窗口类型改为TYPE_APPLICATION_OVERLAY
即可。
提醒窗口
使用
[SYSTEM_ALERT_WINDOW]
权限的应用无法再使用以下窗口类型来在其他应用和系统窗口上方显示提醒窗口:
[TYPE_PHONE]
[TYPE_PRIORITY_PHONE]
[TYPE_SYSTEM_ALERT]
[TYPE_SYSTEM_OVERLAY]
[TYPE_SYSTEM_ERROR]
相反,应用必须使用名为[TYPE_APPLICATION_OVERLAY]
的新窗口类型。
使用[TYPE_APPLICATION_OVERLAY]
窗口类型显示应用的提醒窗口时,请记住新窗口类型的以下特性:- 应用的提醒窗口始终显示在状态栏和输入法等关键系统窗口的下面。
- 系统可以移动使用
[TYPE_APPLICATION_OVERLAY]
窗口类型的窗口或调整其大小,以改善屏幕显示效果。- 通过打开通知栏,用户可以访问设置来阻止应用显示使用
[TYPE_APPLICATION_OVERLAY]
窗口类型显示的提醒窗口。
悬浮窗权限检查
具体代码见GitHub
在Android 6.0以上,系统提供了API来检查悬浮窗权限,那么在小于6.0的机器上该怎么检查权限呢?其实,如果你看过了WindowManager添加view的源码,系统已经告诉你答案了,在PhoneWindowManager.checkAddPermission()
中,系统使用了一个叫AppOpsManager
的类,最终调用其中的checkOp()方法来检查权限,但是这个方法本身是隐藏的,所以只能通过反射的方式来调用,另外还需要注意AppOpsManager是API 19才添加的,对于低于这个版本的系统并不能用此方法来检查权限。
public static final int OP_SYSTEM_ALERT_WINDOW = 24;
public int checkOp(int op, int uid, String packageName) {
try {
int mode = mService.checkOperation(op, uid, packageName);
if (mode == MODE_ERRORED) {
throw new SecurityException(buildSecurityExceptionMsg(op, uid, packageName));
}
return mode;
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
经过试验,4.4以下的机器一般都可以直接添加悬浮窗,如果有特殊情况,只能单独适配了。
检查权限的代码如下所示:
public static boolean checkFloatWindowPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return Settings.canDrawOverlays(MainApplication.getInstance());
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
//AppOpsManager添加于API 19
return checkOps();
} else {
//4.4以下一般都可以直接添加悬浮窗
return true;
}
}
@RequiresApi(api = Build.VERSION_CODES.KITKAT)
private static boolean checkOps() {
try {
Object object = MainApplication.getInstance().getSystemService(Context.APP_OPS_SERVICE);
if (object == null) {
return false;
}
Class localClass = object.getClass();
Class[] arrayOfClass = new Class[3];
arrayOfClass[0] = Integer.TYPE;
arrayOfClass[1] = Integer.TYPE;
arrayOfClass[2] = String.class;
Method method = localClass.getMethod("checkOp", arrayOfClass);
if (method == null) {
return false;
}
Object[] arrayOfObject1 = new Object[3];
arrayOfObject1[0] = 24;
arrayOfObject1[1] = Binder.getCallingUid();
arrayOfObject1[2] = MainApplication.getInstance().getPackageName();
int m = (Integer) method.invoke(object, arrayOfObject1);
//4.4至6.0之间的非国产手机,例如samsung,sony一般都可以直接添加悬浮窗
return m == AppOpsManager.MODE_ALLOWED || !RomUtils.isDomesticSpecialRom();
} catch (Exception ignore) {
}
return false;
}
大家应该会注意到我在最后额外判断了一下!RomUtils.isDomesticSpecialRom()
是否为国产Rom,因为有些三星,索尼之类的手机检查结果为MODE_IGNORED
,系统本身又没有设置悬浮窗权限的页面,只会在安装应用的时候询问用户是否允许悬浮窗,如果用户禁用,除非卸载重装就再也没有开启的方法了。
因此对于这类非国产Rom手机,我统一使用了TYPE_TOAST的方法来强制开启悬浮窗以保证功能的正常使用。
此外,在一些手机上,比如oppo,代码返回有悬浮窗权限,但是实际使用过程中APP切到后台悬浮窗就消失(与TYPE_APPLICATION行为一致),这种必须要在手机自带的管家中授权悬浮窗才行,可以判断Rom版本提示用户自行开启。
悬浮窗权限设置引导
使用黑科技的方式绕过悬浮窗权限的检查是很多App的常用做法,但是我本身不太喜欢这种方式,我更倾向于引导用户自行决定是否开启悬浮窗权限,但是各个厂商的悬浮窗设置页面不尽相同,那么该怎么跳转到这个页面呢?
我的做法是手动找到开启悬浮窗的界面,然后执行adb shell dumpsys actvity activities
,就可以看到授权界面的包名和Activity名称,接下来在应用中构造Intent跳转即可。
跳转悬浮窗设置页面在6.0以后也有了通用方法,这边有一个坑是魅族6.0以上跳转这个页面会自动退出,还是需要跳转魅族自己的权限设置页面,跟6.0之前一样,出了魅族以外其他机型目前都可以正常跳转。
private static void applyCommonPermission(Context context) {
try {
Class clazz = Settings.class;
Field field = clazz.getDeclaredField("ACTION_MANAGE_OVERLAY_PERMISSION");
Intent intent = new Intent(field.get(null).toString());
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setData(Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
} catch (Exception e) {
Toast.makeText(context, "进入设置页面失败,请手动设置", Toast.LENGTH_LONG).show();
}
}
至于Android 6.0之前的手机就要根据Rom一个一个进行适配了,因为有人问我这些Rom的判断规则是怎么来的,假如出了个新手机怎么判断,这里说一下判断Rom的思路,具体的代码就不贴了,自行去GitHub上看。
思路非常简单,我们知道Android系统里存放了一些配置文件,比如
init.rc
default.prop
/system/build.prop
这些文件里记录了很多系统属性,我们可以通过adb shell getprop
来读取这些信息,找到Rom厂商所特有的字段来作为判断依据,还是以小米手机为例,执行命令后可以看到:
这些跟Rom版本有关的字段就可以拿来作为我们的判断依据。
使用悬浮窗播放视频,切换至桌面时出现音画不同步现象
这个现象的出现与使用的播放器有一定关系,我们使用的是ijkplayer。当解码方式为ijk硬解时,在6.0系统上切至桌面就会出现音画不同步,系统硬解和软解时则没有出现这种情况,主要原因是切换至桌面时系统判断应用不在前台,对应用做了降级处理,资源分配上优先级很低。解决方法也很简单,只需要开启一个前台服务Service.startForeground()
即可防止被降级。