diff --git a/README.md b/README.md
index 0d8f639..1017956 100644
--- a/README.md
+++ b/README.md
@@ -90,12 +90,13 @@ Android APK:
- 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
- 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
-- 当前最新 release 构建版本:`2.1.0`(`versionCode=7`)
+- 当前最新 release 构建版本:`2.1.1`(`versionCode=8`)
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
+- `2.1.1` 已补上原生 OTA 下载链路:关于页会直接请求受保护的 `/api/v1/user/ota/package`,下载完成后可拉起系统安装器
## 本地启动
@@ -203,12 +204,21 @@ cd /Users/kris/code/boss
npm run apk:release
```
+```bash
+cd /Users/kris/code/boss
+npm run aab:release
+```
+
说明:
- `npm run apk:debug` 现在会在 Gradle 构建完成后自动执行 `scripts/publish-apk-to-public.sh`
- `npm run apk:release` 会先准备本机 release keystore,再构建 signed release APK,并发布到 `public/downloads`
- 最新 APK 会同步到 `public/downloads/boss-android-latest.apk`
- 同时也会额外保留一份带版本号的 APK:`public/downloads/boss-android-v{versionName}-{flavor}.apk`
+- `npm run aab:release` 会先准备本机 release keystore,再构建 signed release AAB,并发布到 `public/downloads`
+- 最新 AAB 会同步到 `public/downloads/boss-android-latest.aab`
+- 同时也会额外保留一份带版本号的 AAB:`public/downloads/boss-android-v{versionName}-{flavor}.aab`
+- AAB 归档元数据会写入 `public/downloads/boss-android-latest-aab.json`
- OTA 下载入口固定走受保护的 `GET /api/v1/user/ota/package`
- release 签名文件当前放在本机:
- `android/keystores/boss-release.keystore`
diff --git a/android/app/build.gradle b/android/app/build.gradle
index f053c42..f70d3cc 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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"
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d52a9b4..31d2b9b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,9 @@
+
+
+
-
-
-
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 b32ee97..0eec69a 100644
--- a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java
@@ -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);
+ }
}
diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java
index 30be0a8..ac7b4c5 100644
--- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java
@@ -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;
diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md
index 189c83c..ecb5d4b 100644
--- a/docs/architecture/ai_handoff_index_cn.md
+++ b/docs/architecture/ai_handoff_index_cn.md
@@ -130,7 +130,7 @@
- 邮件:`Postfix + Dovecot`
- Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
- 原生登录恢复:`SharedPreferences + restore token`
-- 当前最新原生 APK:`2.1.0`(`versionCode=7`)
+- 当前最新原生 APK:`2.1.1`(`versionCode=8`)
当前不要误判成已经用了:
diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md
index 97362d6..9ce55b3 100644
--- a/docs/architecture/api_and_service_inventory_cn.md
+++ b/docs/architecture/api_and_service_inventory_cn.md
@@ -138,6 +138,7 @@
- `code`
- 当前行为:
- 当前已临时切到免验证模式,点击登录会直接创建 `17600003315` 的最高管理员会话
+ - 原生 Android 端登录后会持久化 `boss_session + restore token`,用于 30 天登录保持和 OTA / 覆盖安装后的会话恢复
- 当前阶段不会因为账号、密码或验证码为空而拒绝登录
- 校验通过后会写入 `boss_session` Cookie
- 当请求头带 `x-boss-native-app: 1` 时,还会额外返回 `restoreToken`
diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md
index 525890f..d2317d5 100644
--- a/docs/architecture/current_runtime_and_deploy_status_cn.md
+++ b/docs/architecture/current_runtime_and_deploy_status_cn.md
@@ -41,6 +41,11 @@ cd /Users/kris/code/boss
npm run apk:release
```
+```bash
+cd /Users/kris/code/boss
+npm run aab:release
+```
+
```bash
cd /Users/kris/code/boss
./scripts/start-local-agent.sh ./local-agent/config.example.json
@@ -94,17 +99,21 @@ cd /Users/kris/code/boss
- `npm run apk:debug` 当前会自动把最新 APK 发布到 `public/downloads/boss-android-latest.apk`,并写入 `public/downloads/boss-android-latest.json`
- `npm run apk:release` 当前会先准备本机 release keystore,再构建 signed release APK 并发布到 `public/downloads/boss-android-latest.apk`
- APK 发布脚本当前还会额外保留带版本号的安装包:`public/downloads/boss-android-v{versionName}-{flavor}.apk`
+- `npm run aab:release` 当前会先准备本机 release keystore,再构建 signed release AAB 并发布到 `public/downloads/boss-android-latest.aab`
+- AAB 发布脚本当前还会额外保留带版本号的归档包:`public/downloads/boss-android-v{versionName}-{flavor}.aab`
+- AAB 归档元数据会写入 `public/downloads/boss-android-latest-aab.json`
- 当前默认管理员账号:`17600003315`
- 当前默认测试密码:`boss123456`
- 登录页当前是临时免验证入口;Web 登录页和原生 Android 登录页都会直接创建会话
- 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
- 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
- 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
-- 当前最新 release 构建版本:`2.1.0`(`versionCode=7`)
+- 当前最新 release 构建版本:`2.1.1`(`versionCode=8`)
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
- `2.1.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
+- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
## 2. 服务器状态
diff --git a/package.json b/package.json
index ddc0ec6..612d702 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"start": "BOSS_RUNTIME_ROOT=\"$PWD\" BOSS_STATE_FILE=\"$PWD/data/boss-state.json\" node .next/standalone/server.js",
"lint": "eslint",
"apk:debug": "cd android && ./gradlew assembleDebug && cd .. && zsh ./scripts/publish-apk-to-public.sh",
- "apk:release": "zsh ./scripts/build-release-apk.sh"
+ "apk:release": "zsh ./scripts/build-release-apk.sh",
+ "aab:release": "zsh ./scripts/build-release-aab.sh"
},
"dependencies": {
"@capacitor/android": "^8.2.0",
diff --git a/public/downloads/boss-android-latest-aab.json b/public/downloads/boss-android-latest-aab.json
new file mode 100644
index 0000000..a97c014
--- /dev/null
+++ b/public/downloads/boss-android-latest-aab.json
@@ -0,0 +1,11 @@
+{
+ "artifactType": "aab",
+ "fileName": "boss-android-v2.1.1-release.aab",
+ "urlPath": "/downloads/boss-android-v2.1.1-release.aab",
+ "sizeBytes": 2853490,
+ "updatedAt": "2026-03-26T15:51:15Z",
+ "sha256": "c67341ca50d219e3d75baa6c88520c11d475611bed33237710d4518f292779c9",
+ "versionName": "2.1.1",
+ "versionCode": 8,
+ "buildFlavor": "release"
+}
diff --git a/public/downloads/boss-android-latest.aab b/public/downloads/boss-android-latest.aab
new file mode 100644
index 0000000..18b7c05
Binary files /dev/null and b/public/downloads/boss-android-latest.aab differ
diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk
index ec19f21..d29a908 100644
Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ
diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json
index 421e494..a5135fc 100644
--- a/public/downloads/boss-android-latest.json
+++ b/public/downloads/boss-android-latest.json
@@ -1,10 +1,10 @@
{
- "fileName": "boss-android-v2.1.0-release.apk",
+ "fileName": "boss-android-v2.1.1-release.apk",
"urlPath": "/api/v1/user/ota/package",
- "sizeBytes": 3029746,
- "updatedAt": "2026-03-26T15:05:40Z",
- "sha256": "17d048d256969ca08d36f7e95da39e466ef8a68dcf3c15fc04421310b97db16f",
- "versionName": "2.1.0",
- "versionCode": 7,
+ "sizeBytes": 3032808,
+ "updatedAt": "2026-03-26T15:51:10Z",
+ "sha256": "453412d605ad2cd3b1cabf806752d1288a8b23f1c61900b007468a264dda3459",
+ "versionName": "2.1.1",
+ "versionCode": 8,
"buildFlavor": "release"
}
diff --git a/public/downloads/boss-android-v2.1.0-release.aab b/public/downloads/boss-android-v2.1.0-release.aab
new file mode 100644
index 0000000..d703b1b
Binary files /dev/null and b/public/downloads/boss-android-v2.1.0-release.aab differ
diff --git a/public/downloads/boss-android-v2.1.1-release.aab b/public/downloads/boss-android-v2.1.1-release.aab
new file mode 100644
index 0000000..18b7c05
Binary files /dev/null and b/public/downloads/boss-android-v2.1.1-release.aab differ
diff --git a/public/downloads/boss-android-v2.1.1-release.apk b/public/downloads/boss-android-v2.1.1-release.apk
new file mode 100644
index 0000000..d29a908
Binary files /dev/null and b/public/downloads/boss-android-v2.1.1-release.apk differ
diff --git a/scripts/build-release-aab.sh b/scripts/build-release-aab.sh
new file mode 100755
index 0000000..6c06730
--- /dev/null
+++ b/scripts/build-release-aab.sh
@@ -0,0 +1,29 @@
+#!/bin/zsh
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+ANDROID_DIR="$ROOT_DIR/android"
+RELEASE_AAB="$ANDROID_DIR/app/build/outputs/bundle/release/app-release.aab"
+BUILD_GRADLE="$ROOT_DIR/android/app/build.gradle"
+
+VERSION_NAME="$(sed -n 's/.*versionName \"\([^\"]*\)\"/\1/p' "$BUILD_GRADLE" | head -n 1)"
+VERSIONED_RELEASE_AAB="$ANDROID_DIR/app/build/outputs/bundle/release/boss-android-v${VERSION_NAME}-release.aab"
+
+zsh "$ROOT_DIR/scripts/prepare-android-signing.sh"
+
+cd "$ANDROID_DIR"
+./gradlew bundleRelease
+
+cd "$ROOT_DIR"
+cp "$RELEASE_AAB" "$VERSIONED_RELEASE_AAB"
+zsh "$ROOT_DIR/scripts/publish-aab-to-public.sh" "$RELEASE_AAB"
+
+JARSIGNER="$(command -v jarsigner || true)"
+if [[ -n "${JARSIGNER:-}" ]]; then
+ "$JARSIGNER" -verify -verbose -certs "$RELEASE_AAB"
+else
+ echo "jarsigner not found, skipped signature verification output" >&2
+fi
+
+echo "Signed release AAB ready: $RELEASE_AAB"
+echo "Versioned release AAB ready: $VERSIONED_RELEASE_AAB"
diff --git a/scripts/publish-aab-to-public.sh b/scripts/publish-aab-to-public.sh
new file mode 100755
index 0000000..8ce80b1
--- /dev/null
+++ b/scripts/publish-aab-to-public.sh
@@ -0,0 +1,59 @@
+#!/bin/zsh
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+SOURCE_AAB="${1:-$ROOT_DIR/android/app/build/outputs/bundle/release/app-release.aab}"
+TARGET_DIR="$ROOT_DIR/public/downloads"
+TARGET_AAB="$TARGET_DIR/boss-android-latest.aab"
+TARGET_META="$TARGET_DIR/boss-android-latest-aab.json"
+STANDALONE_DIR="$ROOT_DIR/.next/standalone/public/downloads"
+BUILD_GRADLE="$ROOT_DIR/android/app/build.gradle"
+SOURCE_NAME="$(basename "$SOURCE_AAB")"
+BUILD_FLAVOR="release"
+
+if [[ "$SOURCE_NAME" == *debug* ]]; then
+ BUILD_FLAVOR="debug"
+fi
+
+if [[ ! -f "$SOURCE_AAB" ]]; then
+ echo "AAB not found: $SOURCE_AAB" >&2
+ exit 1
+fi
+
+mkdir -p "$TARGET_DIR"
+cp "$SOURCE_AAB" "$TARGET_AAB"
+
+SIZE_BYTES="$(stat -f '%z' "$TARGET_AAB" 2>/dev/null || stat -c '%s' "$TARGET_AAB")"
+UPDATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+SHA256="$(shasum -a 256 "$TARGET_AAB" | awk '{print $1}')"
+VERSION_NAME="$(sed -n 's/.*versionName \"\([^\"]*\)\"/\1/p' "$BUILD_GRADLE" | head -n 1)"
+VERSION_CODE="$(sed -n 's/.*versionCode \([0-9][0-9]*\).*/\1/p' "$BUILD_GRADLE" | head -n 1)"
+VERSIONED_AAB_NAME="boss-android-v${VERSION_NAME}-${BUILD_FLAVOR}.aab"
+VERSIONED_TARGET_AAB="$TARGET_DIR/$VERSIONED_AAB_NAME"
+
+cp "$TARGET_AAB" "$VERSIONED_TARGET_AAB"
+
+cat > "$TARGET_META" <