Android实战系列 | 后台启动Activity实践之路

作者:苍耳叔叔
原文地址:https://juejin.cn/post/6907817072185737224

概述

前几天产品提了一个需求,想在后台的时候启动我们 APP 的一个 Activity,随着 Android 版本的更新,以及各家 ROM 厂商的无限改造,这种影响用户体验的功能许多都受到了限制,没办法,虽然是比较流氓的功能,但拿人钱财替人消灾,于是开启了哼哧哼哧的调研之路。

原生Android ROM

首先从 Android 的原生 ROM 开始,根据官方的介绍,后台启动 Activity 的限制是从 Android 10(API 29) 才开始的,在此之前原生 ROM 是没有这个限制的,于是我分别启动了一个 Android 9(API 28) 和 10(API 29) 版本的模拟器,发现在 API 28 上可以直接从后台启动 Activity,而在 API 29 上则受到了限制无法直接启动。参照官方 从后台启动 Activity 的限制 的说明,给出了一些不受限制的例外情况,此外官方的推荐是对于后台启动的需求,先向用户展示一个 Notification 而不是直接启动 Activity,然后在用户点击 Notification 后才处理对应的逻辑。还可以在设置 Notification 时通过 setFullScreenIntent 添加一个全屏 Intent 对象,该方法经过测试,可以在 Android 10 的模拟器上从后台启动一个 Activity 界面(需要 android.permission.USE_FULL_SCREEN_INTENT 权限)。代码如下:

object NotificationUtils {
    private const val ID = "channel_1"
    private const val NAME = "notification"

    private var manager: NotificationManager? = null

    private fun getNotificationManagerManager(context: Context): NotificationManager? {
        if (manager == null) {
            manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
        }
        return manager
    }

    fun sendNotificationFullScreen(context: Context, title: String?, content: String?) {
        if (Build.VERSION.SDK_INT >= 26) {
            clearAllNotification(context)
            val channel = NotificationChannel(ID, NAME, NotificationManager.IMPORTANCE_HIGH)
            channel.setSound(null, null)
            getNotificationManagerManager(context)?.createNotificationChannel(channel)
            val notification = getChannelNotificationQ(context, title, content)
            getNotificationManagerManager(context)?.notify(1, notification)
        }
    }

    private fun clearAllNotification(context: Context) {
        getNotificationManagerManager(context)?.cancelAll()
    }

