Android后臺(tái)啟動(dòng)Activity的實(shí)現(xiàn)示例
前幾天產(chǎn)品提了一個(gè)需求,想在后臺(tái)的時(shí)候啟動(dòng)我們 APP 的一個(gè) Activity,隨著 Android 版本的更新,以及各家 ROM 廠商的無(wú)限改造,這種影響用戶體驗(yàn)的功能許多都受到了限制,沒(méi)辦法,雖然是比較流氓的功能,但拿人錢(qián)財(cái)替人消災(zāi),于是開(kāi)啟了哼哧哼哧的調(diào)研之路。
原生Android ROM首先從 Android 的原生 ROM 開(kāi)始,根據(jù)官方的介紹,后臺(tái)啟動(dòng) Activity 的限制是從 Android 10(API 29) 才開(kāi)始的,在此之前原生 ROM 是沒(méi)有這個(gè)限制的,于是我分別啟動(dòng)了一個(gè) Android 9(API 28) 和 10(API 29) 版本的模擬器,發(fā)現(xiàn)在 API 28 上可以直接從后臺(tái)啟動(dòng) Activity,而在 API 29 上則受到了限制無(wú)法直接啟動(dòng)。參照官方 從后臺(tái)啟動(dòng) Activity 的限制 的說(shuō)明,給出了一些不受限制的例外情況,此外官方的推薦是對(duì)于后臺(tái)啟動(dòng)的需求,先向用戶展示一個(gè) Notification 而不是直接啟動(dòng) Activity,然后在用戶點(diǎn)擊 Notification 后才處理對(duì)應(yīng)的邏輯。還可以在設(shè)置 Notification 時(shí)通過(guò) setFullScreenIntent 添加一個(gè)全屏 Intent 對(duì)象,該方法經(jīng)過(guò)測(cè)試,可以在 Android 10 的模擬器上從后臺(tái)啟動(dòng)一個(gè) Activity 界面(需要 android.permission.USE_FULL_SCREEN_INTENT 權(quán)限)。代碼如下:
object NotificationUtils { private const val 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() }}
到現(xiàn)在,整體上感覺(jué)還是不錯(cuò)的,現(xiàn)階段的 Android 原生 ROM 都能正常地從后臺(tái)啟動(dòng) Activity 界面,無(wú)論是 Android 9 還是 10 版本,都美滋滋。
定制化ROM問(wèn)題開(kāi)始浮出水面,由于各大廠商對(duì) Android 的定制化各有不一,而 Android 并沒(méi)有繼承 GPL 協(xié)議,它使用的是 Apache 開(kāi)源許可協(xié)議,即第三方廠商在修改代碼后可以閉源,因此也無(wú)法得知廠商 ROM 的源碼到底做了哪些修改。有的機(jī)型增加了一項(xiàng)權(quán)限——后臺(tái)彈出界面,比如說(shuō)在 MIUI 上便新增了這項(xiàng)權(quán)限且默認(rèn)是關(guān)閉的,除非加入了它們的白名單,小米開(kāi)放平臺(tái)的文檔 里有說(shuō)明:該權(quán)限默認(rèn)為拒絕的,既為應(yīng)用默認(rèn)不允許在后臺(tái)彈出頁(yè)面,針對(duì)特殊應(yīng)用會(huì)提供白名單,例如音樂(lè)(歌詞顯示)、運(yùn)動(dòng)、VOIP(來(lái)電)等;白名單應(yīng)用一旦出現(xiàn)推廣等惡意行為,將永久取消白名單。
檢測(cè)后臺(tái)彈出界面權(quán)限在小米機(jī)型上,新增的這個(gè) 后臺(tái)彈出界面 的權(quán)限是在 AppOpsService 里擴(kuò)展了新的權(quán)限,查看 AppOpsManager 源代碼,可以在里面看到許多熟悉的常量:
@SystemService(Context.APP_OPS_SERVICE)public class AppOpsManager { public static final int OP_GPS = 2; public static final int OP_READ_CONTACTS = 4; // ...}
因此可以通過(guò) AppOpsService 來(lái)檢測(cè)是否具有 后臺(tái)彈出界面 的權(quán)限,那么這個(gè)權(quán)限對(duì)應(yīng)的 OpCode 是啥呢?網(wǎng)上有知情人士透露這個(gè)權(quán)限的 Code 是 10021,因此可以使用 AppOpsManager.checkOpNoThrow 或 AppOpsManager.noteOpNoThrow 等系列的方法檢測(cè)該權(quán)限是否存在,不過(guò)這些方法都是 @hide 標(biāo)識(shí)的,需要使用反射:
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 Intreturn 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}
另外如果想知道其它新增權(quán)限的 code, 可以通過(guò)上面的方法去遍歷某個(gè)范圍(如10000~10100)內(nèi)的 code 的權(quán)限,然后手機(jī)操作去開(kāi)關(guān)想要查詢(xún)的權(quán)限,根據(jù)遍歷的結(jié)果,就大致可以得到對(duì)應(yīng)權(quán)限的 code 了。
Android P后臺(tái)啟動(dòng)權(quán)限在小米 Max3 上測(cè)試發(fā)現(xiàn)了兩種方式可以實(shí)現(xiàn)從后臺(tái)啟動(dòng) Activity 界面,其系統(tǒng)是基于 Android 9 的 MIUI 系統(tǒng)。
方式一:moveTaskToFront
這種方式不算是直接從后臺(tái)啟動(dòng) Activity,而是換了一個(gè)思路,在后臺(tái)啟動(dòng)目標(biāo) Activity 之前先將應(yīng)用切換到前臺(tái),然后再啟動(dòng)目標(biāo) Activity,如果有必要的話,還可以通過(guò) Activity.moveTaskToBack 方法將之前切換到前臺(tái)的 Activity 重新移入后臺(tái),經(jīng)過(guò)測(cè)試,在 Android 10 上這個(gè)方法已經(jīng)失效了...但是 10 以下的版本還是可以搶救一下的(需要聲明 android.permission.REORDER_TASKS 權(quán)限)。
啟動(dòng)目標(biāo) Activity 之前先判斷一下應(yīng)用是否在后臺(tái),判斷方法可以借助 ActivityManager.getRunningAppProcesses 方法或者 Application.ActivityLifecycleCallbacks 來(lái)監(jiān)聽(tīng)前后臺(tái),這兩種方法網(wǎng)上都有文章講解,就不贅述了。直接貼出后臺(tái)切換到前臺(tái)的代碼:
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 失敗,可以多嘗試調(diào)用幾次 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 系統(tǒng)不開(kāi)源,因此嘗試再研究研究 AOSP 源碼,死馬當(dāng)活馬醫(yī)看能不能找到什么蛛絲馬跡。首先從 Activity.startActivity 方法開(kāi)始追,如果閱讀過(guò) Activity 啟動(dòng)源碼流程的話可以知道 Activity.startActivity 或調(diào)用到 Instrumentation.execStartActivity 中,然后通過(guò) Binder 調(diào)用到 AMS 相關(guān)的方法,權(quán)限認(rèn)證就在 AMS 中完成,如果權(quán)限不滿足自然啟動(dòng)就失敗了(Android 10)。
// APP 進(jìn)程public ActivityResult execStartActivity(Context who, IBinder contextThread, ...) { // ... // 這里會(huì)通過(guò) Binder 調(diào)用到 AMS 相關(guān)的代碼 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進(jìn)程// AMSpublic final int startActivity(IApplicationThread caller, String callingPackage, Intent intent, String resolvedType, IBinder resultTo, String resultWho, int requestCode, int startFlags, ProfilerInfo profilerInfo, Bundle bOptions) { // ...}
看一下這幾個(gè)參數(shù):
caller: AMS 在完成相關(guān)任務(wù)后會(huì)通過(guò)它來(lái) Binder 調(diào)用到客戶端 APP 進(jìn)程來(lái)實(shí)例化 Activity 對(duì)象并回調(diào)其生命周期方法,caller 的 Binder 服務(wù)端位于 APP 進(jìn)程。 callingPackage: 這個(gè)參數(shù)標(biāo)識(shí)調(diào)用者包名。 ...這里可以嘗試 Hook 一些系統(tǒng)的東西,具體怎么 Hook 的代碼先不給出了,經(jīng)過(guò)測(cè)試在 Android 9 的小米設(shè)備上可以成功,有興趣可以自行研究談?wù)摴瑫簳r(shí)不公開(kāi)了,有需要的同學(xué)可以留言告訴我。或者反編譯小米 ROM 源碼,可以從里面發(fā)現(xiàn)一些東西。
Android Q后臺(tái)啟動(dòng)權(quán)限在上面介紹過(guò) Android Q 版本開(kāi)始原生系統(tǒng)也加入了后臺(tái)啟動(dòng)的限制,通過(guò)通知設(shè)置 fullScreenIntent 可以在原生 Android 10 系統(tǒng)上從后臺(tái)啟動(dòng) Activity。查看 AOSP 源碼,可以在 AMS 找到這部分后臺(tái)權(quán)限限制的代碼,上面講到 startActivity 的流程,在 APP 進(jìn)程發(fā)起請(qǐng)求后,會(huì)通過(guò) Binder 跨進(jìn)程調(diào)用到 system_server 進(jìn)程中的 AMS,然后調(diào)用到 ActivityStarter.startActivity 方法,關(guān)于后臺(tái)啟動(dòng)的限制就這這里:
// 好家伙,整整二十多個(gè)參數(shù),嘿嘿,嘿嘿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 調(diào)用是在 Android Q 中新增的,看方法名就能菜刀這是針對(duì)后臺(tái)啟動(dòng)的:
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; } // ...}
從這個(gè)方法可以看到后臺(tái)啟動(dòng)的限制和官方文檔 從后臺(tái)啟動(dòng) Activity 的限制 中的說(shuō)明是可以對(duì)應(yīng)上的,這里面都是針對(duì) uid 去做權(quán)限判斷的,且是在系統(tǒng)進(jìn)程 system_server 中完成,單純更改包名已經(jīng)沒(méi)用了。。。
在一些沒(méi)有針對(duì)后臺(tái)啟動(dòng)單獨(dú)做限制的 ROM 上通過(guò) 全屏通知 可以成功彈出后臺(tái) Activity 頁(yè)面,比如說(shuō)小米 A3,另外還有一臺(tái) vivo 和一臺(tái)三星手機(jī),具體機(jī)型忘記了;在做了限制的設(shè)備上則彈不出來(lái),比如說(shuō)紅米 Note 8 Pro。
對(duì)于紅米 Note 8 Pro 這塊硬骨頭,不停嘗試了好多方法,但其實(shí)都是碰運(yùn)氣的,因?yàn)槟貌坏?MIUI 的源碼,后來(lái)想轉(zhuǎn)變思路,是否可以嘗試從這臺(tái)手機(jī)上 pull 出相關(guān)的 framework.jar 包然后反編譯呢?說(shuō)不定就有收獲!不過(guò)需要 Root 手機(jī),這個(gè)好辦,小米自己是有提供可以 Root 的開(kāi)發(fā)版系統(tǒng)的,于是就去 MIUI 官網(wǎng)找了一下,發(fā)現(xiàn)這臺(tái)紅米 Note 8 Pro 機(jī)型沒(méi)有提供開(kāi)發(fā)版系統(tǒng)(笑哭),想起來(lái)好像之前是說(shuō)過(guò)低端機(jī)小米不再提供開(kāi)發(fā)版了。。。好吧,手里頭沒(méi)有其它可以嘗試的手機(jī)了。
再轉(zhuǎn)念一想,是否可以直接下載穩(wěn)定版的 ROM 包,解壓后有沒(méi)有工具能夠得到一些源碼相關(guān)的痕跡呢?于是下載了一個(gè) ROM.zip 后,解壓看到里面只有一些系統(tǒng)映像 img 文件和 .dat.br 文件,這一塊我還不太懂,猜想就算能得到我想要的東西,整套流程花費(fèi)的時(shí)間成本估計(jì)也超出預(yù)期了,所以暫時(shí)只能先放下這個(gè)想法了。后續(xù)有足夠的時(shí)間再深入研究研究吧。
總結(jié)原生Android ROM
Android 原生 ROM 都能正常地從后臺(tái)啟動(dòng) Activity 界面,無(wú)論是 Android 9(直接啟動(dòng)) 還是 10 版本(借助全屏通知)。
定制化ROM
檢測(cè)后臺(tái)彈出界面權(quán)限:
通過(guò)反射 AppOpsManager 相關(guān)方法檢測(cè)對(duì)應(yīng) opCode 的權(quán)限; opCode = 10021(小米機(jī)型); 其它機(jī)型可以嘗試遍歷得到 opCode;Android P版本的小米:
通過(guò)Hook相關(guān)參數(shù)來(lái)后臺(tái)啟動(dòng)Activity,代碼由于某些原因不能給出了,有需要的同學(xué)可以留言告訴我哈; 只測(cè)試過(guò)小米機(jī)型,其它機(jī)型不一定可用; 理論上 P 版本以下的小米應(yīng)該也支持;Android P版本的機(jī)型:
通過(guò) moveTaskToFront 方法將應(yīng)用切換到前臺(tái); 這種方法畢竟是官方 API,因此兼容性可能更好一些; 如果切換失敗的話可以多嘗試幾次調(diào)用 moveTaskToFront 方法; 理論上 P 版本以下的機(jī)型應(yīng)該也支持;Android Q版本的機(jī)型:
通過(guò)系統(tǒng)全屏通知的方式調(diào)起后臺(tái) Activity; 在一些另作了限制的 ROM 上可能調(diào)起失敗;至于反編譯 MIUI 代碼的方式只是一個(gè)猜想,時(shí)間原因未能付諸行動(dòng)。看樣子產(chǎn)品哥哥的需求暫時(shí)不能完全實(shí)現(xiàn)了,不知道有沒(méi)有做過(guò)相關(guān)研究(或者知道內(nèi)情)的小伙伴能不能提供一些參考思路,雖然是一個(gè)比較流氓的功能,但是代碼是無(wú)罪的嘿嘿,朝著一個(gè)需求目標(biāo),為此思考解決方法,并從各個(gè)方向去調(diào)研,我覺(jué)得本身是一件有意思也有提升的事情!歡迎有過(guò)相關(guān)研究的同學(xué)在評(píng)論區(qū)提出建議,做好需求奧里給。
以上就是Android后臺(tái)啟動(dòng)Activity的實(shí)現(xiàn)示例的詳細(xì)內(nèi)容,更多關(guān)于Android后臺(tái)啟動(dòng)Activity的資料請(qǐng)關(guān)注好吧啦網(wǎng)其它相關(guān)文章!
相關(guān)文章:
1. .Net Core和RabbitMQ限制循環(huán)消費(fèi)的方法2. jsp網(wǎng)頁(yè)實(shí)現(xiàn)貪吃蛇小游戲3. asp(vbs)Rs.Open和Conn.Execute的詳解和區(qū)別及&H0001的說(shuō)明4. ASP.NET MVC遍歷驗(yàn)證ModelState的錯(cuò)誤信息5. 用css截取字符的幾種方法詳解(css排版隱藏溢出文本)6. ASP 信息提示函數(shù)并作返回或者轉(zhuǎn)向7. asp中response.write("中文")或者js中文亂碼問(wèn)題8. PHP設(shè)計(jì)模式中工廠模式深入詳解9. CSS hack用法案例詳解10. 將properties文件的配置設(shè)置為整個(gè)Web應(yīng)用的全局變量實(shí)現(xiàn)方法
