diff --git a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java index f4e192c..3727bf8 100644 --- a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java @@ -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; + } + } } diff --git a/android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java b/android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java new file mode 100644 index 0000000..cf79fa3 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java @@ -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)); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java b/android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java new file mode 100644 index 0000000..947461f --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java @@ -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)); + } +}