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

@@ -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`

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;

View File

@@ -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`
当前不要误判成已经用了:

View File

@@ -138,6 +138,7 @@
- `code`
- 当前行为:
- 当前已临时切到免验证模式,点击登录会直接创建 `17600003315` 的最高管理员会话
- 原生 Android 端登录后会持久化 `boss_session + restore token`,用于 30 天登录保持和 OTA / 覆盖安装后的会话恢复
- 当前阶段不会因为账号、密码或验证码为空而拒绝登录
- 校验通过后会写入 `boss_session` Cookie
- 当请求头带 `x-boss-native-app: 1` 时,还会额外返回 `restoreToken`

View File

@@ -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. 服务器状态

View File

@@ -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",

View File

@@ -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"
}

Binary file not shown.

View File

@@ -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"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

29
scripts/build-release-aab.sh Executable file
View File

@@ -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"

View File

@@ -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" <<EOF
{
"artifactType": "aab",
"fileName": "$VERSIONED_AAB_NAME",
"urlPath": "/downloads/$VERSIONED_AAB_NAME",
"sizeBytes": $SIZE_BYTES,
"updatedAt": "$UPDATED_AT",
"sha256": "$SHA256",
"versionName": "$VERSION_NAME",
"versionCode": $VERSION_CODE,
"buildFlavor": "$BUILD_FLAVOR"
}
EOF
if [[ -d "$ROOT_DIR/.next/standalone/public" ]]; then
mkdir -p "$STANDALONE_DIR"
cp "$TARGET_AAB" "$STANDALONE_DIR/boss-android-latest.aab"
cp "$VERSIONED_TARGET_AAB" "$STANDALONE_DIR/$VERSIONED_AAB_NAME"
cp "$TARGET_META" "$STANDALONE_DIR/boss-android-latest-aab.json"
fi
echo "Published AAB to $TARGET_AAB"
echo "Published versioned AAB to $VERSIONED_TARGET_AAB"
echo "Metadata written to $TARGET_META"