diff --git a/app/src/main/java/com/ttstd/dialer/db/app/AppDatabase.java b/app/src/main/java/com/ttstd/dialer/db/app/AppDatabase.java
index 508cbeb..84fcfbb 100644
--- a/app/src/main/java/com/ttstd/dialer/db/app/AppDatabase.java
+++ b/app/src/main/java/com/ttstd/dialer/db/app/AppDatabase.java
@@ -2,18 +2,41 @@ package com.ttstd.dialer.db.app;
import android.content.Context;
+import androidx.annotation.NonNull;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
+import androidx.room.migration.Migration;
+import androidx.sqlite.db.SupportSQLiteDatabase;
import java.io.File;
-@Database(entities = {AppInfo.class}, version = 2, exportSchema = false)
+@Database(entities = {AppInfo.class, AppIconUpload.class}, version = 3, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
public abstract AppDao appDao();
+ public abstract AppIconUploadDao appIconUploadDao();
+
private static volatile AppDatabase INSTANCE;
+ /**
+ * 2 -> 3:新增图标上传去重表 {@code app_icon_upload},不涉及已有表结构变更,
+ * 因此 {@code app_list} 中的排序等用户数据可完整保留。
+ *
+ * 建表语句必须与 Room 依据 {@link AppIconUpload} 生成的 schema 完全一致,
+ * 否则打开数据库时的完整性校验会抛出 IllegalStateException。
+ */
+ static final Migration MIGRATION_2_3 = new Migration(2, 3) {
+ @Override
+ public void migrate(@NonNull SupportSQLiteDatabase database) {
+ database.execSQL("CREATE TABLE IF NOT EXISTS `app_icon_upload` ("
+ + "`package_name` TEXT NOT NULL, "
+ + "`version_code` INTEGER NOT NULL, "
+ + "`updated_at` INTEGER NOT NULL, "
+ + "PRIMARY KEY(`package_name`))");
+ }
+ };
+
// 单例模式获取数据库实例
public static AppDatabase getDatabase(final Context context) {
if (INSTANCE == null) {
@@ -21,6 +44,7 @@ public abstract class AppDatabase extends RoomDatabase {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
AppDatabase.class, context.getExternalFilesDir("db") + File.separator + "app" + File.separator + "app_db")
+ .addMigrations(MIGRATION_2_3)
.fallbackToDestructiveMigration()
.build();
}
diff --git a/app/src/main/java/com/ttstd/dialer/db/app/AppIconUpload.java b/app/src/main/java/com/ttstd/dialer/db/app/AppIconUpload.java
new file mode 100644
index 0000000..d7894ec
--- /dev/null
+++ b/app/src/main/java/com/ttstd/dialer/db/app/AppIconUpload.java
@@ -0,0 +1,73 @@
+package com.ttstd.dialer.db.app;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParser;
+
+import java.io.Serializable;
+
+/**
+ * 图标上传去重记录。
+ *
+ * 以包名为主键,记录该应用最近一次上传成功的 versionCode;
+ * 应用更新后 versionCode 变化会重新上传图标。
+ */
+@Entity(tableName = "app_icon_upload")
+public class AppIconUpload implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @NonNull
+ @PrimaryKey
+ @ColumnInfo(name = "package_name")
+ private String packageName = "";
+
+ @ColumnInfo(name = "version_code")
+ private long versionCode;
+
+ @ColumnInfo(name = "updated_at")
+ private long updatedAt;
+
+ public AppIconUpload() {
+ }
+
+ public AppIconUpload(@NonNull String packageName, long versionCode) {
+ this.packageName = packageName;
+ this.versionCode = versionCode;
+ this.updatedAt = System.currentTimeMillis();
+ }
+
+ @NonNull
+ public String getPackageName() {
+ return packageName;
+ }
+
+ public void setPackageName(@NonNull String packageName) {
+ this.packageName = packageName;
+ }
+
+ public long getVersionCode() {
+ return versionCode;
+ }
+
+ public void setVersionCode(long versionCode) {
+ this.versionCode = versionCode;
+ }
+
+ public long getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(long updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return JsonParser.parseString(new Gson().toJson(this)).getAsJsonObject().toString();
+ }
+}
diff --git a/app/src/main/java/com/ttstd/dialer/db/app/AppIconUploadDao.java b/app/src/main/java/com/ttstd/dialer/db/app/AppIconUploadDao.java
new file mode 100644
index 0000000..8667420
--- /dev/null
+++ b/app/src/main/java/com/ttstd/dialer/db/app/AppIconUploadDao.java
@@ -0,0 +1,33 @@
+package com.ttstd.dialer.db.app;
+
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+
+import java.util.List;
+
+@Dao
+public interface AppIconUploadDao {
+
+ /**
+ * 查询指定包名已上传的 versionCode,未上传过时返回 null。
+ */
+ @Query("SELECT version_code FROM app_icon_upload WHERE package_name = :packageName")
+ Long getUploadedVersionCode(String packageName);
+
+ @Query("SELECT * FROM app_icon_upload")
+ List getAll();
+
+ /**
+ * 记录(或更新)某个包名上传成功的 versionCode。
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ long insert(AppIconUpload record);
+
+ @Query("DELETE FROM app_icon_upload WHERE package_name = :packageName")
+ int deleteByPackageName(String packageName);
+
+ @Query("DELETE FROM app_icon_upload")
+ int deleteAll();
+}
diff --git a/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java b/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java
index 5e30c60..b324a62 100644
--- a/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java
+++ b/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java
@@ -384,11 +384,16 @@ public class OkHttpManager {
}
/**
- * 上传应用图标(全局图标库,packageName 作为 form 字段,file 作为文件 part)
+ * 上传应用图标(全局图标库,packageName/appName 作为 form 字段,file 作为文件 part)
+ *
+ * 注意:Retrofit 的 @Part 不允许传 null,appName 为空时统一转为空串,
+ * 由服务端按「未上报」处理(沿用已有名称)。
*/
- public Observable getUploadAppIconObservable(String packageName, MultipartBody.Part part) {
- RequestBody packageNameBody = RequestBody.Companion.create(packageName, MediaType.Companion.parse("text/plain"));
- return schedule(getApiService(SnApi.class).uploadAppIcon(packageNameBody, part));
+ public Observable getUploadAppIconObservable(String packageName, String appName, MultipartBody.Part part) {
+ MediaType textPlain = MediaType.Companion.parse("text/plain");
+ RequestBody packageNameBody = RequestBody.Companion.create(packageName, textPlain);
+ RequestBody appNameBody = RequestBody.Companion.create(appName == null ? "" : appName, textPlain);
+ return schedule(getApiService(SnApi.class).uploadAppIcon(packageNameBody, appNameBody, part));
}
public Observable>> getContactListObservable(BehaviorSubject provider) {
diff --git a/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java b/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java
index 7a4f2c4..581f510 100644
--- a/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java
+++ b/app/src/main/java/com/ttstd/dialer/network/api/SnApi.java
@@ -97,6 +97,7 @@ public interface SnApi {
@POST(UrlConstants.UPLOAD_APP_ICON)
Observable uploadAppIcon(
@Part("packageName") RequestBody packageName,
+ @Part("appName") RequestBody appName,
@Part MultipartBody.Part file
);
diff --git a/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java b/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java
index fb21ca4..25f92fe 100644
--- a/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java
+++ b/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java
@@ -3,22 +3,29 @@ package com.ttstd.dialer.push;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
+import android.text.TextUtils;
import android.util.Log;
-import com.tencent.mmkv.MMKV;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
import com.ttstd.dialer.BuildConfig;
import com.ttstd.dialer.bean.ApkInstalledInfo;
import com.ttstd.dialer.bean.BaseResponse;
import com.ttstd.dialer.bean.DeveloperOptions;
import com.ttstd.dialer.bean.PushMessage;
-import com.ttstd.dialer.config.CommonConfig;
import com.ttstd.dialer.data.repository.SnRepository;
+import com.ttstd.dialer.db.app.AppDatabase;
+import com.ttstd.dialer.db.app.AppIconUpload;
+import com.ttstd.dialer.db.app.AppIconUploadDao;
import com.ttstd.dialer.manager.AppManager;
import com.ttstd.dialer.manager.MapManager;
import com.ttstd.dialer.mdm.DeviceInfoCollector;
import com.ttstd.dialer.mdm.DeviceManagerService;
import com.ttstd.dialer.network.BaseObserver;
import com.ttstd.dialer.network.OkHttpManager;
+import com.ttstd.dialer.utils.ApkUtils;
+import com.ttstd.dialer.utils.SystemUtils;
import com.ttstd.dialer.utils.CmdUtil;
import com.ttstd.dialer.utils.Logger;
import com.ttstd.dialer.utils.RebootUtils;
@@ -42,8 +49,6 @@ import okhttp3.RequestBody;
public class PushExecutor {
private static final String TAG = "PushExecutor";
- private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE);
-
@SuppressLint("StaticFieldLeak")
private static PushExecutor sInstance;
@@ -57,11 +62,16 @@ public class PushExecutor {
private static final String DEVICE_LOCATE = "5";
private static final String DEVICE_RESTORE = "6";
private static final String DEVICE_DEVELOPER = "7";
+ private static final String DEVICE_APP_LAUNCH = "8";
+ private static final String DEVICE_APP_CLEAR_DATA = "9";
+ private static final String DEVICE_APP_UNINSTALL = "10";
+ private static final String DEVICE_APP_STOP = "11";
/**
- * 图标上传去重缓存前缀(记录已上传的 versionCode,应用更新后 versionCode 变化会重新上传)
+ * 服务端业务码:图标已锁定,不可覆盖。
+ * 需与后端 {@code ResultCode.APP_ICON_LOCKED} 保持一致。
*/
- private static final String ICON_UPLOADED_PREFIX = "app_icon_uploaded_";
+ private static final String CODE_APP_ICON_LOCKED = "A0720";
/**
* 最近一次获取的已安装应用列表(供列表上报完成后触发图标上传)
@@ -125,6 +135,18 @@ public class PushExecutor {
break;
case DEVICE_DEVELOPER:
+ break;
+ case DEVICE_APP_LAUNCH:
+ launchApp(extra);
+ break;
+ case DEVICE_APP_CLEAR_DATA:
+ clearAppData(extra);
+ break;
+ case DEVICE_APP_UNINSTALL:
+ uninstallApp(extra);
+ break;
+ case DEVICE_APP_STOP:
+ stopApp(extra);
break;
default:
Logger.e(TAG, "setPushMessage: default");
@@ -212,25 +234,32 @@ public class PushExecutor {
return;
}
- // 过滤:同版本已上传过的不再重复上传
- List toUpload = new ArrayList<>();
- for (ApkInstalledInfo info : apkInstalledInfos) {
- String packageName = info.getPackageName();
- if (packageName == null || packageName.isEmpty()) {
- continue;
- }
- long uploadedVersion = mMMKV.decodeLong(ICON_UPLOADED_PREFIX + packageName, -1L);
- if (uploadedVersion != info.getVersionCode()) {
- toUpload.add(info);
- }
- }
- if (toUpload.isEmpty()) {
- Logger.e(TAG, "uploadAppIcons: 无需上传的图标");
- return;
- }
+ final AppIconUploadDao iconUploadDao = AppDatabase.getDatabase(mContext).appIconUploadDao();
- Observable.fromIterable(toUpload)
+ // 过滤:同版本已上传过的不再重复上传(查询 Room,需在 IO 线程执行)
+ Observable
+ .fromCallable(() -> {
+ List toUpload = new ArrayList<>();
+ for (ApkInstalledInfo info : apkInstalledInfos) {
+ String packageName = info.getPackageName();
+ if (packageName == null || packageName.isEmpty()) {
+ continue;
+ }
+ Long uploadedVersion = iconUploadDao.getUploadedVersionCode(packageName);
+ if (uploadedVersion == null || uploadedVersion != info.getVersionCode()) {
+ toUpload.add(info);
+ }
+ }
+ return toUpload;
+ })
.subscribeOn(Schedulers.io())
+ .flatMap((Function, Observable>) toUpload -> {
+ if (toUpload.isEmpty()) {
+ Logger.e(TAG, "uploadAppIcons: 无需上传的图标");
+ return Observable.empty();
+ }
+ return Observable.fromIterable(toUpload);
+ })
.concatMap((Function>) apkInfo -> {
String packageName = apkInfo.getPackageName();
File iconFile = AppManager.getInstance().getApkIconFile(packageName);
@@ -238,12 +267,24 @@ public class PushExecutor {
return Observable.empty();
}
MultipartBody.Part part = OkHttpManager.getBodyPart("image/png", "file", iconFile);
- return OkHttpManager.getInstance().getUploadAppIconObservable(packageName, part)
+ return OkHttpManager.getInstance()
+ .getUploadAppIconObservable(packageName, apkInfo.getAppName(), part)
+ // getUploadAppIconObservable 内部已 observeOn(mainThread),
+ // 这里必须切回 IO 线程,否则下面的 Room 写入会发生在主线程
+ .observeOn(Schedulers.io())
.doOnNext(response -> {
- // 上传成功后记录版本,避免下次重复上传
- if (response != null && response.isSuccess()) {
- mMMKV.encode(ICON_UPLOADED_PREFIX + packageName, apkInfo.getVersionCode());
+ if (response == null) {
+ return;
+ }
+ // 上传成功、或服务端明确拒绝(图标已锁定)均视为终态:
+ // 记录版本避免下次重复上传。锁定场景若不记录,
+ // 会导致每次轮询都重试并必然失败,形成无意义的重复请求。
+ if (response.isSuccess()) {
+ iconUploadDao.insert(new AppIconUpload(packageName, apkInfo.getVersionCode()));
Logger.e(TAG, "uploadAppIcon success: " + packageName);
+ } else if (CODE_APP_ICON_LOCKED.equals(response.getCode())) {
+ iconUploadDao.insert(new AppIconUpload(packageName, apkInfo.getVersionCode()));
+ Logger.e(TAG, "uploadAppIcon locked, skip retry: " + packageName);
}
});
})
@@ -364,5 +405,93 @@ public class PushExecutor {
});
}
+ /**
+ * 从推送 extra 中解析 {@code package_name} 字段。
+ *
+ * @param extra 推送透传的 extra JSON 字符串
+ * @return 包名;解析失败或字段为空时返回 null
+ */
+ private String parsePackageName(String extra) {
+ if (TextUtils.isEmpty(extra)) {
+ return null;
+ }
+ try {
+ JsonObject jsonObject = JsonParser.parseString(extra).getAsJsonObject();
+ if (jsonObject.has("package_name")) {
+ return jsonObject.get("package_name").getAsString();
+ }
+ } catch (Exception e) {
+ Logger.e(TAG, "parsePackageName: " + e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * 打开指定应用(contentType = 8, appLaunch)。
+ */
+ private void launchApp(String extra) {
+ String packageName = parsePackageName(extra);
+ if (TextUtils.isEmpty(packageName)) {
+ Logger.e(TAG, "launchApp: package_name is empty");
+ return;
+ }
+ boolean launched = ApkUtils.openPackage(mContext, packageName);
+ Logger.e(TAG, "launchApp: " + packageName + " result=" + launched);
+ }
+
+ /**
+ * 清除指定应用的数据(contentType = 9, appClearData)。
+ * 通过 {@code pm clear} 命令实现,需要设备具备 root / shell 权限。
+ */
+ private void clearAppData(String extra) {
+ String packageName = parsePackageName(extra);
+ if (TextUtils.isEmpty(packageName)) {
+ Logger.e(TAG, "clearAppData: package_name is empty");
+ return;
+ }
+ if (!ApkUtils.isInstalled(mContext, packageName)) {
+ Logger.e(TAG, "clearAppData: " + packageName + " not installed");
+ return;
+ }
+ ApkUtils.cleanApplicationUserData(mContext, packageName);
+ Logger.e(TAG, "clearAppData: " + packageName);
+ }
+
+ /**
+ * 卸载指定应用(contentType = 10, appUninstall)。
+ * 通过 {@code pm uninstall} 命令实现,需要设备具备 root / shell 权限。
+ */
+ private void uninstallApp(String extra) {
+ String packageName = parsePackageName(extra);
+ if (TextUtils.isEmpty(packageName)) {
+ Logger.e(TAG, "uninstallApp: package_name is empty");
+ return;
+ }
+ if (!ApkUtils.isInstalled(mContext, packageName)) {
+ Logger.e(TAG, "uninstallApp: " + packageName + " not installed");
+ return;
+ }
+ ApkUtils.uninstall(mContext, packageName);
+ Logger.e(TAG, "uninstallApp: " + packageName );
+ }
+
+ /**
+ * 停止(强制停止)指定应用(contentType = 11, appStop)。
+ * 通过 {@code ActivityManager.forceStopPackage} 实现,需要设备具备相应权限。
+ */
+ private void stopApp(String extra) {
+ String packageName = parsePackageName(extra);
+ if (TextUtils.isEmpty(packageName)) {
+ Logger.e(TAG, "stopApp: package_name is empty");
+ return;
+ }
+ if (!ApkUtils.isInstalled(mContext, packageName)) {
+ Logger.e(TAG, "stopApp: " + packageName + " not installed");
+ return;
+ }
+ SystemUtils.killBackgroundProcesses(mContext, packageName);
+ Logger.e(TAG, "stopApp: " + packageName);
+ }
+
}
diff --git a/app/src/main/java/com/ttstd/dialer/receiver/InstallResultReceiver.java b/app/src/main/java/com/ttstd/dialer/receiver/InstallResultReceiver.java
new file mode 100644
index 0000000..71334bd
--- /dev/null
+++ b/app/src/main/java/com/ttstd/dialer/receiver/InstallResultReceiver.java
@@ -0,0 +1,52 @@
+package com.ttstd.dialer.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInstaller;
+
+import com.ttstd.dialer.utils.Logger;
+
+/**
+ * 接收应用安装或卸载状态的广播接收器
+ */
+public class InstallResultReceiver extends BroadcastReceiver {
+ private static final String TAG = "InstallResultReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null) return;
+
+ int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
+ String packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME);
+ String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
+
+ Logger.i(TAG, "onReceive: status=" + status + ", package=" + packageName + ", msg=" + message);
+
+ switch (status) {
+ case PackageInstaller.STATUS_PENDING_USER_ACTION:
+ // 需要用户确认(如果不是系统应用或未获得静默权限)
+ Intent userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT);
+ if (userAction != null) {
+ userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(userAction);
+ }
+ break;
+ case PackageInstaller.STATUS_SUCCESS:
+ Logger.i(TAG, "Operation successful for: " + packageName);
+ break;
+ case PackageInstaller.STATUS_FAILURE:
+ case PackageInstaller.STATUS_FAILURE_ABORTED:
+ case PackageInstaller.STATUS_FAILURE_BLOCKED:
+ case PackageInstaller.STATUS_FAILURE_CONFLICT:
+ case PackageInstaller.STATUS_FAILURE_INCOMPATIBLE:
+ case PackageInstaller.STATUS_FAILURE_INVALID:
+ case PackageInstaller.STATUS_FAILURE_STORAGE:
+ Logger.e(TAG, "Operation failed for " + packageName + ", status: " + status + ", message: " + message);
+ break;
+ default:
+ Logger.d(TAG, "Operation status unknown for " + packageName + ": " + status);
+ break;
+ }
+ }
+}
diff --git a/app/src/main/java/com/ttstd/dialer/utils/ApkUtils.java b/app/src/main/java/com/ttstd/dialer/utils/ApkUtils.java
index 55a1e70..e5c5c56 100644
--- a/app/src/main/java/com/ttstd/dialer/utils/ApkUtils.java
+++ b/app/src/main/java/com/ttstd/dialer/utils/ApkUtils.java
@@ -1,5 +1,6 @@
package com.ttstd.dialer.utils;
+import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
@@ -7,15 +8,21 @@ import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
+import android.content.pm.PackageInstaller;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.net.Uri;
+import android.os.Build;
import android.text.TextUtils;
+import androidx.annotation.RequiresApi;
+
import com.kongzue.dialogx.dialogs.PopTip;
import com.ttstd.dialer.R;
+import com.ttstd.dialer.receiver.InstallResultReceiver;
+import java.lang.reflect.Method;
import java.util.List;
public class ApkUtils {
@@ -145,4 +152,150 @@ public class ApkUtils {
Logger.e(TAG, "openWeixin storeIntent: " + e1.getMessage());
}
}
+
+ /**
+ * 静默卸载应用
+ *
+ * @param context 上下文
+ * @param packageName 包名
+ * @return 是否成功
+ */
+ public static boolean pmUninstall(Context context, String packageName) {
+ if (TextUtils.isEmpty(packageName) || !isInstalled(context, packageName)) {
+ return false;
+ }
+ CmdUtil.Result result = CmdUtil.execute("pm uninstall " + packageName);
+ Logger.i(TAG, "pmUninstall: " + packageName + " result code: " + result.code);
+ return result.code == 0;
+ }
+
+ /**
+ * 静默清除应用缓存
+ * 注意:pm clear 命令会清除应用的全部数据(包括缓存和数据)
+ *
+ * @param context 上下文
+ * @param packageName 包名
+ * @return 是否成功
+ */
+ public static boolean pmClearCache(Context context, String packageName) {
+ if (TextUtils.isEmpty(packageName) || !isInstalled(context, packageName)) {
+ return false;
+ }
+ CmdUtil.Result result = CmdUtil.execute("pm clear " + packageName);
+ Logger.i(TAG, "pmClearCache: " + packageName + " result code: " + result.code);
+ return result.code == 0;
+ }
+
+ /**
+ * 通过 PackageInstaller 卸载应用
+ *
+ * @param context 上下文
+ * @param packageName 包名
+ */
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ public static void uninstall(Context context, String packageName) {
+ if (TextUtils.isEmpty(packageName) || !isInstalled(context, packageName)) {
+ return;
+ }
+ try {
+ Intent broadcastIntent = new Intent(context, InstallResultReceiver.class);
+ // 注意:由于 PackageInstaller 需要向 Intent 中填充状态 extras,
+ // 在 Android 12 (API 31) 及以上版本,PendingIntent 必须指定为 FLAG_MUTABLE。
+ PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 1,
+ broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
+
+ PackageInstaller packageInstaller = context.getPackageManager().getPackageInstaller();
+ packageInstaller.uninstall(packageName, pendingIntent.getIntentSender());
+ Logger.i(TAG, "uninstall: request sent for " + packageName);
+ } catch (Exception e) {
+ Logger.e(TAG, "uninstall error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 通过反射调用 PackageManager 的隐藏方法 deletePackage 进行静默卸载
+ * 适用于低版本或特定系统环境,需要系统签名
+ *
+ * @param context 上下文
+ * @param packageName 包名
+ */
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ public static void silentUninstallReflection(Context context, String packageName) {
+ if (TextUtils.isEmpty(packageName) || !isInstalled(context, packageName)) {
+ return;
+ }
+ try {
+ PackageManager pm = context.getPackageManager();
+ // deletePackage 方法在不同版本可能在不同位置,通常在 PackageManager 中
+ // 方法签名: void deletePackage(String packageName, IPackageDeleteObserver observer, int flags)
+ Method deletePackage = pm.getClass().getMethod("deletePackage", String.class,
+ Class.forName("android.content.pm.IPackageDeleteObserver"), int.class);
+
+ deletePackage.invoke(pm, packageName, null, 0);
+ Logger.i(TAG, "silentUninstallReflection: request sent for " + packageName);
+ } catch (Exception e) {
+ Logger.e(TAG, "silentUninstallReflection error: " + e.getMessage());
+ }
+ }
+
+ private static final int WAIT_TIME_INCR = 50;
+ private static final int MAX_WAIT_TIME = 10000;
+
+ /**
+ * 清除应用所有数据(通过反射调用隐藏的 clearApplicationUserData 方法)
+ * 需系统签名
+ *
+ * @param context 上下文
+ * @param packageName 包名
+ * @return 是否成功
+ */
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ public static boolean cleanApplicationUserData(Context context, String packageName) {
+ Logger.e(TAG, "cleanApplicationUserData: " + packageName);
+ try {
+ PackageDataObserver observer = new PackageDataObserver();
+ synchronized (observer) {
+ // 反射调用 PackageManager.clearApplicationUserData
+ // 方法签名: void clearApplicationUserData(String packageName, IPackageDataObserver observer)
+ Method method = PackageManager.class.getMethod("clearApplicationUserData", String.class,
+ Class.forName("android.content.pm.IPackageDataObserver"));
+ method.invoke(context.getPackageManager(), packageName, observer);
+
+ long waitTime = 0;
+ while (!observer.isDone() && waitTime < MAX_WAIT_TIME) {
+ observer.wait(WAIT_TIME_INCR);
+ waitTime += WAIT_TIME_INCR;
+ }
+ if (!observer.isDone()) {
+ Logger.e(TAG, "timed out waiting for PackageDataObserver.onRemoveCompleted");
+ return false;
+ }
+ }
+ Logger.d(TAG, "cleanApplicationUserData result for " + packageName + ": " + observer.retValue);
+ return observer.retValue;
+ } catch (Exception e) {
+ Logger.e(TAG, "cleanApplicationUserData error: " + e.getMessage());
+ return false;
+ }
+ }
+
+ private static class PackageDataObserver extends android.content.pm.IPackageDataObserver.Stub {
+ boolean retValue;
+ private boolean done;
+
+ @Override
+ public void onRemoveCompleted(String packageName, boolean succeeded) {
+ synchronized (this) {
+ retValue = succeeded;
+ done = true;
+ this.notifyAll();
+ }
+ }
+
+ public boolean isDone() {
+ return done;
+ }
+ }
+
+
}