一、逆向
就「悬浮窗适配」这一具体的业务需求而言,需要有一定的逆向能力,为什么呢?
- 国内的 rom 对原生系统进行了部分修改,适配有时就需要了解到具体的技术细节,但是他们没有开源,所以需要逆向;
- 观察某些大厂应用,借鉴他们某些场景下的函数参数,因为作为大厂,他们的方案稳定性在一定程度上是值得学习的。
1.1 反编译 APK
下面举个例子,我看到 虎牙直播 加入了悬浮窗功能。该功能的开启入口是:
虎牙直播版本号:4.11.1
为了了解虎牙直播是怎么适配悬浮窗的。首先拖出虎牙的 apk 文件:
// 如果不知道应用包名,参考 【1.2】中的 adb shell dumpsys activity
adb shell pm path "com.duowan.kiwi"
adb pull /data/app/com.duowan.kiwi-1/base.apk ~/Desktop
接下来要用到的工具:
为了能一步转换到位,就不用手动解压 apk 了,使用 dex2-jar 直接转换 apk。(dex2-jar 支持多 dex 转 jar)
d2j-dex2jar.sh -f your.apk
然后使用 jd-gui 打开 jar 文件。
注意: dex2-jar 转换的 class 部分地方可能存在问题。我采取的策略是 使用 dex2jar + JD-GUI 看 class 代码,可以在 class 之间来回跳转,十分方便;使用 jadx + IDEA 看 java 代码。
1.2 突破口
虽然反编译了 apk ,但是由于混淆,我们暂时无法获取有效信息。我目前知道的大概有两个突破点:
- 字符串资源,案例参考 Android无需权限显示悬浮窗, 兼谈逆向分析app
- Activity 入口
个人认为有时候 Activity 来得更快。借助 adb shell dumpsys activity
获取手机的 Activity Stack 信息:
Display #0 (activities from top to bottom):
Stack #96:
Task id #341
TaskRecord{8df0de7 #341 A=com.duowan.kiwi U=0 sz=2}
Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10200000 cmp=com.duowan.kiwi/.simpleactivity.SplashActivity bnds=[816,666][996,846] (has extras) }
Hist #1: ActivityRecord{f378699 u0 com.duowan.kiwi/.simpleactivity.mytab.Setting t341}
Intent { cmp=com.duowan.kiwi/.simpleactivity.mytab.Setting }
ProcessRecord{8c2bc0b 1966:com.duowan.kiwi/u0a142}
Hist #0: ActivityRecord{f75e522 u0 com.duowan.kiwi/.homepage.Homepage t341}
Intent { flg=0x10000000 cmp=com.duowan.kiwi/.homepage.Homepage bnds=[816,1095][996,1275] (has extras) }
ProcessRecord{8c2bc0b 1966:com.duowan.kiwi/u0a142}
定位到 com.duowan.kiwi/.simpleactivity.mytab.Setting
:
Setting.class:
private FloatingShowOtherAppSwitch mShowOtherAppSwitch;
成员变量映入眼帘,发现了最具有价值的家伙:FloatingShowOtherAppSwitch.class
它应该是上图的 switch 控件。
FloatingShowOtherAppSwitch.class
public class FloatingShowOtherAppSwitch
extends BaseSettingFloatingSwitch
{
private Context mContext;
public FloatingShowOtherAppSwitch(Context paramContext)
{
super(paramContext);
a(paramContext);
}
public FloatingShowOtherAppSwitch(Context paramContext, AttributeSet paramAttributeSet)
{
super(paramContext, paramAttributeSet);
a(paramContext);
}
public FloatingShowOtherAppSwitch(Context paramContext, AttributeSet paramAttributeSet, int paramInt)
{
super(paramContext, paramAttributeSet, paramInt);
a(paramContext);
}
private void a(Context paramContext)
{
this.mContext = paramContext;
if (this.mFloatingSwitch != null)
{
this.mFloatingSwitch.setChecked(ajt.j());
this.mFloatingSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener()
{
public void onCheckedChanged(CompoundButton paramAnonymousCompoundButton, boolean paramAnonymousBoolean)
{
ajt.d(paramAnonymousBoolean);
if ((paramAnonymousBoolean) && ((FloatingShowOtherAppSwitch.a(FloatingShowOtherAppSwitch.this) instanceof Activity)))
{
paramAnonymousCompoundButton = FloatingVideoMgr.a();
// 检测权限,见【二】
if (!paramAnonymousCompoundButton.l()) {
// 申请权限,见【三】
paramAnonymousCompoundButton.a((Activity)FloatingShowOtherAppSwitch.a(FloatingShowOtherAppSwitch.this), -1);
}
}
if (paramAnonymousBoolean) {}
for (int i = 1;; i = 0)
{
Report.a("Click/MiniLive/OtherAppSetting", String.valueOf(i));
return;
}
}
});
}
}
}
典型的自定义 View 写法,可以肯定 private void a(Context paramContext)
类似 init()
。switch 控件状态变化后的 Action 依赖 FloatingVideoMgr
,从功能性可以预知 switch 控件状态发生变化后:
- 如果开启「允许在其他应用上显示」,则需要判断此时应用是否有这个权限,若没有则引导用户开启悬浮窗权限
- 如果关闭「允许在其他应用上显示」,则无需判断权限。
FloatingVideoMgr.class
public static FloatingVideoMgr a()
{
// 最简单的单例写法不用多说
if (c == null) {
c = new FloatingVideoMgr();
}
return c;
}
paramAnonymousCompoundButton = FloatingVideoMgr.a() ,猜测应该是反编译工具自身的 bug 。
到这里开始进入「悬浮窗适配」的核心环节,分为两步:检测权限、申请权限。
二、检测权限
paramAnonymousCompoundButton.l()
用来检测应用是否有悬浮窗权限。
FloatingVideoMgr.class
public boolean l()
{
final ajn localajn = ajn.a();
try
{
this.m = localajn.a(BaseApp.gContext);
return this.m;
}
catch (Exception localException)
{
for (;;)
{
Helper.a(new Runnable()
{
public void run()
{
localajn.b();
}
});
L.error(b, localException);
}
}
}
ajn.class
private final String c = "MobileCompatManager";
public static ajn a()
{
if (e == null) {
e = new ajn();
}
return e;
}
ajn.class
实际上是 MobileCompatManager.class
再看localajn.a(BaseApp.gContext)
ajn.class
public boolean a(Context paramContext)
throws Exception
{
String str = ajo.a();
if (str == null) {
throw new Exception("call checkPermission after mobileRomType is not null");
}
boolean bool;
if ("flyme".equals(str)) {
bool = d(paramContext);
}
for (;;)
{
return bool;
if (Build.VERSION.SDK_INT < 23)
{
if ("xiaomi".equals(str))
{
bool = c(paramContext);
continue;
}
if ("huawei".equals(str))
{
bool = b(paramContext);
continue;
}
if ("qiku".equals(str))
{
bool = e(paramContext);
continue;
}
}
bool = f(paramContext);
}
}
for 循环实在是让人无法理解作者的意图了,只好查看相应的 java 文件:ajn.java
:
ajn.java
public boolean a(Context context) throws Exception {
String a = ajo.a(); // 获取 rom 类型
if (a == null) {
throw new Exception("call checkPermission after mobileRomType is not null");
} else if (MobileRomInfo.a.equals(a)) {
// MobileRomInfo.a = "flyme";
// 检测魅族权限,见【2.1】
return d(context);
} else {
if (VERSION.SDK_INT < 23) {
// MobileRomInfo.e = "xiaomi";
if (MobileRomInfo.e.equals(a)) {
// 检测小米权限 见【2.2】
return c(context);
}
// MobileRomInfo.b = "huawei";
if (MobileRomInfo.b.equals(a)) {
// 检测华为权限 见【2.3】
return b(context);
}
// MobileRomInfo.k = "qiku";
if (MobileRomInfo.k.equals(a)) {
// 检测 360 权限,见【2.4】
return e(context);
}
}
return f(context);
}
}
在 SDK 23 也就是 Android 6.0 之后,检测权限统一进行了处理。但是魅族 rom 似乎比较特殊,魅族 6.0 之后仍然是单独处理。
2.1 魅族检测权限
ajn.java
private boolean d(Context context) {
return ajl.a(context);
}
调用了 ajl
ajl.java
public static boolean a(Context context) {
if (VERSION.SDK_INT >= 19) {
return a(context, 24);
}
return true;
}
对 魅族 rom 而言
- SDk 版本小于 19 ,默认开启悬浮窗权限
- SDK 版本大于或者等于 19 单独检测悬浮窗权限
检测权限方法:
@TargetApi(19)
private static boolean a(Context context, int i) {
if (VERSION.SDK_INT >= 19) {
try {
return ((Integer) AppOpsManager.class.getDeclaredMethod("checkOp", new Class[]{Integer.TYPE, Integer.TYPE, String.class}).invoke((AppOpsManager) context.getSystemService("appops"), new Object[]{Integer.valueOf(i), Integer.valueOf(Binder.getCallingUid()), context.getPackageName()})).intValue() == 0;
} catch (Throwable e) {
L.error(a, e);
}
} else {
L.debug(a, "Below API 19 cannot invoke!");
return false;
}
}
因为 方法 checkOp
是 hide,无法直接使用,所以只能使用反射。方法原型:
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();
}
}
传入的第一个参数 op = 24
,在 AppOpsManager 中 :public static final int OP_SYSTEM_ALERT_WINDOW = 24;
2.2 小米、华为、360检测权限 ( SDK < 23)
对 小米、华为、360 rom 而言
- SDk 版本小于 19 ,默认开启悬浮窗权限
- SDK 版本大于或者等于 19 ,小于 23,单独检测权限,具体检测方法和 【2.1】一样。
2.3 其他机型检测权限
其他机型指的是:除去魅族,小米(sdk <23),华为(sdk < 23),360 (sdk < 23)。
private boolean f(Context context) {
Boolean bool;
Boolean valueOf = Boolean.valueOf(true);
if (VERSION.SDK_INT >= 23) {
try {
bool = (Boolean) Settings.class.getDeclaredMethod("canDrawOverlays", new Class[]{Context.class}).invoke(null, new Object[]{context});
} catch (Throwable e) {
L.error((Object) "MobileCompatManager", e);
}
return bool.booleanValue();
}
bool = valueOf;
return bool.booleanValue();
}
常规的检测:
- 如果 SDK 大于或者等于 23,通过
Settings.canDrawOverlays
检测是否有相应权限,虎牙直播此处使用反射,但实际上可以直接调用。方法原型:public static boolean canDrawOverlays(Context context) { return Settings.isCallingPackageAllowedToDrawOverlays(context, Process.myUid(), context.getOpPackageName(), false); }
- 如果 SDK 小于 23 ,则直接返回true。这里似乎写的不太严谨?其他机型也可以通过
checkOp
来检测权限的。
三、申请权限
检测完权限后就是申请权限了,申请权限就是调系统的设置界面。如:
见【一】中分析,申请悬浮窗权限调用的是:
FloatingVideoMgr.class
public void a(Activity paramActivity, int paramInt)
{
try
{
ajn.a().a(paramActivity, paramInt);
return;
}
catch (Exception paramActivity)
{
for (;;)
{
L.error(b, paramActivity);
}
}
}
然后调用 ajn#a(paramActivity, paramInt)
:
ajn.java
"MobileCompatManager"
public void a(Activity activity, int i) throws Exception {
String a = ajo.a();// 获取 rom 信息
if (a == null) {
throw new Exception("call applyPermission after mobileRomType is not null");
} else if (MobileRomInfo.a.equals(a)) {
// 魅族申请权限
d(activity, i);
} else if (VERSION.SDK_INT >= 23) {
// Android 6.0 之后申请权限
f(activity, i);
} else if (MobileRomInfo.e.equals(a)) {
// 小米申请权限
e(activity, i);
} else if (MobileRomInfo.b.equals(a)) {
// 华为申请权限,
c(activity, i);
} else if (MobileRomInfo.k.equals(a)) {
// 360 申请权限
b(activity, i);
} else {
f(activity, i);
}
}
3.1 魅族申请权限
ajl.java
"MeizuUtils"
public static void a(Activity activity, int i) {
if (activity != null) {
try {
Intent intent = new Intent("com.meizu.safe.security.SHOW_APPSEC");
intent.setClassName("com.meizu.safe", "com.meizu.safe.security.AppSecActivity");
intent.putExtra("packageName", activity.getPackageName());
activity.startActivityForResult(intent, i);
} catch (Throwable e) {
ahw.b("进入设置页面失败,请手动设置");
L.error(b, e);
}
}
}
Intent 的构造是一个关键,不仅要明确隐式启动哪一个 Activity,还需要知道传入的参数是什么。这一部分可以参考 打开MIUI中的悬浮窗权限编辑界面分析过程. 思路是:
- 通过 adb shell dumps activity 获取 Activity 信息,例如魅族 是
com.meizu.safe.security.AppSecActivity
- 反编译各 rom 包含 Activity 的 apk
- 观察反编译文件,得到所需要传递的参数。例如魅族需要传递
packageName
` activity.startActivityForResult(intent, i);` 可以回传用户是否开启了悬浮窗权限。
3.2 SDk >= 23 申请权限
ajn.java
"MobileCompatManager"
private void f(Activity activity, int i) {
if (VERSION.SDK_INT >= 23) {
a(activity, i, new 5(this, activity, i));
}
}
5
是匿名内部类,这个时候再反过来看 class 文件
ajn.class
"MobileCompatManager"
private void f(final Activity paramActivity, final int paramInt)
{
if (Build.VERSION.SDK_INT >= 23) {
a(paramActivity, paramInt, new b()
{
public void a(boolean paramAnonymousBoolean)
{
if (paramAnonymousBoolean) {}
for (;;)
{
try
{
Object localObject = Settings.class.getDeclaredField("ACTION_MANAGE_OVERLAY_PERMISSION");
Intent localIntent = new android/content/Intent;
localIntent.<init>(((Field)localObject).get(null).toString());
localObject = new java/lang/StringBuilder;
((StringBuilder)localObject).<init>();
localIntent.setData(Uri.parse("package:" + paramActivity.getPackageName()));
paramActivity.startActivityForResult(localIntent, paramInt);
return;
}
catch (Exception localException)
{
L.error("MobileCompatManager", localException);
continue;
}
L.debug("MobileCompatManager", "user manually refuse OVERLAY_PERMISSION");
}
}
});
}
}
通过反射获取 Settings
中的 ACTION_MANAGE_OVERLAY_PERMISSION
字段
public static final String ACTION_MANAGE_OVERLAY_PERMISSION = "android.settings.action.MANAGE_OVERLAY_PERMISSION"; 。
然后给 Intent 传入 Uri.parse("package:" + context.getPackageName())
。
3.3 小米(SDK<23)申请权限
ajm.java
"MiuiUtils"
public static void a(Activity activity, int i) throws Exception {
String b = ajo.b(); // 获取 miui 版本号
if (b == null) {
throw new Exception("run applyMiuiPermission before romVersion is not null");
} else if ("5".equals(b)) {
// 处理 miui 5
b(activity, i);
} else if ("6".equals(b)) {
// 处理 miui 6
c(activity, i);
} else if ("7".equals(b)) {
// 处理 miui 7
d(activity, i);
} else if ("8".equals(b)) {
// 处理 miui 8
e(activity, i);
} else {
L.debug(b, "this is a special MIUI rom version, its version code " + b);
}
}
这里对每个 miui 版本都进行了处理。
这里只选择 miui 5 版本分析,其他版本同理:
ajm.java
"MiuiUtils"
public static void b(Activity activity, int i) {
if (activity == null) {
L.debug(b, "context is null");
return;
}
String packageName = activity.getPackageName();
Intent intent = new Intent("android.settings.APPLICATION_DETAILS_SETTINGS");
intent.setData(Uri.fromParts("package", packageName, null));
if (a(intent, (Context) activity)) {
try {
activity.startActivityForResult(intent, i);
return;
} catch (Throwable e) {
L.error(b, e);
return;
}
}
L.error(b, "intent is not available!");
}
有个细节 a(intent, (Context) activity)
:
ajm.java
"MiuiUtils"
private static boolean a(Intent intent, Context context) {
if (context == null) {
L.debug(b, "context is null");
return false;
}
try {
// 取出所有能响应次 Intent 的 Activity
if (context.getPackageManager().queryIntentActivities(intent, 65536).size() > 0) {
return true;
}
return false;
} catch (Throwable e) {
L.error(b, e);
return false;
}
}
3.4 华为(SDK < 23)申请权限
这一块因为反编译出的 class 文件和 java 文件均损坏,暂时无法分析。
3.5 360 (SDK < 23)申请权限
ajq.java
"QikuUtils"
public static void a(Activity activity, int i) {
Intent intent = new Intent();
intent.setClassName("com.android.settings", "com.android.settings.Settings$OverlaySettingsActivity");
if (a(intent, (Context) activity)) {
try {
activity.startActivityForResult(intent, i);
return;
} catch (Throwable e) {
L.error(b, e);
return;
}
}
L.debug(b, "can't open permission page with Settings$OverlaySettingsActivity, please use \"adb shell dumpsys activity\" command and tell me the name of the float window permission page");
}
四、总结
至此虎牙直播的悬浮窗适配分析完毕。实际上虎牙直播的适配方案和 Android 悬浮窗权限各机型各系统适配大全如出一辙,但有些细节略有不同。
我参考了两者的实现,梳理了其中的逻辑:FloatWindowPermission