feat(push): 新增远程应用管理功能及图标上传优化
- 新增远程启动、停止、清除数据、卸载应用能力(contentType 8-11) - 图标上传改用 Room 持久化去重,支持服务端锁定状态缓存 - 数据库升级至 v3,新增 app_icon_upload 表 - 上传接口新增 appName 字段 - 新增 InstallResultReceiver 用于 PackageInstaller 回调 - 补充 ApkUtils 静默卸载/清除数据等工具方法
This commit is contained in:
@@ -224,6 +224,10 @@
|
||||
android:name=".receiver.AlarmReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.InstallResultReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.HourlyChimeReceiver"
|
||||
android:exported="false">
|
||||
|
||||
@@ -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} 中的排序等用户数据可完整保留。
|
||||
* <p>
|
||||
* 建表语句必须与 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();
|
||||
}
|
||||
|
||||
73
app/src/main/java/com/ttstd/dialer/db/app/AppIconUpload.java
Normal file
73
app/src/main/java/com/ttstd/dialer/db/app/AppIconUpload.java
Normal file
@@ -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;
|
||||
|
||||
/**
|
||||
* 图标上传去重记录。
|
||||
* <p>
|
||||
* 以包名为主键,记录该应用最近一次上传成功的 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();
|
||||
}
|
||||
}
|
||||
@@ -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<AppIconUpload> 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();
|
||||
}
|
||||
@@ -384,11 +384,16 @@ public class OkHttpManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传应用图标(全局图标库,packageName 作为 form 字段,file 作为文件 part)
|
||||
* 上传应用图标(全局图标库,packageName/appName 作为 form 字段,file 作为文件 part)
|
||||
* <p>
|
||||
* 注意:Retrofit 的 @Part 不允许传 null,appName 为空时统一转为空串,
|
||||
* 由服务端按「未上报」处理(沿用已有名称)。
|
||||
*/
|
||||
public Observable<BaseResponse> 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<BaseResponse> 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<BaseResponse<List<ContactInfo>>> getContactListObservable(BehaviorSubject<ActivityEvent> provider) {
|
||||
|
||||
@@ -97,6 +97,7 @@ public interface SnApi {
|
||||
@POST(UrlConstants.UPLOAD_APP_ICON)
|
||||
Observable<BaseResponse> uploadAppIcon(
|
||||
@Part("packageName") RequestBody packageName,
|
||||
@Part("appName") RequestBody appName,
|
||||
@Part MultipartBody.Part file
|
||||
);
|
||||
|
||||
|
||||
@@ -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<ApkInstalledInfo> 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<ApkInstalledInfo> 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<List<ApkInstalledInfo>, Observable<ApkInstalledInfo>>) toUpload -> {
|
||||
if (toUpload.isEmpty()) {
|
||||
Logger.e(TAG, "uploadAppIcons: 无需上传的图标");
|
||||
return Observable.empty();
|
||||
}
|
||||
return Observable.fromIterable(toUpload);
|
||||
})
|
||||
.concatMap((Function<ApkInstalledInfo, Observable<BaseResponse>>) 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user