序言
乍聽起來,靜默安裝是非常流氓的一件事,它讓使用者不知覺的情況下被「收割」。但是技術本身是中立的,我們只談談實現靜默安裝這件事兒。
下面我將介紹三種靜默安裝的方案,每種方案各有利弊,但是目的是一致的。
- 手機被 Root 後直接靜默安裝
- 宣告安裝許可權並進行系統簽名來靜默安裝
- 使用輔助功能進行安裝(稱作「智慧安裝」更貼切吧)
1. 手機被 Root 後直接靜默安裝
眾所周知,手機被 Root 後可以做好多奇妙的事情,比如靜默安裝,直接呼叫 pm install 命令就可以實現,來看程式碼:
public static boolean silentInstall(String apkPath) {
boolean result = false;
DataOutputStream dataOutputStream = null;
BufferedReader errorStream = null;
BufferedReader successStream = null;
Process process = null;
try {
// 申請 su 許可權
process = Runtime.getRuntime().exec("su");
dataOutputStream = new DataOutputStream(process.getOutputStream());
// 執行 pm install 命令
String command = "pm install -r " + apkPath + "\n";
dataOutputStream.write(command.getBytes(Charset.forName("UTF-8")));
dataOutputStream.writeBytes("exit\n");
dataOutputStream.flush();
process.waitFor();
errorStream = new BufferedReader(new InputStreamReader(process.getErrorStream()));
StringBuilder errorMsg = new StringBuilder();
String line;
while ((line = errorStream.readLine()) != null) {
errorMsg.append(line);
}
log.debug("silent install error message:{}", errorMsg);
StringBuilder successMsg = new StringBuilder();
successStream = new BufferedReader(new InputStreamReader(process.getInputStream()));
// 讀取命令執行結果
while ((line = successStream.readLine()) != null) {
successMsg.append(line);
}
log.debug("silent install success message:{}", successMsg);
// 如果執行結果中包含 Failure 字樣就認為是操作失敗,否則就認為安裝成功
if (!(errorMsg.toString().contains("Failure") || successMsg.toString().contains("Failure"))) {
result = true;
}
} catch (Exception e) {
log.error(e);
} finally {
try {
if (process != null) {
process.destroy();
}
if (dataOutputStream != null) {
dataOutputStream.close();
}
if (errorStream != null) {
errorStream.close();
}
if (successStream != null) {
successStream.close();
}
} catch (Exception e) {
// ignored
}
}
return result;
}
public static boolean isRoot() {
return new File("/system/bin/su").exists() || new File("/system/xbin/su").exists();
}
複製程式碼
首先申請 Root 許可權,然後執行 pm install- r <apk 路徑>
命令,-r 參數列示允許覆蓋安裝。 process.waitFor() 說明安裝過程是同步的,等待命令執行完成,然後讀取執行結果。注意:不要在主執行緒呼叫靜默安裝的程式碼,安裝過程會比較耗時,導致執行緒一直等待結果。
結論:只要手機被 Root,該方法十分奏效。但是絕大部分使用者不懂 Root,即使手機被 Root了,還需要使用者授權,所以該方案侷限性非常大。
2. 宣告安裝許可權並進行系統簽名來靜默安裝
當我們選擇手動安裝應用時,會跳轉到應用安裝介面,這個介面就是系統的 PackageInstaller 提供,專門用來讓使用者有感知地安裝應用。
Intent intent = new Intent(Intent.ACTION_VIEW);
Uri uri = Uri.fromFile(new File("/sdcard/news.apk")));
intent.setDataAndType(uri, "application/vnd.android.package-archive");
startActivity(intent);
複製程式碼
分析 PackageInstaller 的原始碼,我們發現它會通過 PackageManager 呼叫 installPackage 方法,這是個隱藏的抽象方法,實現類是 ApplicationPackageManager。主要看一下四個引數:packageURI 就是 apk 的路徑;observer 是安裝的監聽器,應用安裝完成時會被回撥,不能為 null;flags 是標誌位,指定安裝的引數;installersPackageName 表示可選的安裝來源,比如應用寶之類的。
public abstract void installPackage(
Uri packageURI, PackageInstallObserver observer,
int flags, String installerPackageName);
複製程式碼
ApplicationPackageManager 裡面 mPM 是一個 IPackageManager 型別的物件,它會執行具體的安裝任務。
try {
mPM.installPackage(originPath, observer.getBinder(), flags, installerPackageName,
verificationParams, null);
} catch (RemoteException ignored) {
}
複製程式碼
ContextImpl 的 getPackageManager 方法,通過 ActivityThread 獲取 IPackageManager 物件用來構造 ApplicationPackageManager,然後返回 ApplicationPackageManager。
public PackageManager getPackageManager() {
if (mPackageManager != null) {
return mPackageManager;
}
IPackageManager pm = ActivityThread.getPackageManager();
if (pm != null) {
// Doesn't matter if we make more than one instance.
return (mPackageManager = new ApplicationPackageManager(this, pm));
}
return null;
}
複製程式碼
ActivityThread 的 getPackageManager 方法,其實就是獲取系統服務的過程。
public static IPackageManager getPackageManager() {
if (sPackageManager != null) {
//Slog.v("PackageManager", "returning cur default = " + sPackageManager);
return sPackageManager;
}
IBinder b = ServiceManager.getService("package");
//Slog.v("PackageManager", "default service binder = " + b);
sPackageManager = IPackageManager.Stub.asInterface(b);
//Slog.v("PackageManager", "default service = " + sPackageManager);
return sPackageManager;
}
複製程式碼
通過以上分析,我們通過 PackageManager 呼叫 installPackage 方法就行了,下面看程式碼:
public static boolean silentInstall(PackageManager packageManager, String apkPath) {
Class<?> pmClz = packageManager.getClass();
try {
if (Build.VERSION.SDK_INT >= 21) {
Class<?> aClass = Class.forName("android.app.PackageInstallObserver");
Constructor<?> constructor = aClass.getDeclaredConstructor();
constructor.setAccessible(true);
Object installObserver = constructor.newInstance();
Method method = pmClz.getDeclaredMethod("installPackage", Uri.class, aClass, int.class, String.class);
method.setAccessible(true);
method.invoke(packageManager, Uri.fromFile(new File(apkPath)), installObserver, 2, null);
} else {
Method method = pmClz.getDeclaredMethod("installPackage", Uri.class, Class.forName("android.content.pm.IPackageInstallObserver"), int.class, String.class);
method.setAccessible(true);
method.invoke(packageManager, Uri.fromFile(new File(apkPath)), null, 2, null);
}
return true;
} catch (Exception e) {
log.error(e);
}
return false;
}
複製程式碼
由於 PackageManager 在不同版本上的 installPackage 方法引數不一致,所以我們根據編譯版本做了處理。在 API 21 及以上,需要傳遞一個非 null 的 PackageInstallObserver,這個類是不可見 的,我們就用反射建立一個 observer 物件,flags 指定 INSTALL_REPLACE_EXISTING
,用常量表示就是 2。在 API 21 以下,observer 型別是IPackageInstallObserver,同樣使用反射處理即可。
最後宣告許可權 <uses-permission android:name="android.permission.INSTALL_PACKAGES"/>
,還要使用系統簽名,這個非常關鍵,要不然就會出現異常: java.lang.SecurityException: Neither user 10052 nor current process has android.permission.INSTALL_PACKAGES.
。
結論:通過呼叫系統 API 靜默安裝,終於可以堂堂正正地搞事情了!雖然這是官方提供的介面,但是為了不讓你為所欲為,強制使用系統簽名,所以對於第三方應用採用的可能性是零。
3. 使用輔助功能進行安裝
現在大多數應用採取的是這種辦法,讓使用者主動開啟輔助功能,然後模擬點選使用者操作,進行自動安裝。核心就是 AccessibilityService,我們來實現這一功能。
- 建立 AccessibilityService 配置檔案 在 res 目錄下建立 xml 目錄,然後在 xml 目錄下建立 accessibility_service_config.xml 檔案,內容如下
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canRetrieveWindowContent="true"
android:description="@string/accessibility_service_description"
android:packageNames="com.android.packageinstaller"
/>
複製程式碼
accessibilityEventTypes: 指定我們在監聽視窗中可以模擬哪些事件,typeAllMask 表示所有的事件都能模擬。 accessibilityFeedbackType: 指定無障礙服務的反饋方式。 canRetrieveWindowContent: 指定是否允許我們的程式讀取視窗中的節點和內容,當然是 true。 description: 當使用者手動配置服務時,顯示給使用者看的說明資訊。 packageNames: 指定要監聽哪個應用程式下的視窗活動,這裡寫 com.android.packageinstaller 表示監聽 Android 系統的安裝介面。 配置裡面描述的內容
<resources>
<string name="app_name">InstallTest</string>
<string name="accessibility_service_description">智慧安裝服務,無需使用者的任何操作就可以自動安裝程式。</string>
</resources>
複製程式碼
- 建立 AccessibilityService 服務 建立一個類繼承自 AccessibilityService ,然後重寫 onAccessibilityEvent 方法。
public class MyAccessibilityService extends AccessibilityService {
private static final String TAG = "[TAG]";
private Map<Integer, Boolean> handleMap = new HashMap<>();
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
AccessibilityNodeInfo nodeInfo = event.getSource();
if (nodeInfo != null) {
int eventType = event.getEventType();
if (eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED || eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
if (handleMap.get(event.getWindowId()) == null) {
boolean handled = iterateNodesAndHandle(nodeInfo);
if (handled) {
handleMap.put(event.getWindowId(), true);
}
}
}
}
}
@Override
public void onInterrupt() {
}
// 遍歷節點,模擬點選安裝按鈕
private boolean iterateNodesAndHandle(AccessibilityNodeInfo nodeInfo) {
if (nodeInfo != null) {
int childCount = nodeInfo.getChildCount();
if ("android.widget.Button".equals(nodeInfo.getClassName())) {
String nodeCotent = nodeInfo.getText().toString();
Log.d(TAG, "content is: " + nodeCotent);
if ("安裝".equals(nodeCotent) || "完成".equals(nodeCotent) || "確定".equals(nodeCotent)) {
nodeInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK);
return true;
}
}
// 遇到 ScrollView 的時候模擬滑動一下
else if ("android.widget.ScrollView".equals(nodeInfo.getClassName())) {
nodeInfo.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
}
for (int i = 0; i < childCount; i++) {
AccessibilityNodeInfo childNodeInfo = nodeInfo.getChild(i);
if (iterateNodesAndHandle(childNodeInfo)) {
return true;
}
}
}
return false;
}
}
複製程式碼
當進入安裝介面就會回撥 onAccessibilityEvent() 這個方法,我們只處理 TYPE_WINDOW_CONTENT_CHANGED 和 TYPE_WINDOW_STATE_CHANGED 兩個事件。為了防止重複處理事件,用 map 來過濾事件,然後遞迴遍歷節點,找到「安裝」、「完成」、「缺點」的按鈕就模擬點選。由於安裝介面需要使用者看完許可權才出現按鈕,所以遇到 ScrollView 的時候就模擬滾動,直到出現安裝按鈕。
- 在 AndroidManifest 中配置服務
<service
android:name=".MyAccessibilityService"
android:label="智慧安裝應用"
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_config"
/>
</service>
複製程式碼
android:label:這個就是使用者看到的無障礙服務的名稱。 android:permission: 需要用到 BIND_ACCESSIBILITY_SERVICE 這個許可權。 action: android.accessibilityservice.AccessibilityService 有了這個 action,使用者才能在設定裡面看到我們的服務,否則使用者無法開啟我們的 AccessibilityService,也就不能進到 MyAccessibilityService 裡面了。
- 呼叫智慧安裝程式碼
private void smartInstall() {
Uri uri = Uri.fromFile(new File("/sdcard/test.apk"));
Intent localIntent = new Intent(Intent.ACTION_VIEW);
localIntent.setDataAndType(uri, "application/vnd.android.package-archive");
startActivity(localIntent);
}
複製程式碼
- 手動配置智慧安裝服務 跳轉輔助功能的配置介面,引導使用者開啟智慧安裝服務。
Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
startActivity(intent);
複製程式碼
總結
智慧安裝是一種妥協的方案,在沒有 Root 和安裝許可權的情況下,確實解放了使用者的拇指。看看市面上的應用,大部分都採用了這種方法。應用市場使用智慧安裝可以理解,視訊瀏覽器工具一類不相干的應用也要開啟?我真是呵呵了。
最後
在現在這個金三銀四的面試季,我自己在網上也蒐集了很多資料做成了文件和架構視訊資料免費分享給大家【包括高階UI、效能優化、架構師課程、NDK、Kotlin、混合式開發(ReactNative+Weex)、Flutter等架構技術資料】,希望能幫助到您面試前的複習且找到一個好的工作,也節省大家在網上搜尋資料的時間來學習。