From 257f9435eb298212a19ab0113c36f0469d1670c7 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Wed, 5 Aug 2026 21:48:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(push):=20=E6=96=B0=E5=A2=9E=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E5=BA=94=E7=94=A8=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=8F=8A=E5=9B=BE=E6=A0=87=E4=B8=8A=E4=BC=A0=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增远程启动、停止、清除数据、卸载应用能力(contentType 8-11) - 图标上传改用 Room 持久化去重,支持服务端锁定状态缓存 - 数据库升级至 v3,新增 app_icon_upload 表 - 上传接口新增 appName 字段 - 新增 InstallResultReceiver 用于 PackageInstaller 回调 - 补充 ApkUtils 静默卸载/清除数据等工具方法 --- app/src/main/AndroidManifest.xml | 4 + .../com/ttstd/dialer/db/app/AppDatabase.java | 26 ++- .../ttstd/dialer/db/app/AppIconUpload.java | 73 +++++++ .../ttstd/dialer/db/app/AppIconUploadDao.java | 33 ++++ .../ttstd/dialer/network/OkHttpManager.java | 13 +- .../com/ttstd/dialer/network/api/SnApi.java | 1 + .../com/ttstd/dialer/push/PushExecutor.java | 183 +++++++++++++++--- .../receiver/InstallResultReceiver.java | 52 +++++ .../java/com/ttstd/dialer/utils/ApkUtils.java | 153 +++++++++++++++ 9 files changed, 506 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/ttstd/dialer/db/app/AppIconUpload.java create mode 100644 app/src/main/java/com/ttstd/dialer/db/app/AppIconUploadDao.java create mode 100644 app/src/main/java/com/ttstd/dialer/receiver/InstallResultReceiver.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bf376cf..19e32c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -224,6 +224,10 @@ android:name=".receiver.AlarmReceiver" android:exported="false" /> + + 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; + } + } + + }