說明
Android 的防劫持是門大學問。涉及到眾多高深的知識。本文不會闡述這些。本文只是會討論其中的一個小部分---如何檢測界面被覆蓋,或者說如何檢測用戶離開了應用。
功能目的
最近需要實現一個功能:當用戶退出 APP 時,如果用戶處於某些特定的界面(比如登錄、注冊、修改密碼界面),需要提示用戶退出了應用。以滿足合規要求。實現效果可以參考"建設銀行 APP"。做這個功能主要是為了滿足以下兩個目的:
- 滿足合規要求
- 增強應用安全性
經過幾天的預研,也算是搞懂了一些東西。話不多說,開講。
知識講解
首先,我們需要知道以下幾個概念:
- Activity 生命周期:完整的生命周期過程就不講了,網上一大堆資料。這里就提幾個場景:用戶按 Home 鍵退出當前 APP 或者點擊了 menu 鍵,Activity 生命周期會走到 onStop。
- Activity 棧:通常情況下,處於 Activity 棧棧頂的 Activity,才能跟用戶交互。這里我把它叫做交互界面。注意前台界面和交互界面的區別,前台界面是可見的,但並不一定是可以和用戶交互的
- 不透明 Activity 和透明 Activity:假設 Activity A 被 Activity B 覆蓋,那么會出現兩種情況:B 透明,B 不透明。具體說明看下面的圖。
說明
假設當前在 APP 1 的 A 界面,進入了 APP 2 的 B 界面,那么相應的生命周期為:
- 如果 B 界面是非透明的。那么 A 界面的生命周期函數會走到 onStop
- 如果 B 界面是透明的。那么 A 界面的生命周期函數會走到 onPause
下面幾張圖片是關於生命周期的說明:
- 單個生命周期示意圖
- A 進入 B 時的生命周期
- 當 B 不透明時
- 當 B 透明時
代碼預設
既然我們已經知道了啟動透明界面和非透明界面(2 種界面)的不同,那么就需要針對性的做些設計了。而又因為需要檢測其它 APP,所以需要設計多個 APP (2 個)及多個對應的界面(2 * 2 = 4 個界面)。
下面是設計的兩個 APP。
------------------------ APP 1 設計開始 -------------------------
新建 1 個項目,我把他起名叫 ThisApp,意思是當前需要檢測界面覆蓋的 APP。這個 APP 代表着實際工作中我們需要維護的 APP。
生命周期變化監聽
自定義 Application,在初始化時注冊生命周期變化監聽:
/**
* LifeCycleApplication: 監聽生命周期變化的 Application
* */
public class ThisLCApplication extends Application {
private static final String TAG = "ThisLCApplication";
private ActivityLifecycleCallbacks callback = new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
Log.d(TAG, " onActivityCreated ---> " + activity.getClass().getSimpleName());
}
@Override
public void onActivityStarted(@NonNull Activity activity) {
Log.d(TAG, " onActivityStarted ---> " + activity.getClass().getSimpleName());
}
@Override
public void onActivityResumed(@NonNull Activity activity) {
Log.d(TAG, " onActivityResumed ---> " + activity.getClass().getSimpleName());
}
@Override
public void onActivityPaused(@NonNull Activity activity) {
Log.d(TAG, " onActivityPaused ---> " + activity.getClass().getSimpleName());
}
@Override
public void onActivityStopped(@NonNull Activity activity) {
Log.d(TAG, " onActivityStopped ---> " + activity.getClass().getSimpleName());
}
@Override
public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
Log.d(TAG, " onActivitySaveInstanceState ---> " + activity.getClass().getSimpleName());
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
Log.d(TAG, " onActivityDestroyed ---> " + activity.getClass().getSimpleName());
}
};
@Override
public void onCreate() {
super.onCreate();
registerActivityLifecycleCallbacks(callback);
}
}
在清單文件 AndroidManifest.xml 中使用 ThisLCApplication:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.wellcherish.thisapp">
<application
android:name=".ThisLCApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
設計 MainActivity
首先設計主界面 MainActivity。包含幾個按鈕:
xml 代碼如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<!-- 第 1 個 Button -->
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gl_020"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.20" />
<Button
android:id="@+id/btn_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:text="啟動本 APP 的非透明界面"
android:textColor="@android:color/white"
android:textSize="20sp"
android:background="@color/colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/gl_020"
android:onClick="startThisOpaqueActivity" />
<!-- 第 2 個 Button -->
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gl_040"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.40" />
<Button
android:id="@+id/btn_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:text="啟動本 APP 的透明界面"
android:textColor="@android:color/white"
android:textSize="20sp"
android:background="@color/colorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/gl_040"/>
<!-- 第 3 個 Button -->
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gl_060"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.60" />
<Button
android:id="@+id/btn_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:text="啟動其他 APP 的非透明界面"
android:textColor="@android:color/white"
android:textSize="20sp"
android:background="@android:color/holo_green_light"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/gl_060"
android:onClick="startOtherOpaqueActivity"/>
<!-- 第 4 個 Button -->
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gl_080"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.80" />
<Button
android:id="@+id/btn_4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:text="啟動其他 APP 的透明界面"
android:textColor="@android:color/white"
android:textSize="20sp"
android:background="@android:color/holo_orange_light"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/gl_080"
android:onClick="startOtherTransparentActivity"/>
</androidx.constraintlayout.widget.ConstraintLayout>
上面的幾個點擊方法實現如下:
public void startThisOpaqueActivity(View view) {
Intent i = new Intent(MainActivity.this, ThisOpaqueActivity.class);
startActivity(i);
}
public void startThisTransparentActivity(View view) {
Intent i = new Intent(MainActivity.this, ThisTransparentActivity.class);
startActivity(i);
}
public void startOtherOpaqueActivity(View view) {
Intent i = new Intent();
i.setComponent(new ComponentName("com.wellcherish.otherapp",
"com.wellcherish.otherapp.OtherOpaqueActivity"));
startActivity(i);
}
public void startOtherTransparentActivity(View view) {
Intent i = new Intent();
i.setComponent(new ComponentName("com.wellcherish.otherapp",
"com.wellcherish.otherapp.OtherTransparentActivity"));
startActivity(i);
}
設計 ThisApp 的 ThisOpaqueActivity
ThisApp 的非透明界面(ThisOpaqueActivity)顯示內容如下:
XML 代碼如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ThisOpaqueActivity">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gl_020"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.20"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:text="本 APP 的非透明界面"
android:textSize="30sp"
android:textColor="@android:color/holo_purple"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/gl_020"/>
</androidx.constraintlayout.widget.ConstraintLayout>
設計 ThisApp 的 ThisTransparentActivity
ThisApp 的透明界面(ThisTransparentActivity)顯示內容如下:
xml 代碼如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ThisTransparentActivity">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gl_025"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.25"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:text="本 APP 的透明界面"
android:textSize="30sp"
android:textColor="#BD4ED2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/gl_025"/>
</androidx.constraintlayout.widget.ConstraintLayout>
然后自定義透明主題,並在 AndroidManifest 中為 Activity 設置:
<style name="translucent" parent="Theme.AppCompat.NoActionBar">
<!-- 設置背景透明度及其顏色值 -->
<item name="android:windowBackground">#0000</item>
<!-- 設置當前Activity是否透明-->
<item name="android:windowIsTranslucent">true</item>
<!-- 設置當前Activity進出方式-->
<item name="android:windowAnimationStyle">@android:style/Animation.Translucent</item>
</style>
<!-- 在 AndroidManifest 中設置主題 -->
<activity android:name=".ThisTransparentActivity"
android:theme="@style/translucent" />
------------------------ APP 1 設計結束 -------------------------
------------------------ APP 2 設計開始 -------------------------
新建 2 個項目,我把他起名叫 OtherApp,意思是需要覆蓋當前 APP 的其它 APP。這個 APP 可以理解成實際使用中的惡意 APP。這個 APP 我們只需要模擬兩個界面即可。
生命周期變化監聽
自定義 Application,在初始化時注冊生命周期變化監聽,代碼和 ThisApp 的完全一樣,只是改了下 TAG,就不放代碼了。
設計 OtherApp 的 OtherOpaqueActivity
OtherApp 的非透明界面(OtherOpaqueActivity)顯示內容如下:
XML 代碼就不放了,和 ThisApp 的 ThisOpaqueActivity 布局完全相同,只是改了下 TextView 的文案,文案從圖中可以知曉。
設計 OtherApp 的 OtherTransparentActivity
OtherApp 的透明界面(OtherTransparentActivity)顯示內容如下:
xml 代碼和主題代碼就不放了,和 ThisApp 的 ThisTransparentActivity 布局完全相同,只是改了下 TextView 的文案,文案從圖中可以知曉。而 xml 中也只多了一個允許其他應用訪問的設置。
<!-- OtherApp 需要設置 exported 項為 true -->
<activity android:name=".OtherOpaqueActivity"
android:exported="true"/>
<activity android:name=".OtherTransparentActivity"
android:theme="@style/translucent"
android:exported="true"/>
------------------------ APP 2 設計結束 -------------------------
方案驗證
在設計好了 APP 之后,就可以做方案驗證了。根據網上現有的資料,我總結了界面覆蓋檢測有以下幾種方法:
這些方案我一個個說明。
棧頂 Activity 檢測方案
在 Android 中,與用戶交互的界面,一定是處於任務棧的棧頂。所以可以使用這個方案來檢測棧頂 Activity,判斷頁面是否被其他 APP 的界面覆蓋。方案主要是在生命周期變化時(啟動其他界面時,當前棧頂的 Activity 的生命周期狀態一定會改變),檢測棧頂 Activity 是否屬於當前 APP,進而判斷出當前 APP 是否被其他應用覆蓋。但是鑒於啟動透明界面和非透明界面時存在着不同的生命周期流程,所以需要驗證不同的生命周期流程是否會影響到上述檢測方案的准確性。
先上方案的核心實現代碼:
public static boolean checkByTopActivity(Activity activity) {
ActivityManager activityManager = (ActivityManager) activity
.getSystemService(Context.ACTIVITY_SERVICE);
try {
ComponentName cn = activityManager.getRunningTasks(1).get(0).topActivity;
// 不相等,說明被覆蓋了
return !activity.getPackageName().equals(cn.getPackageName());
} catch (Exception e) {
Log.e(TAG, "", e);
return false;
}
}
將該檢測方法放到自定義 Application 的生命周期監聽的 onStop 中,則可以檢測 APP 是否在前台了。
但是如果你直接在 Android Studio 中使用上述的代碼,Android Studio 會給出提示,如下。這說明了該方案存在高低版本兼容問題。
經過驗證,生命周期的確會影響到檢測的准確性。假設是從 A 界面進入 B 界面。
- 當 B 是透明 Activity 時,A 的生命周期會走到 onPause,此時棧頂 Activity 仍然是 A。這就意味着棧頂 Activity 檢測方案失效了。
- 當 B 是非透明 Activity 時,A 的生命周期會走到 onStop,此時棧頂 Activity 變成了非 A。棧頂 Activity 檢測方案生效。
應用重要性檢測方案
在 Android 中,每一個應用都有一個優先級,進程的優先級分類有:前台進程、可見進程、服務進程、后台進程、空進程。可以通過這個標識來判斷進程是否在前台,進而判斷是否退出了應用,或者應用是否被其他 App 覆蓋。
核心代碼如下:
public static boolean checkByProcessImportance(Activity activity) {
ActivityManager activityManager = (ActivityManager) activity
.getSystemService(Context.ACTIVITY_SERVICE);
try {
List<ActivityManager.RunningAppProcessInfo> appProcesses = activityManager
.getRunningAppProcesses();
for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) {
// 非當前 APP
if(!appProcess.processName.equals(activity.getPackageName())) {
continue;
}
/**
* 前台進程:100
* 可見進程:200
* 服務進程:300
* 后台進程:400
* 空進程: 500
*/
return appProcess.importance != ActivityManager
.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
}
return false;
} catch (Exception e) {
Log.e(TAG, "", e);
return false;
}
}
經過驗證,該方案也無法檢測透明界面的覆蓋情況
應用使用記錄檢測方案與輔助服務檢測前台應用
上述兩種常見的方案都存在失效的場景的情況,我也一籌莫展。但是經過一段時間的探索后,我發現我裝的一個叫做 "當前 Activity" 的應用,可以檢測出半透明界面。一道曙光乍現,我將這個 APK 反編譯了出來。經過一番探索之后,我發現了 "UsageStatsManager" 這個類,上網一搜,發現這個類是和 "應用使用記錄統計" 這個功能強關聯的。關於這個類的使用,可以看下這幾篇文章:Android 5.0 應用使用情況統計信息、(Android 9.0)應用使用數據統計服務——UsageStatsManager、4種獲取前台應用的方法(肯定有你不知道的)。這幾個文章里講的就比較清楚了,這里就不重復敘述了。直接上代碼。
1. 在 AndroidManifest.xml 中聲明權限。
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
2. 核心代碼
public static boolean checkByUsage(Activity activity) {
if(!checkPermission(activity)) {
try {
activity.startActivity(new Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS));
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
UsageStatsManager statsManager = (UsageStatsManager) activity
.getSystemService(Context.USAGE_STATS_SERVICE);
long endTime = System.currentTimeMillis();
List<UsageStats> usageStatsList = statsManager
.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, endTime - 1000*10, endTime);
if(usageStatsList == null || usageStatsList.size() <= 0) {
return false;
}
return !usageStatsList.get(0).getPackageName().equals(activity.getPackageName());
}
public static boolean checkPermission(Activity activity) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) {
return false;
}
AppOpsManager appOpsManager = (AppOpsManager) activity
.getSystemService(Context.APP_OPS_SERVICE);
int mode;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mode = appOpsManager.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
Process.myUid(), activity.getPackageName());
} else {
mode = appOpsManager.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
Process.myUid(), activity.getPackageName());
}
return mode == AppOpsManager.MODE_ALLOWED;
}
輔助服務獲取前台應用
上面的第三篇文章鏈接中,有指出可以與使用應用使用記錄統計達到相同目的的一個功能,就是 輔助服務獲取前台應用,這個輔助服務,是無障礙的相關功能。而 "當前 Activity" 這個 APK,在使用時,需要申請輔助服務的相關權限。這也就是說,"當前 Activity" 這個 APK 就是使用的這種方式檢測的前台應用。
輔助服務獲取前台應用的使用步驟如下:
首先定義輔助服務。
public class AccessibilityMonitorService extends AccessibilityService {
private CharSequence mWindowClassName;
private String mCurrentPackage;
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
int type=event.getEventType();
switch (type){
// 核心代碼
case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
mWindowClassName = event.getClassName();
mCurrentPackage = event.getPackageName()==null ? "" : event.getPackageName().toString();
break;
default:
break;
}
}
}
然后在 AndroidManifest 清單文件中申明服務。
<service
android:name=".service.AccessibilityMonitorService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
>
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility" />
</service>
然后在 res/xml/ 文件夾下新建文件 accessibility.xml,內容如下:
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:canRequestFilterKeyEvents ="true"
android:notificationTimeout="10"
android:packageNames="@null"
android:description="@string/accessibility_des"
android:settingsActivity="com.pl.recent.MainActivity"
/>
關鍵是 typeWindowStateChanged 這個事件的聲明。
這樣就可以檢測前台應用的改變,並獲取前台應用的包名了。
總結一下:應用使用情況統計 和 輔助服務 兩種方法雖然能檢測出半透明應用的覆蓋情況,但是需要申請額外權限。這兩個權限及其敏感的權限,一般過不了合規檢查。所以這兩種方法,知道就行了,不會用的。
第三方 SDK 檢測方案
這個沒啥好講的。我們公司跟梆梆交流過。梆梆的 SDK 能滿足要求,但是很貴,就先放棄了。因為找到了其他方法(就是下面講的 自定義檢測方案)。
在講自定義檢測方案之前,我們需要先總結一下現有方案的優缺點:
- 無須任何額外權限的方案,檢測不了透明界面覆蓋的場景,方案代表:棧頂 Activity 檢測,應用重要性檢測
- 能夠檢測透明界面覆蓋的方案,需要申請額外權限。這些額外權限存在以下問題。方案代表:應用使用記錄統計檢測,輔助服務獲取前台應用檢測。
- 權限很敏感,並且無法動態分配
- 檢測權限狀態時得用更底層的 API
- 分配權限時用戶需要主動前往設置界面分配。
- 無須權限,且能夠檢測透明界面的方案,又需要錢。方案代表:第三方 SDK 檢測
自定義檢測方案
既然上面的方案都有明顯的缺點,那肯定只有我們自定義了。還好,自定義的方案是能實現的(否則只能使用第三方 SDK 了)。
在做界面覆蓋檢測這個功能的研究中,我發現了一件事。那就是在未授權的狀態下,一個應用無法獲取前台應用的相關信息。我們可以理解成應用無法獲取其他應用的信息。既然如此,那我們就不關注其他應用,只關注本應用就行了。原因如下:
- 所有的 APP 被覆蓋或者退出應用時,都會走生命周期的流程,其中 Activity 的 onPause 方法是必定執行的。
- 在進入 APP 時,Activity 的生命周期方法也必定執行:
- 如果是 Activity 已創建,則肯定會執行 Activity 的 onResume 方法
- 如果 Activity 未創建,則肯定會執行 Activity 的 onCreate 方法。
- 通常 Activity 的切換不會太耗時(太耗時容易 ANR),假設每個 Activity 的切換是在 500 ms 以內
- Application 中設置的生命周期回調,雖然不能檢測到其他 APP 的 Activity 的生命周期變化,但本 APP 的 Activity 的生命周期變化,是可以感知到的
基於上述邏輯,我設計了一個方案:在 Activity 生命周期走到 onPause 時,延時發送一個事件,該事件會觸發一個 Toast 提醒,該 Toast 用於提示用戶已離開本應用。然后在 onCreate、onResume 中移除延時事件。設在 A 界面啟動 B,該方案的解釋如下:
- 如果 B 與 A 屬於同一個應用,那么 B 的生命周期變化時,是肯定能觸發生命周期回調的。我們在 onCreate、onResume 中移除 Toast 提醒,是完全可行的。onCreate 對應新 Activity 創建的場景,onResume 對應已創建的 Activity 重回棧頂的場景。
- 如果 B 與 A 不屬於同一個應用,那么在 B 的 onPause 中觸發的延時動作,在 A 走到 onCreate、onResume 時是取消不了的。延時事件取消不了,時間一到,肯定就觸發提示了。
- 在 A 的 onPause 中進行延時處理的前提是,A 的 onPause 過后,就是 B 的生命周期回調。並且二者切換所用的時間間隔小於設定的延時。以 500 ms 為例,加入延時 500 ms,那么當 A.onPause ---> B.onCreate 的耗時,是在 500 ms 以內的話,就可以滿足要求了。而谷歌官方的建議是不要在 onPause 中做耗時操作,因為會影響到界面切換,如果 APP 遵循了這個規則,那么通常在 500 ms 以內,是可以完成 A.onPause ---> B.onCreate/A.onPause ---> B.onResume 動作的。
- 不在 onDestroy 中移除回調的原因是有這么一個場景,A ---> B ---> A,此時按下 back 鍵,棧頂的 A 會被銷毀,退到 B,但是 A 所屬應用並未被殺死,仍然需要提醒用戶。
綜上,下面講講自定義方案的具體實現。
首先,定義一個界面覆蓋檢測類。提供事件延時發送,取消等接口,延時 500 ms。
public class CoverageChecker {
private static final String TAG = "CoverageChecker";
private static CoverageChecker INSTANCE;
/**
* 是否退出了當前 APP 的標志
* */
private boolean isQuit = false;
// Application Context,防止內存泄漏
private Context mContext;
// 延時事件
private Runnable r;
// 延時事件發射器
private Handler handler;
// 單例模式
private CoverageChecker(Context context) {
this.mContext = context.getApplicationContext();
handler = new Handler(Looper.getMainLooper());
r = new Runnable() {
@Override
public void run() {
if(isQuit()) {
Log.d(TAG, "app is covered, show notify");
showCoveredHint();
}
}
};
}
public static CoverageChecker getInstance(Context context) {
if(INSTANCE == null) {
synchronized (CoverageChecker.class) {
if(INSTANCE == null) {
INSTANCE = new CoverageChecker(context);
}
}
}
if(INSTANCE.mContext == null) {
INSTANCE.mContext = context.getApplicationContext();
}
return INSTANCE;
}
public boolean isQuit() {
return isQuit;
}
public void setQuit(boolean isQuit) {
this.isQuit = isQuit;
}
/**
* 退出界面時,延遲通知
*
* @param activity
* */
public synchronized void delayNotify(Activity activity) {
// 不需要提示,則返回
if(!isNeedNotify(activity)) {
return;
}
setQuit(true);
// 先移除已有的
handler.removeCallbacks(r);
handler.postDelayed(r, 500);
}
/**
* 進入界面時,移除通知
* */
public synchronized void removeNotify() {
if(isQuit()) {
setQuit(false);
handler.removeCallbacks(r);
}
}
/**
* 判斷是否需要提示退出 APP
* */
public synchronized boolean isNeedNotify(Activity activity) {
if(activity == null) {
Log.w(TAG, "activity == null, not notify");
return false;
}
String actName = activity.getClass().getName();
if(TextUtils.isEmpty(actName)) {
Log.w(TAG, "activity name is null, not notify");
return false;
}
// 登錄、注冊、密碼、用戶信息等敏感界面才提示,其他界面不提示
return actName.contains("login") // 登錄相關界面
|| actName.contains("register") // 注冊相關界面
|| actName.contains("password") // 密碼相關界面
|| actName.contains("userinfo"); // 信息相關界面
}
/**
* 當應用被覆蓋時,顯示提示
* */
public void showCoveredHint() {
if(mContext == null) {
Log.d(TAG, "showCoveredHint---mContext == null");
return;
}
Toast.makeText(mContext, "你已退出應用", Toast.LENGTH_SHORT).show();
}
}
然后在 Application 的生命周期回調中觸發延時發送,取消等邏輯。
public class CustomApplication extends Application {
private static final String TAG = "CustomApplication";
@Override
public void onCreate() {
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
// 移除通知
CoverageChecker.getInstance(activity).removeNotify();
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityResumed(Activity activity) {
// 移除通知
CoverageChecker.getInstance(activity).removeNotify();
}
@Override
public void onActivityPaused(Activity activity) {
// 延時通知
CoverageChecker.getInstance(activity).delayNotify(activity);
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
});
}
}
在 AndroidManifest.xml 使用 CustomApplication 后,即可監測應用是否被覆蓋了。
使用了自定義方案后,能檢測透明界面的覆蓋情況了;也順利的通過了梆梆的檢測,APP 滿足了應用合規檢測的要求。
至此,界面覆蓋檢測這個功能就講的差不多了。