feat(push): 新增远程应用管理功能及图标上传优化

- 新增远程启动、停止、清除数据、卸载应用能力(contentType 8-11)
- 图标上传改用 Room 持久化去重,支持服务端锁定状态缓存
- 数据库升级至 v3,新增 app_icon_upload 表
- 上传接口新增 appName 字段
- 新增 InstallResultReceiver 用于 PackageInstaller 回调
- 补充 ApkUtils 静默卸载/清除数据等工具方法
This commit is contained in:
TongTongStudio
2026-08-05 21:48:40 +08:00
parent 75dd131990
commit 257f9435eb
9 changed files with 506 additions and 32 deletions

View File

@@ -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">

View File

@@ -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();
}

View 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();
}
}

View File

@@ -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();
}

View File

@@ -384,11 +384,16 @@ public class OkHttpManager {
}
/**
* 上传应用图标全局图标库packageName 作为 form 字段file 作为文件 part
* 上传应用图标全局图标库packageName/appName 作为 form 字段file 作为文件 part
* <p>
* 注意Retrofit 的 @Part 不允许传 nullappName 为空时统一转为空串,
* 由服务端按「未上报」处理(沿用已有名称)。
*/
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) {

View File

@@ -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
);

View 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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}