feat: add native ota progress feedback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user