feat: add native ota progress feedback

This commit is contained in:
kris
2026-03-27 15:39:19 +08:00
parent ae571a76ff
commit 6559ad5bce
3 changed files with 376 additions and 3 deletions

View File

@@ -9,7 +9,10 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
@@ -17,8 +20,32 @@ import org.json.JSONArray;
import org.json.JSONObject;
public class AboutActivity extends BossScreenActivity {
private static final long OTA_PROGRESS_POLL_INTERVAL_MS = 1_000L;
private static final String OTA_UI_PREFS = "boss_native_client";
private static final String KEY_ACTIVE_DOWNLOAD_ID = "ota_active_download_id";
private static final String KEY_COMPLETED_DOWNLOAD_ID = "ota_completed_download_id";
private static final String KEY_LAST_DOWNLOAD_FILE_NAME = "ota_last_download_file_name";
private static final String KEY_LAST_DOWNLOAD_STATUS = "ota_last_download_status";
private long activeDownloadId = -1L;
private long completedDownloadId = -1L;
private @Nullable JSONObject otaPayload;
private @Nullable LinearLayout otaDownloadStateSection;
private @Nullable Uri downloadedApkUri;
private @Nullable String lastDownloadFileName;
private int lastDownloadStatus = -1;
private long lastDownloadedBytes = 0L;
private long lastTotalBytes = -1L;
private final Handler otaProgressHandler = new Handler(Looper.getMainLooper());
private final Runnable otaProgressPoller = new Runnable() {
@Override
public void run() {
refreshDownloadStateSection();
if (activeDownloadId > 0) {
otaProgressHandler.postDelayed(this, OTA_PROGRESS_POLL_INTERVAL_MS);
}
}
};
private final BroadcastReceiver otaDownloadReceiver = new BroadcastReceiver() {
@Override
@@ -30,7 +57,6 @@ public class AboutActivity extends BossScreenActivity {
if (downloadId <= 0 || downloadId != activeDownloadId) {
return;
}
activeDownloadId = -1L;
handleCompletedDownload(downloadId);
}
};
@@ -39,6 +65,7 @@ public class AboutActivity extends BossScreenActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("关于", "版本与更新");
restoreDownloadUiState();
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
@@ -50,6 +77,7 @@ public class AboutActivity extends BossScreenActivity {
@Override
protected void onDestroy() {
otaProgressHandler.removeCallbacks(otaProgressPoller);
try {
unregisterReceiver(otaDownloadReceiver);
} catch (IllegalArgumentException ignored) {
@@ -119,6 +147,10 @@ public class AboutActivity extends BossScreenActivity {
appendContent(BossUi.buildMenuRow(this, "检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check")));
appendContent(BossUi.buildMenuRow(this, "登记应用 OTA", "把当前已应用版本写回服务端", null, v -> performOtaAction("apply")));
appendContent(BossUi.buildMenuRow(this, "应用内下载 APK", "下载最新安装包并拉起系统安装器", null, v -> downloadLatestApk()));
otaDownloadStateSection = new LinearLayout(this);
otaDownloadStateSection.setOrientation(LinearLayout.VERTICAL);
appendContent(otaDownloadStateSection);
refreshDownloadStateSection();
appendContent(BossUi.buildMenuRow(
this,
WechatSurfaceMapper.advancedEntryTitle(),
@@ -203,7 +235,17 @@ public class AboutActivity extends BossScreenActivity {
request.addRequestHeader("x-boss-native-app", "1");
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
downloadedApkUri = null;
lastDownloadFileName = fileName;
lastDownloadStatus = DownloadManager.STATUS_PENDING;
lastDownloadedBytes = 0L;
lastTotalBytes = -1L;
completedDownloadId = -1L;
activeDownloadId = manager.enqueue(request);
persistDownloadUiState();
otaProgressHandler.removeCallbacks(otaProgressPoller);
otaProgressHandler.post(otaProgressPoller);
refreshDownloadStateSection();
showMessage("已开始下载,完成后会自动拉起安装。");
}
@@ -218,10 +260,23 @@ public class AboutActivity extends BossScreenActivity {
try (android.database.Cursor cursor = manager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
showMessage("下载完成,但无法读取文件信息");
activeDownloadId = -1L;
completedDownloadId = -1L;
lastDownloadStatus = DownloadManager.STATUS_FAILED;
persistDownloadUiState();
refreshDownloadStateSection();
return;
}
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
if (status != DownloadManager.STATUS_SUCCESSFUL) {
lastDownloadStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
lastDownloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
lastTotalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
if (lastDownloadStatus != DownloadManager.STATUS_SUCCESSFUL) {
activeDownloadId = -1L;
completedDownloadId = -1L;
downloadedApkUri = null;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
showMessage("下载未成功完成");
return;
}
@@ -229,10 +284,23 @@ public class AboutActivity extends BossScreenActivity {
Uri apkUri = manager.getUriForDownloadedFile(downloadId);
if (apkUri == null) {
activeDownloadId = -1L;
completedDownloadId = -1L;
lastDownloadStatus = DownloadManager.STATUS_FAILED;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
showMessage("下载完成,但找不到安装包");
return;
}
activeDownloadId = -1L;
completedDownloadId = downloadId;
downloadedApkUri = apkUri;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
if (!getPackageManager().canRequestPackageInstalls()) {
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
@@ -246,4 +314,178 @@ public class AboutActivity extends BossScreenActivity {
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(installIntent);
}
private void refreshDownloadStateSection() {
if (otaDownloadStateSection == null) {
return;
}
otaDownloadStateSection.removeAllViews();
OtaDownloadStateMapper.UiState uiState = resolveDownloadUiState();
if (uiState == null) {
return;
}
otaDownloadStateSection.addView(BossUi.buildListRow(
this,
uiState.title,
uiState.subtitle,
uiState.meta,
uiState.badge,
null
));
if (uiState.actionKind != OtaDownloadStateMapper.ActionKind.NONE) {
otaDownloadStateSection.addView(BossUi.buildMenuRow(
this,
uiState.actionLabel,
uiState.subtitle,
null,
v -> performDownloadStateAction(uiState.actionKind)
));
}
}
@Nullable
private OtaDownloadStateMapper.UiState resolveDownloadUiState() {
String fileName = resolveDownloadFileName();
if (activeDownloadId > 0) {
DownloadProgressSnapshot snapshot = queryDownloadProgress(activeDownloadId);
if (snapshot != null) {
lastDownloadStatus = snapshot.status;
lastDownloadedBytes = snapshot.bytesDownloaded;
lastTotalBytes = snapshot.totalBytes;
boolean hasKnownTotal = snapshot.totalBytes > 0;
int percent = hasKnownTotal
? (int) Math.round((snapshot.bytesDownloaded * 100.0d) / snapshot.totalBytes)
: 0;
if (snapshot.status == DownloadManager.STATUS_RUNNING
|| snapshot.status == DownloadManager.STATUS_PENDING
|| snapshot.status == DownloadManager.STATUS_PAUSED) {
return OtaDownloadStateMapper.active(fileName, percent, hasKnownTotal, snapshot.bytesDownloaded, snapshot.totalBytes);
}
if (snapshot.status == DownloadManager.STATUS_FAILED) {
activeDownloadId = -1L;
completedDownloadId = -1L;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
return OtaDownloadStateMapper.failed(fileName);
}
}
}
if (lastDownloadStatus == DownloadManager.STATUS_FAILED) {
return OtaDownloadStateMapper.failed(fileName);
}
if (downloadedApkUri != null) {
if (!getPackageManager().canRequestPackageInstalls()) {
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
}
return OtaDownloadStateMapper.readyToInstall(fileName);
}
return null;
}
private void performDownloadStateAction(OtaDownloadStateMapper.ActionKind actionKind) {
switch (actionKind) {
case RETRY_DOWNLOAD:
downloadLatestApk();
break;
case OPEN_INSTALL_PERMISSION:
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
break;
case INSTALL_APK:
installDownloadedApk();
break;
case NONE:
default:
break;
}
}
private void installDownloadedApk() {
if (downloadedApkUri == null) {
showMessage("当前没有可安装的更新包");
return;
}
if (!getPackageManager().canRequestPackageInstalls()) {
showMessage("请先开启安装未知来源应用权限");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
return;
}
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.setDataAndType(downloadedApkUri, "application/vnd.android.package-archive");
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(installIntent);
}
private String resolveDownloadFileName() {
if (lastDownloadFileName != null && !lastDownloadFileName.isEmpty()) {
return lastDownloadFileName;
}
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
if (availableRelease != null) {
return availableRelease.optString("packageFileName", "boss-android-latest.apk");
}
return "boss-android-latest.apk";
}
private void restoreDownloadUiState() {
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
completedDownloadId = prefs.getLong(KEY_COMPLETED_DOWNLOAD_ID, -1L);
lastDownloadFileName = prefs.getString(KEY_LAST_DOWNLOAD_FILE_NAME, null);
lastDownloadStatus = prefs.getInt(KEY_LAST_DOWNLOAD_STATUS, -1);
if (completedDownloadId > 0) {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager != null) {
downloadedApkUri = manager.getUriForDownloadedFile(completedDownloadId);
}
}
if (activeDownloadId > 0) {
otaProgressHandler.removeCallbacks(otaProgressPoller);
otaProgressHandler.post(otaProgressPoller);
}
}
private void persistDownloadUiState() {
getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_ACTIVE_DOWNLOAD_ID, activeDownloadId)
.putLong(KEY_COMPLETED_DOWNLOAD_ID, completedDownloadId)
.putString(KEY_LAST_DOWNLOAD_FILE_NAME, lastDownloadFileName)
.putInt(KEY_LAST_DOWNLOAD_STATUS, lastDownloadStatus)
.apply();
}
@Nullable
private DownloadProgressSnapshot queryDownloadProgress(long downloadId) {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager == null) {
return null;
}
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
try (android.database.Cursor cursor = manager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
return null;
}
return new DownloadProgressSnapshot(
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)),
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)),
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
);
}
}
private static final class DownloadProgressSnapshot {
private final int status;
private final long bytesDownloaded;
private final long totalBytes;
private DownloadProgressSnapshot(int status, long bytesDownloaded, long totalBytes) {
this.status = status;
this.bytesDownloaded = bytesDownloaded;
this.totalBytes = totalBytes;
}
}
}

View File

@@ -0,0 +1,114 @@
package com.hyzq.boss;
public final class OtaDownloadStateMapper {
public enum ActionKind {
NONE,
RETRY_DOWNLOAD,
OPEN_INSTALL_PERMISSION,
INSTALL_APK
}
public static final class UiState {
public final String title;
public final String subtitle;
public final String meta;
public final String badge;
public final String actionLabel;
public final ActionKind actionKind;
public UiState(
String title,
String subtitle,
String meta,
String badge,
String actionLabel,
ActionKind actionKind
) {
this.title = title;
this.subtitle = subtitle;
this.meta = meta;
this.badge = badge;
this.actionLabel = actionLabel;
this.actionKind = actionKind;
}
}
private OtaDownloadStateMapper() {}
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
if (!hasKnownTotal) {
return "正在准备下载";
}
int safePercent = Math.max(0, Math.min(100, percent));
return "已下载 " + safePercent + "%";
}
public static UiState active(String fileName, int percent, boolean hasKnownTotal, long bytesDownloaded, long totalBytes) {
return new UiState(
"安装包下载中",
toProgressLabel(percent, hasKnownTotal),
buildMeta(fileName, bytesDownloaded, totalBytes),
"NOW",
null,
ActionKind.NONE
);
}
public static UiState failed(String fileName) {
return new UiState(
"安装包下载失败",
"下载未成功完成,可以直接重试",
fileName,
"FAIL",
"重试下载",
ActionKind.RETRY_DOWNLOAD
);
}
public static UiState waitingInstallPermission(String fileName) {
return new UiState(
"等待安装授权",
"请先允许 Boss 安装未知来源应用",
fileName,
"STEP",
"前往授权",
ActionKind.OPEN_INSTALL_PERMISSION
);
}
public static UiState readyToInstall(String fileName) {
return new UiState(
"安装包已就绪",
"下载完成,可继续拉起系统安装",
fileName,
"DONE",
"继续安装",
ActionKind.INSTALL_APK
);
}
private static String buildMeta(String fileName, long bytesDownloaded, long totalBytes) {
if (bytesDownloaded <= 0 && totalBytes <= 0) {
return fileName;
}
StringBuilder builder = new StringBuilder(fileName);
builder.append(" · ").append(formatBytes(bytesDownloaded));
if (totalBytes > 0) {
builder.append(" / ").append(formatBytes(totalBytes));
}
return builder.toString();
}
private static String formatBytes(long bytes) {
if (bytes <= 0) {
return "0 B";
}
if (bytes < 1024) {
return bytes + " B";
}
if (bytes < 1024L * 1024L) {
return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024.0d);
}
return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0d * 1024.0d));
}
}

View File

@@ -0,0 +1,17 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class OtaDownloadStateMapperTest {
@Test
public void toProgressLabel_formatsKnownProgress() {
assertEquals("已下载 50%", OtaDownloadStateMapper.toProgressLabel(50, true));
}
@Test
public void toProgressLabel_handlesUnknownProgress() {
assertEquals("正在准备下载", OtaDownloadStateMapper.toProgressLabel(0, false));
}
}