feat(push): 应用列表上报完成后自动上传图标到全局图标库

新增应用图标提取(支持矢量图转Bitmap)和上传功能,通过MMKV按包名+版本号去重,避免重复上传。
This commit is contained in:
2026-08-05 14:37:49 +08:00
parent 79b80ba776
commit 75dd131990
5 changed files with 159 additions and 15 deletions

View File

@@ -8,8 +8,11 @@ import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.UserHandle;
import android.util.Log;
import com.tencent.mmkv.MMKV;
@@ -25,6 +28,7 @@ import com.ttstd.dialer.utils.HashUtils;
import com.ttstd.dialer.utils.Logger;
import java.io.File;
import java.io.FileOutputStream;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
@@ -35,14 +39,10 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Function;
import java.util.function.IntConsumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -718,6 +718,59 @@ public class AppManager {
return apkInstalledInfos;
}
/**
* 提取应用图标为 PNG 文件(缓存目录 app_icons/ 下)
*
* @param packageName 应用包名
* @return 图标文件(失败返回 null
*/
public File getApkIconFile(String packageName) {
try {
Drawable drawable = mPackageManager.getApplicationIcon(packageName);
Bitmap bitmap = drawableToBitmap(drawable);
if (bitmap == null) {
return null;
}
File iconDir = new File(mContext.getCacheDir(), "app_icons");
if (!iconDir.exists() && !iconDir.mkdirs()) {
return null;
}
File iconFile = new File(iconDir, packageName.replaceAll("[^A-Za-z0-9._-]", "_") + ".png");
try (FileOutputStream fos = new FileOutputStream(iconFile)) {
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)) {
return null;
}
fos.flush();
}
if (iconFile.length() == 0) {
return null;
}
return iconFile;
} catch (Exception e) {
Log.e(TAG, "getApkIconFile error, packageName: " + packageName, e);
return null;
}
}
/**
* Drawable 转 Bitmap适配矢量图等非 BitmapDrawable 场景)
*/
private Bitmap drawableToBitmap(Drawable drawable) {
if (drawable instanceof BitmapDrawable) {
Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
if (bitmap != null) {
return bitmap;
}
}
int width = drawable.getIntrinsicWidth() > 0 ? drawable.getIntrinsicWidth() : 64;
int height = drawable.getIntrinsicHeight() > 0 ? drawable.getIntrinsicHeight() : 64;
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}
private static class StorageStatsHelper {
@SuppressLint("NewApi")
static void fillStats(Context context, PackageInfo packageInfo, ApkInstalledInfo apkInstalledInfo) {

View File

@@ -383,6 +383,14 @@ public class OkHttpManager {
return schedule(getApiService(SnApi.class).uploadInstallApks(body));
}
/**
* 上传应用图标全局图标库packageName 作为 form 字段file 作为文件 part
*/
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<List<ContactInfo>>> getContactListObservable(BehaviorSubject<ActivityEvent> provider) {
return schedule(getApiService(ContactApi.class).getContactList(), provider);
}

View File

@@ -23,6 +23,8 @@ public class UrlConstants {
public static final String DEVELOPER_OPTIONS = "get_developer_options";
/*上传设备已安装应用列表*/
public static final String UPLOAD_INSTALL_APKS = "upload_install_apks";
/*上传应用图标(全局图标库)*/
public static final String UPLOAD_APP_ICON = "upload_app_icon";
public static final String CONTACT_LIST = "contact/list";

View File

@@ -93,4 +93,11 @@ public interface SnApi {
@Body RequestBody body
);
@Multipart
@POST(UrlConstants.UPLOAD_APP_ICON)
Observable<BaseResponse> uploadAppIcon(
@Part("packageName") RequestBody packageName,
@Part MultipartBody.Part file
);
}

View File

@@ -25,6 +25,7 @@ import com.ttstd.dialer.utils.RebootUtils;
import com.ttstd.iconloader.utils.FileUtils;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
@@ -57,6 +58,16 @@ public class PushExecutor {
private static final String DEVICE_RESTORE = "6";
private static final String DEVICE_DEVELOPER = "7";
/**
* 图标上传去重缓存前缀(记录已上传的 versionCode应用更新后 versionCode 变化会重新上传)
*/
private static final String ICON_UPLOADED_PREFIX = "app_icon_uploaded_";
/**
* 最近一次获取的已安装应用列表(供列表上报完成后触发图标上传)
*/
private List<ApkInstalledInfo> mApkInstalledInfos;
private PushExecutor(Context context) {
if (context == null) {
throw new RuntimeException("Context is NULL");
@@ -95,7 +106,8 @@ public class PushExecutor {
switch (contentType) {
case DEVICE_REFRESH:
getDeviceSettings();
uploadDeviceInfo();
uploadInstallApksInfo();
uploadAllDeviceInfo();
break;
case DEVICE_SCREEN_SNAPSHOT:
screenSnapshot();
@@ -136,7 +148,7 @@ public class PushExecutor {
}, throwable -> Logger.e("getDeveloperOptions", "onError: " + throwable.getMessage()));
}
private void uploadDeviceInfo() {
private void uploadInstallApksInfo() {
Observable
.fromCallable(new Callable<List<ApkInstalledInfo>>() {
@Override
@@ -155,6 +167,7 @@ public class PushExecutor {
.flatMap(new Function<List<ApkInstalledInfo>, Observable<BaseResponse>>() {
@Override
public Observable<BaseResponse> apply(List<ApkInstalledInfo> apkInstalledInfos) throws Throwable {
mApkInstalledInfos = apkInstalledInfos;
RequestBody body = OkHttpManager.convertToRequestBodyjson(apkInstalledInfos);
return OkHttpManager.getInstance().getUploadInstallApksObservable(body);
}
@@ -180,11 +193,72 @@ public class PushExecutor {
@Override
public void onComplete() {
Logger.e("uploadDeviceInfo", "onComplete: ");
// 应用列表上报完成后,上传图标到全局图标库
uploadAppIcons(mApkInstalledInfos);
}
});
uploadAllDeviceInfo();
}
/**
* 批量上传已安装应用图标到全局图标库。
* <p>
* 串行逐个上传concatMap避免一次并发大量请求本地按包名记录已上传的
* versionCode版本未变化的应用不会重复上传应用更新后会自动重新上报图标。
*/
private void uploadAppIcons(final List<ApkInstalledInfo> apkInstalledInfos) {
if (apkInstalledInfos == null || apkInstalledInfos.isEmpty()) {
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;
}
Observable.fromIterable(toUpload)
.subscribeOn(Schedulers.io())
.concatMap((Function<ApkInstalledInfo, Observable<BaseResponse>>) apkInfo -> {
String packageName = apkInfo.getPackageName();
File iconFile = AppManager.getInstance().getApkIconFile(packageName);
if (iconFile == null) {
return Observable.empty();
}
MultipartBody.Part part = OkHttpManager.getBodyPart("image/png", "file", iconFile);
return OkHttpManager.getInstance().getUploadAppIconObservable(packageName, part)
.doOnNext(response -> {
// 上传成功后记录版本,避免下次重复上传
if (response != null && response.isSuccess()) {
mMMKV.encode(ICON_UPLOADED_PREFIX + packageName, apkInfo.getVersionCode());
Logger.e(TAG, "uploadAppIcon success: " + packageName);
}
});
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new BaseObserver<BaseResponse>() {
@Override
public void onSuccess(BaseResponse baseResponse) {
Logger.e("uploadAppIcons", "onSuccess: " + baseResponse);
}
@Override
public void onFailure(Throwable e) {
Logger.e("uploadAppIcons", "onFailure: ", e);
}
});
}
/**
@@ -254,13 +328,13 @@ public class PushExecutor {
String filepath = FileUtils.getCacheDir(mContext) + File.separator + "screenshot_" + System.currentTimeMillis() + ".png";
Observable.fromCallable(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
CmdUtil.Result result = CmdUtil.execute("screencap -p " + filepath);
Logger.e(TAG, "call: " + result.error);
return result.code;
}
})
@Override
public Integer call() throws Exception {
CmdUtil.Result result = CmdUtil.execute("screencap -p " + filepath);
Logger.e(TAG, "call: " + result.error);
return result.code;
}
})
.subscribeOn(Schedulers.io()) // 在子线程执行截图
.flatMap(new Function<Integer, Observable<BaseResponse>>() {
@Override