悬浮窗适配

Posted by CoXier on August 19, 2017

参考:Android 悬浮窗权限各机型各系统适配大全

一、逆向

就「悬浮窗适配」这一具体的业务需求而言,需要有一定的逆向能力,为什么呢?

  • 国内的 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 ,但是由于混淆,我们暂时无法获取有效信息。我目前知道的大概有两个突破点:

个人认为有时候 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