feat(push): 应用列表上报完成后自动上传图标到全局图标库
新增应用图标提取(支持矢量图转Bitmap)和上传功能,通过MMKV按包名+版本号去重,避免重复上传。
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user