    private fun getChannelNotificationQ(context: Context, title: String?, content: String?): Notification {
        val fullScreenPendingIntent = PendingIntent.getActivity(
            context,
            0,
            DemoActivity.genIntent(context),
            PendingIntent.FLAG_UPDATE_CURRENT
        )
        val notificationBuilder = NotificationCompat.Builder(context, ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle(title)
            .setContentText(content)
            .setSound(null)
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setCategory(Notification.CATEGORY_CALL)
            .setOngoing(true)
            .setFullScreenIntent(fullScreenPendingIntent, true)
        return notificationBuilder.build()
    }
}
复制代码

到现在,整体上感觉还是不错的,现阶段的 Android 原生 ROM 都能正常地从后台启动 Activity 界面,无论是 Android 9 还是 10 版本,都美滋滋。

定制化ROM

问题开始浮出水面,由于各大厂商对 Android 的定制化各有不一,而 Android 并没有继承 GPL 协议,它使用的是 Apache 开源许可协议,即第三方厂商在修改代码后可以闭源,因此也无法得知厂商 ROM 的源码到底做了哪些修改。有的机型增加了一项权限——后台弹出界面,比如说在 MIUI 上便新增了这项权限且默认是关闭的,除非加入了它们的白名单,小米开放平台的文档 里有说明:该权限默认为拒绝的,既为应用默认不允许在后台弹出页面,针对特殊应用会提供白名单,例如音乐(歌词显示)、运动、VOIP(来电)等;白名单应用一旦出现推广等恶意行为,将永久取消白名单。

检测后台弹出界面权限

在小米机型上,新增的这个 后台弹出界面 的权限是在 AppOpsService 里扩展了新的权限,查看 AppOpsManager 源代码,可以在里面看到许多熟悉的常量:

@SystemService(Context.APP_OPS_SERVICE)
public class AppOpsManager {
    public static final int OP_GPS = 2;
    public static final int OP_READ_CONTACTS = 4;
    // ...
}
复制代码

因此可以通过 AppOpsService 来检测是否具有 后台弹出界面 的权限,那么这个权限对应的 OpCode 是啥呢?网上有知情人士透露这个权限的 Code 是 10021,因此可以使用 AppOpsManager.checkOpNoThrow 或 AppOpsManager.noteOpNoThrow 等系列的方法检测该权限是否存在,不过这些方法都是 @hide 标识的,需要使用反射:

fun checkOpNoThrow(context: Context, op: Int): Boolean {
    val ops = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
    try {
        val method: Method = ops.javaClass.getMethod(
            "checkOpNoThrow", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, String::class.java
        )
        val result = method.invoke(ops, op, myUid(), context.packageName) as Int
        return result == AppOpsManager.MODE_ALLOWED
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return false
}

fun noteOpNoThrow(context: Context, op: Int): Int {
    val ops = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
    try {
        val method: Method = ops.javaClass.getMethod(
            "noteOpNoThrow", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, String::class.java
        )
        return method.invoke(ops, op, myUid(), context.packageName) as Int
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return -100
}
复制代码

另外如果想知道其它新增权限的 code, 可以通过上面的方法去遍历某个范围(如10000~10100)内的 code 的权限,然后手机操作去开关想要查询的权限,根据遍历的结果,就大致可以得到对应权限的 code 了。

Android P后台启动权限

在小米 Max3 上测试发现了两种方式可以实现从后台启动 Activity 界面,其系统是基于 Android 9 的 MIUI 系统。

方式一:moveTaskToFront

这种方式不算是直接从后台启动 Activity,而是换了一个思路,在后台启动目标 Activity 之前先将应用切换到前台,然后再启动目标 Activity,如果有必要的话,还可以通过 Activity.moveTaskToBack 方法将之前切换到前台的 Activity 重新移入后台,经过测试,在 Android 10 上这个方法已经失效了...但是 10 以下的版本还是可以抢救一下的(需要声明 android.permission.REORDER_TASKS 权限)。

启动目标 Activity 之前先判断一下应用是否在后台,判断方法可以借助 ActivityManager.getRunningAppProcesses 方法或者 Application.ActivityLifecycleCallbacks 来监听前后台,这两种方法网上都有文章讲解,就不赘述了。直接贴出后台切换到前台的代码:

fun moveToFront(context: Context) {
    val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
    activityManager?.getRunningTasks(100)?.forEach { taskInfo ->
        if (taskInfo.topActivity?.packageName == context.packageName) {
            Log.d("LLL", "Try to move to front")
            activityManager.moveTaskToFront(taskInfo.id, 0)
            return
        }
    }
}

fun startActivity(activity: Activity, intent: Intent) {
    if (!isRunningForeground(activity)) {
        Log.d("LLL", "Now is in background")
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            // TODO 防止 moveToFront 失败,可以多尝试调用几次
            moveToFront(activity)
            activity.startActivity(intent)
            activity.moveTaskToBack(true)
        } else {
            NotificationUtils.sendNotificationFullScreen(activity, "", "")
        }
    } else {
        Log.d("LLL", "Now is in foreground")
        activity.startActivity(intent)
    }
}
复制代码

方式二:Hook

由于 MIUI 系统不开源,因此尝试再研究研究 AOSP 源码,死马当活马医看能不能找到什么蛛丝马迹。首先从 Activity.startActivity 方法开始追,如果阅读过 Activity 启动源码流程的话可以知道 Activity.startActivity 或调用到 Instrumentation.execStartActivity 中,然后通过 Binder 调用到 AMS 相关的方法,权限认证就在 AMS 中完成,如果权限不满足自然启动就失败了(Android 10)。

// APP 进程
public ActivityResult execStartActivity(Context who, IBinder contextThread, ...) {
    // ...
    // 这里会通过 Binder 调用到 AMS 相关的代码
    int result = ActivityManager.getService().startActivity(whoThread, who.getBasePackageName(),
            intent, intent.resolveTypeIfNeeded(who.getContentResolver()),
            token, target != null ? target.mEmbeddedID : null, requestCode, 0, null, options);
    // ...
}

// system_server进程
// AMS
public final int startActivity(IApplicationThread caller, String callingPackage, Intent intent,
    String resolvedType, IBinder resultTo, String resultWho, int requestCode,
    int startFlags, ProfilerInfo profilerInfo, Bundle bOptions) {
    // ...
}
复制代码

这里如果对 system_server, zygote 进程这些感兴趣的话可以参考 Android系统启动源码解读-init-zygote-system_server。看一下这几个参数:

  • caller: AMS 在完成相关任务后会通过它来 Binder 调用到客户端 APP 进程来实例化 Activity 对象并回调其生命周期方法,caller 的 Binder 服务端位于 APP 进程。
  • callingPackage: 这个参数标识调用者包名。
  • ...

看上去在 APP 进程这一侧只有一个 callingPackage 参数在 Hook 后有突破的可能,碰碰运气,可以看到 callingPackage 是由 who.getBasePackageName() 赋值的,其实现在 ContextImpl 类中:

class ContextImpl extends Context {
    private final String mBasePackageName;

    /** @hide */
    @Override
    public String getBasePackageName() {
        return mBasePackageName != null ? mBasePackageName : getPackageName();
    }

    // ...
}
复制代码

参考 Android-Application启动流程 可以知道这个 ContextImpl 对象是作为 Application 中的 mBase 上下文传入的:

protected void attachBaseContext(Context base) {
    if (mBase != null) {
        throw new IllegalStateException("Base context already set");
    }
    mBase = base;
}
复制代码

因此可以尝试 Hook Application 中的 baseContext 将 mBasePackageName 改成白名单应用的包名:

fun startActivityMIUIBelowQ(baseContext: Context, intent: Intent): Boolean {
    val newPkgName = "com.android.contacts"
    val backPkgName = baseContext.packageName // 备份原包名
    try {
        Reflect.on(baseContext).set("mBasePackageName", newPkgName)  // 反射
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        baseContext.startActivity(intent) // 启动 Activity
        Reflect.on(baseContext).set("mBasePackageName", backPkgName) // 还原变量
        Log.d("LLL", "Hook完成")
        return true
    } catch (e: Throwable) {
        e.printStackTrace()
        Log.e("LLL", "Hook失败")
    }
    return false
}
复制代码

注意上面的 baseContext 参数需要传入 application.baseContext,然后使用上面的 startActivityMIUIBelowQ 方法在 Android 9 上的小米 Max3 上一跑,发现真的从后台启动起来了。。。就硬猜呗,运气不错。然后我将 newPkgName 参数随便改了个字符串,发现还是可以后台启动成功,这。。鬼知道 MIUI 到底改了啥。

虽然有碰运气的成分,但是好歹在 Android 10 下通过这个方法可以直接从后台启动 Activity 了(限于小米手机,且不保证所有小米手机都兼容,其它手机的兼容性没测过)。

Android Q后台启动权限

在上面介绍过 Android Q 版本开始原生系统也加入了后台启动的限制,通过通知设置 fullScreenIntent 可以在原生 Android 10 系统上从后台启动 Activity。查看 AOSP 源码,可以在 AMS 找到这部分后台权限限制的代码,上面讲到 startActivity 的流程,在 APP 进程发起请求后,会通过 Binder 跨进程调用到 system_server 进程中的 AMS,然后调用到 ActivityStarter.startActivity 方法,关于后台启动的限制就这这里:

// 好家伙,整整二十多个参数,嘿嘿,嘿嘿
private int startActivity(IApplicationThread caller, Intent intent, Intent ephemeralIntent,
        String resolvedType, ActivityInfo aInfo, ResolveInfo rInfo,
        IVoiceInteractionSession voiceSession, IVoiceInteractor voiceInteractor,
        IBinder resultTo, String resultWho, int requestCode, int callingPid, int callingUid,
        String callingPackage, int realCallingPid, int realCallingUid, int startFlags,
        SafeActivityOptions options,
        boolean ignoreTargetSecurity, boolean componentSpecified, ActivityRecord[] outActivity,
        TaskRecord inTask, boolean allowPendingRemoteAnimationRegistryLookup,
        PendingIntentRecord originatingPendingIntent, boolean allowBackgroundActivityStart) {
    // ...
    boolean abort = !mSupervisor.checkStartAnyActivityPermission(intent, aInfo, resultWho,
        requestCode, callingPid, callingUid, callingPackage, ignoreTargetSecurity,
        inTask != null, callerApp, resultRecord, resultStack);
    abort |= !mService.mIntentFirewall.checkStartActivity(intent, callingUid,
            callingPid, resolvedType, aInfo.applicationInfo);
    abort |= !mService.getPermissionPolicyInternal().checkStartActivity(intent, callingUid,
            callingPackage);

    boolean restrictedBgActivity = false;
    if (!abort) {
        restrictedBgActivity = shouldAbortBackgroundActivityStart(callingUid,
                callingPid, callingPackage, realCallingUid, realCallingPid, callerApp,
                originatingPendingIntent, allowBackgroundActivityStart, intent);
    }
    // ...
}
复制代码

这里的 shouldAbortBackgroundActivityStart 调用是在 Android Q 中新增的,看方法名就能菜刀这是针对后台启动的:

boolean shouldAbortBackgroundActivityStart(...) {
    final int callingAppId = UserHandle.getAppId(callingUid);
    if (callingUid == Process.ROOT_UID || callingAppId == Process.SYSTEM_UID
            || callingAppId == Process.NFC_UID) {
        return false;
    }
    if (callingUidHasAnyVisibleWindow || isCallingUidPersistentSystemProcess) {
        return false;
    }
    // don't abort if the callingUid has START_ACTIVITIES_FROM_BACKGROUND permission
    if (mService.checkPermission(START_ACTIVITIES_FROM_BACKGROUND, callingPid, callingUid)
            == PERMISSION_GRANTED) {
        return false;
    }
    // don't abort if the caller has the same uid as the recents component
    if (mSupervisor.mRecentTasks.isCallerRecents(callingUid)) {
        return false;
    }
    // don't abort if the callingUid is the device owner
    if (mService.isDeviceOwner(callingUid)) {
        return false;
    }
    // don't abort if the callingUid has SYSTEM_ALERT_WINDOW permission
    if (mService.hasSystemAlertWindowPermission(callingUid, callingPid, callingPackage)) {
        Slog.w(TAG, "Background activity start for " + callingPackage
                + " allowed because SYSTEM_ALERT_WINDOW permission is granted.");
        return false;
    }
    // ...
}
复制代码

从这个方法可以看到后台启动的限制和官方文档 从后台启动 Activity 的限制 中的说明是可以对应上的,这里面都是针对 uid 去做权限判断的,且是在系统进程 system_server 中完成,单纯更改包名已经没用了。。。

在一些没有针对后台启动单独做限制的 ROM 上通过 全屏通知 可以成功弹出后台 Activity 页面,比如说小米 A3,另外还有一台 vivo 和一台三星手机,具体机型忘记了;在做了限制的设备上则弹不出来,比如说红米 Note 8 Pro。

对于红米 Note 8 Pro 这块硬骨头,不停尝试了好多方法,但其实都是碰运气的,因为拿不到 MIUI 的源码,后来想转变思路,是否可以尝试从这台手机上 pull 出相关的 framework.jar 包然后反编译呢?说不定就有收获!不过需要 Root 手机,这个好办,小米自己是有提供可以 Root 的开发版系统的,于是就去 MIUI 官网找了一下,发现这台红米 Note 8 Pro 机型没有提供开发版系统(笑哭),想起来好像之前是说过低端机小米不再提供开发版了。。。好吧,手里头没有其它可以尝试的手机了。

再转念一想,是否可以直接下载稳定版的 ROM 包,解压后有没有工具能够得到一些源码相关的痕迹呢?于是下载了一个 ROM.zip 后,解压看到里面只有一些系统映像 img 文件和 .dat.br 文件,这一块我还不太懂,猜想就算能得到我想要的东西,整套流程花费的时间成本估计也超出预期了,所以暂时只能先放下这个想法了。后续有足够的时间再深入研究研究吧。

总结

原生Android ROM

Android 原生 ROM 都能正常地从后台启动 Activity 界面,无论是 Android 9(直接启动) 还是 10 版本(借助全屏通知)。

定制化ROM

检测后台弹出界面权限:

  • 通过反射 AppOpsManager 相关方法检测对应 opCode 的权限;
  • opCode = 10021(小米机型);
  • 其它机型可以尝试遍历得到 opCode;

Android P版本的小米:

  • 通过反射修改 mBasePackageName 参数来后台启动Activity;
  • 只测试过小米机型,其它机型不一定可用;
  • 理论上 P 版本以下的小米应该也支持;

Android P版本的机型:

  • 通过 moveTaskToFront 方法将应用切换到前台;
  • 这种方法毕竟是官方 API,因此兼容性可能更好一些;
  • 如果切换失败的话可以多尝试几次调用 moveTaskToFront 方法;
  • 理论上 P 版本以下的机型应该也支持;

Android Q版本的机型:

  • 通过系统全屏通知的方式调起后台 Activity;
  • 在一些另作了限制的 ROM 上可能调起失败;

至于反编译 MIUI 代码的方式只是一个猜想,时间原因未能付诸行动。看样子产品哥哥的需求暂时不能完全实现了,不知道有没有做过相关研究(或者知道内情)的小伙伴能不能提供一些参考思路,虽然是一个比较流氓的功能,但是代码是无罪的嘿嘿,朝着一个需求目标,为此思考解决方法,并从各个方向去调研,我觉得本身是一件有意思也有提升的事情!欢迎有过相关研究的同学在评论区提出建议,做好需求奥里给。

文中内容如有错误欢迎指出,共同进步!觉得不错的留个赞再走哈~

推荐阅读更多精彩内容