feat: add native ota delivery and aab release

This commit is contained in:
kris
2026-03-26 23:53:39 +08:00
parent 90cb6b7ff1
commit e6215211c5
18 changed files with 305 additions and 26 deletions

View File

@@ -33,8 +33,8 @@ android {
applicationId "com.hyzq.boss"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 7
versionName "2.1.0"
versionCode 8
versionName "2.1.1"
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -49,7 +52,4 @@
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -1,8 +1,15 @@
package com.hyzq.boss;
import android.app.DownloadManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.widget.Button;
import android.widget.LinearLayout;
@@ -12,13 +19,47 @@ import org.json.JSONArray;
import org.json.JSONObject;
public class AboutActivity extends BossScreenActivity {
private long activeDownloadId = -1L;
private @Nullable JSONObject otaPayload;
private final BroadcastReceiver otaDownloadReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (!DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) {
return;
}
long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L);
if (downloadId <= 0 || downloadId != activeDownloadId) {
return;
}
activeDownloadId = -1L;
handleCompletedDownload(downloadId);
}
};
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("关于 / OTA", "原生版本中心");
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(otaDownloadReceiver, filter);
}
reload();
}
@Override
protected void onDestroy() {
try {
unregisterReceiver(otaDownloadReceiver);
} catch (IllegalArgumentException ignored) {
// already unregistered
}
super.onDestroy();
}
@Override
protected void reload() {
setRefreshing(true);
@@ -46,6 +87,7 @@ public class AboutActivity extends BossScreenActivity {
private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) {
replaceContent();
otaPayload = ota;
if (user != null) {
appendContent(BossUi.buildCard(
this,
@@ -77,11 +119,8 @@ public class AboutActivity extends BossScreenActivity {
Button apply = BossUi.buildSecondaryButton(this, "登记应用 OTA");
apply.setOnClickListener(v -> performOtaAction("apply"));
actionCard.addView(apply);
Button download = BossUi.buildSecondaryButton(this, "下载最新 APK");
download.setOnClickListener(v -> {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apiClient.getProtectedOtaPackageUrl()));
startActivity(intent);
});
Button download = BossUi.buildSecondaryButton(this, "应用内下载 APK");
download.setOnClickListener(v -> downloadLatestApk());
actionCard.addView(download);
appendContent(actionCard);
@@ -119,4 +158,87 @@ public class AboutActivity extends BossScreenActivity {
}
});
}
private void downloadLatestApk() {
executor.execute(() -> {
try {
BossApiClient.ApiResponse session = apiClient.getSession();
if (!session.ok()) {
session = apiClient.restoreSession();
}
if (!session.ok()) {
throw new IllegalStateException("SESSION_UNAVAILABLE");
}
runOnUiThread(this::enqueueOtaDownload);
} catch (Exception error) {
runOnUiThread(() -> showMessage("准备下载失败:" + error.getMessage()));
}
});
}
private void enqueueOtaDownload() {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager == null) {
showMessage("当前设备不支持 DownloadManager");
return;
}
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
String fileName = availableRelease == null
? "boss-android-latest.apk"
: availableRelease.optString("packageFileName", "boss-android-latest.apk");
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apiClient.getProtectedOtaPackageUrl()));
request.setTitle(fileName);
request.setDescription("Boss 原生客户端更新包");
request.setMimeType("application/vnd.android.package-archive");
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
request.setVisibleInDownloadsUi(true);
request.addRequestHeader("Cookie", apiClient.getSessionCookie());
request.addRequestHeader("x-boss-native-app", "1");
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
activeDownloadId = manager.enqueue(request);
showMessage("已开始下载,完成后会自动拉起安装。");
}
private void handleCompletedDownload(long downloadId) {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager == null) {
showMessage("无法读取下载状态");
return;
}
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
try (android.database.Cursor cursor = manager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
showMessage("下载完成,但无法读取文件信息");
return;
}
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
if (status != DownloadManager.STATUS_SUCCESSFUL) {
showMessage("下载未成功完成");
return;
}
}
Uri apkUri = manager.getUriForDownloadedFile(downloadId);
if (apkUri == null) {
showMessage("下载完成,但找不到安装包");
return;
}
if (!getPackageManager().canRequestPackageInstalls()) {
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
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(apkUri, "application/vnd.android.package-archive");
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(installIntent);
}
}

View File

@@ -44,6 +44,9 @@ public class MainActivity extends AppCompatActivity {
private LinearLayout screenContent;
private String activeTab = "conversations";
private String preferredEntryTab = "conversations";
private boolean explicitTabRequest = false;
private boolean userSelectedTab = false;
private @Nullable JSONObject sessionData;
private @Nullable JSONObject otaData;
private @Nullable JSONArray conversationsData;
@@ -66,10 +69,24 @@ public class MainActivity extends AppCompatActivity {
setIntent(intent);
applyInitialTab(intent);
if (contentPanel.getVisibility() == View.VISIBLE) {
maybeApplyPreferredEntry();
renderCurrentTab();
}
}
@Override
public void onBackPressed() {
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false);
return;
}
if (contentPanel.getVisibility() == View.VISIBLE) {
moveTaskToBack(true);
return;
}
super.onBackPressed();
}
@Override
protected void onDestroy() {
executor.shutdownNow();
@@ -97,16 +114,18 @@ public class MainActivity extends AppCompatActivity {
loginButton.setOnClickListener(v -> performAutoLogin());
backButton.setVisibility(View.GONE);
refreshButton.setOnClickListener(v -> refreshCurrentTab());
tabConversations.setOnClickListener(v -> switchTab("conversations"));
tabDevices.setOnClickListener(v -> switchTab("devices"));
tabMe.setOnClickListener(v -> switchTab("me"));
tabConversations.setOnClickListener(v -> setActiveTab("conversations", true));
tabDevices.setOnClickListener(v -> setActiveTab("devices", true));
tabMe.setOnClickListener(v -> setActiveTab("me", true));
screenRefresh.setOnRefreshListener(this::refreshCurrentTab);
}
private void applyInitialTab(@Nullable Intent intent) {
explicitTabRequest = false;
String requested = intent == null ? null : intent.getStringExtra(EXTRA_INITIAL_TAB);
if ("devices".equals(requested) || "me".equals(requested) || "conversations".equals(requested)) {
activeTab = requested;
explicitTabRequest = true;
}
}
@@ -182,7 +201,8 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse conversations = apiClient.getConversations();
BossApiClient.ApiResponse devices = apiClient.getDevices();
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
if (!conversations.ok() || !devices.ok() || !ota.ok()) {
BossApiClient.ApiResponse settings = apiClient.getSettings();
if (!conversations.ok() || !devices.ok() || !ota.ok() || !settings.ok()) {
throw new IOException("API_REFRESH_FAILED");
}
@@ -192,6 +212,11 @@ public class MainActivity extends AppCompatActivity {
conversationsData = conversations.json.optJSONArray("conversations");
devicesData = devices.json.optJSONArray("devices");
otaData = ota.json;
JSONObject settingsPayload = settings.json.optJSONObject("settings");
if (settingsPayload != null) {
preferredEntryTab = settingsPayload.optString("preferredEntryPoint", "conversations");
}
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
});
@@ -217,7 +242,7 @@ public class MainActivity extends AppCompatActivity {
private void showContent() {
loginPanel.setVisibility(View.GONE);
contentPanel.setVisibility(View.VISIBLE);
switchTab(activeTab);
setActiveTab(activeTab, false);
}
private void setLoginLoading(boolean loading, String hint) {
@@ -227,12 +252,24 @@ public class MainActivity extends AppCompatActivity {
loginHint.setText(hint);
}
private void switchTab(String tab) {
private void setActiveTab(String tab, boolean fromUser) {
activeTab = tab;
if (fromUser) {
userSelectedTab = true;
}
updateTabStyles();
renderCurrentTab();
}
private void maybeApplyPreferredEntry() {
if (explicitTabRequest || userSelectedTab) {
return;
}
if ("devices".equals(preferredEntryTab) || "me".equals(preferredEntryTab) || "conversations".equals(preferredEntryTab)) {
activeTab = preferredEntryTab;
}
}
private void renderCurrentTab() {
if (contentPanel.getVisibility() != View.VISIBLE) {
return;