feat: add app update flow and apk deploy automation

This commit is contained in:
JOJO 2026-04-06 02:10:52 +08:00
parent 6f2f854f6d
commit fafb0d35ad
9 changed files with 624 additions and 11 deletions

View File

@ -0,0 +1,33 @@
# App 更新说明
# 1.0.6
- 应用图标替换为指定 JPG 图片资源
- 更新 AndroidManifest 图标引用icon / roundIcon
# 1.0.5
- 手机端「工作文件」侧栏最大宽度由 86% 提升到 100%
- 保持默认 60% 起始宽度,并在内容需要时继续自适应扩展
# 1.0.4
- 调整手机端「工作文件」侧栏宽度策略:默认按 60% 屏宽展开
- 当内容较宽时可自适应扩展侧栏宽度(上限 86%),减少内容被截断
- 保持「对话记录」侧栏宽度逻辑不变
# 1.0.3
- 修复 App 端版本读取时机,避免显示“当前版本未知(?)”
- 进一步隐藏 App 端页面滚动条(保留页面内部可滚动区域)
# 1.0.2
- 修复 App 端“当前版本显示未知”问题(版本号改为优先从 Android JS Bridge 读取)
- 优化“软件更新”页更新日志展示:长内容在卡片内滚动,不再无限拉长
- 强化 App 页面滚动隐藏补丁,减少出现系统滚动条的情况
- 更新上传脚本:支持自动打包 + 上传,并同步版本号与更新说明
# 1.0.1
- 移除 App 页面上下滚动条(仅 App 壳生效)
- 优化 App 内页面样式补丁与资源版本参数
## 1.0.0
- 首次发布 Android WebView 壳应用
- 适配移动端抽屉宽度与状态栏主题联动
- 新增个人空间「软件更新」页(仅 App 端显示)

View File

@ -3,6 +3,11 @@ plugins {
id("org.jetbrains.kotlin.android")
}
fun resolveConfig(name: String): String? {
return (project.findProperty(name) as String?)?.takeIf { it.isNotBlank() }
?: System.getenv(name)?.takeIf { it.isNotBlank() }
}
android {
namespace = "com.cyjai.agent"
compileSdk = 35
@ -11,15 +16,32 @@ android {
applicationId = "com.cyjai.agent"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 7
versionName = "1.0.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
val storeFilePath = resolveConfig("ANDROID_KEYSTORE_PATH")
val storePass = resolveConfig("ANDROID_KEYSTORE_PASSWORD")
val keyAliasValue = resolveConfig("ANDROID_KEY_ALIAS")
val keyPass = resolveConfig("ANDROID_KEY_PASSWORD")
if (!storeFilePath.isNullOrBlank()) {
storeFile = file(storeFilePath)
}
storePassword = storePass
keyAlias = keyAliasValue
keyPassword = keyPass
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"

View File

@ -5,10 +5,10 @@
<application
android:allowBackup="true"
android:icon="@drawable/ic_bot_icon"
android:icon="@drawable/ic_launcher_photo"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@drawable/ic_bot_icon"
android:roundIcon="@drawable/ic_launcher_photo"
android:supportsRtl="true"
android:theme="@style/Theme.AgentWebView"
android:usesCleartextTraffic="false">

View File

@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.ViewGroup
import android.view.View
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
@ -37,6 +38,9 @@ class MainActivity : AppCompatActivity() {
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
webView.isVerticalScrollBarEnabled = false
webView.isHorizontalScrollBarEnabled = false
webView.overScrollMode = View.OVER_SCROLL_NEVER
setContentView(webView)
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
@ -75,10 +79,16 @@ class MainActivity : AppCompatActivity() {
super.onPageFinished(view, url)
injectThemeObserver()
injectMobileOverlayWidthPatch()
injectNoPageScrollPatch()
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return false
if (isApkUrl(url)) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
return true
}
return !url.startsWith(HOME_URL)
}
}
@ -196,10 +206,57 @@ class MainActivity : AppCompatActivity() {
(function() {
var styleId = '__android_mobile_overlay_width_patch';
var css = [
'.mobile-panel-sheet.mobile-panel-sheet--conversation,',
'.mobile-panel-sheet.mobile-panel-sheet--workspace {',
'.mobile-panel-sheet.mobile-panel-sheet--conversation {',
' width: min(420px, 65vw) !important;',
' max-width: 65vw !important;',
'}',
'.mobile-panel-sheet.mobile-panel-sheet--workspace {',
' width: fit-content !important;',
' min-width: min(420px, 60vw) !important;',
' max-width: 100vw !important;',
'}'
].join('\n');
var existing = document.getElementById(styleId);
if (existing) {
existing.textContent = css;
return;
}
var style = document.createElement('style');
style.id = styleId;
style.type = 'text/css';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
""".trimIndent()
webView.evaluateJavascript(script, null)
}
private fun injectNoPageScrollPatch() {
val script = """
(function() {
var styleId = '__android_no_page_scroll_patch';
var css = [
'html, body {',
' overflow: hidden !important;',
' height: 100% !important;',
' max-height: 100% !important;',
'}',
'body {',
' position: fixed !important;',
' width: 100% !important;',
' inset: 0 !important;',
'}',
'* {',
' scrollbar-width: none !important;',
'}',
'*::-webkit-scrollbar {',
' width: 0 !important;',
' height: 0 !important;',
' display: none !important;',
'}',
'#app, .app-root, .chat-container {',
' max-height: 100% !important;',
' overflow: hidden !important;',
'}'
].join('\n');
var existing = document.getElementById(styleId);
@ -224,15 +281,41 @@ class MainActivity : AppCompatActivity() {
applySystemBarTheme(theme)
}
}
@JavascriptInterface
fun getAppVersionCode(): String {
return getInstalledVersionCode().toString()
}
@JavascriptInterface
fun getAppVersionName(): String {
return getInstalledVersionName()
}
}
companion object {
private const val HOME_URL = "https://agent.cyjai.com"
private const val WEB_ASSET_VERSION = "20260405_3"
private const val WEB_ASSET_VERSION = "20260406_4"
private const val FILE_CHOOSER_REQUEST_CODE = 9001
}
private fun buildHomeUrl(): String {
return "$HOME_URL/?app_shell=$WEB_ASSET_VERSION"
val vc = getInstalledVersionCode()
val vn = Uri.encode(getInstalledVersionName())
return "$HOME_URL/?app_shell=$WEB_ASSET_VERSION&app_vc=$vc&app_vn=$vn"
}
private fun getInstalledVersionCode(): Long {
val pkgInfo = packageManager.getPackageInfo(packageName, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pkgInfo.longVersionCode else @Suppress("DEPRECATION") pkgInfo.versionCode.toLong()
}
private fun getInstalledVersionName(): String {
val pkgInfo = packageManager.getPackageInfo(packageName, 0)
return pkgInfo.versionName ?: "unknown"
}
private fun isApkUrl(url: String): Boolean {
return url.lowercase(Locale.getDefault()).endsWith(".apk") || url.contains("/api/app/apk/latest")
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,6 +1,8 @@
from __future__ import annotations
import time
from flask import Blueprint, jsonify
import re
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file
from .auth_helpers import api_login_required, resolve_admin_policy
from .context import with_terminal
@ -16,6 +18,42 @@ from config import AGENT_VERSION
status_bp = Blueprint('status', __name__)
def _parse_android_version_from_gradle(project_root: Path) -> tuple[int, str]:
"""从 android-webview-app/app/build.gradle.kts 读取 versionCode/versionName。"""
gradle_file = project_root / "android-webview-app" / "app" / "build.gradle.kts"
if not gradle_file.exists():
return 1, "1.0.0"
text = gradle_file.read_text(encoding="utf-8", errors="ignore")
vc_match = re.search(r"versionCode\s*=\s*(\d+)", text)
vn_match = re.search(r'versionName\s*=\s*"([^"]+)"', text)
version_code = int(vc_match.group(1)) if vc_match else 1
version_name = vn_match.group(1) if vn_match else "1.0.0"
return version_code, version_name
def _resolve_android_apk_path(project_root: Path) -> Path:
"""优先使用 release APK其次 fallback 到 debug APK。"""
release_apk = project_root / "android-webview-app" / "app" / "release" / "app-release.apk"
if release_apk.exists():
return release_apk
return project_root / "android-webview-app" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
def _load_app_changelog(project_root: Path) -> str:
"""读取 App 更新说明(优先 APP_CHANGELOG.md"""
candidates = [
project_root / "android-webview-app" / "APP_CHANGELOG.md",
project_root / "android-webview-app" / "CHANGELOG.md",
]
for path in candidates:
if not path.exists():
continue
text = path.read_text(encoding="utf-8", errors="ignore").strip()
if text:
return text[:4000]
return ""
@status_bp.route('/api/status')
@api_login_required
@with_terminal
@ -94,3 +132,53 @@ def get_project_storage(terminal, workspace, username):
return jsonify({"success": True, "data": stale.get("data"), "stale": True}), 200
return jsonify({"success": False, "error": str(exc)}), 500
@status_bp.route('/api/app/version')
@api_login_required
def get_app_version_info():
project_root = Path(__file__).resolve().parent.parent
version_code, version_name = _parse_android_version_from_gradle(project_root)
apk_path = _resolve_android_apk_path(project_root)
apk_exists = apk_path.exists()
file_size = apk_path.stat().st_size if apk_exists else 0
published_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(apk_path.stat().st_mtime)) if apk_exists else None
changelog = _load_app_changelog(project_root)
apk_url = request.url_root.rstrip("/") + "/api/app/apk/latest"
current_code_raw = request.args.get("currentVersionCode", "").strip()
current_code = int(current_code_raw) if current_code_raw.isdigit() else None
has_update = (version_code > current_code) if current_code is not None else None
return jsonify({
"success": True,
"data": {
"latestVersionCode": version_code,
"latestVersionName": version_name,
"apkUrl": apk_url,
"apkSha256": "",
"fileSizeBytes": file_size,
"changelog": changelog,
"forceUpdate": False,
"publishedAt": published_at,
"minSupportedVersionCode": 1,
"apkExists": apk_exists,
"hasUpdate": has_update,
}
})
@status_bp.route('/api/app/apk/latest')
def download_latest_apk():
project_root = Path(__file__).resolve().parent.parent
apk_path = _resolve_android_apk_path(project_root)
if not apk_path.exists():
return jsonify({"success": False, "error": "APK 不存在,请先构建 release 包"}), 404
return send_file(
apk_path,
as_attachment=True,
download_name="cyj-agent-latest.apk",
mimetype="application/vnd.android.package-archive",
conditional=False,
etag=False,
max_age=0,
)

View File

@ -380,6 +380,61 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'app-update'" key="app-update" class="personal-page app-update-page">
<div class="usage-summary-card">
<div class="usage-summary-header">
<div>
<p class="usage-summary-eyebrow">App 更新</p>
<h3>软件更新</h3>
<p class="usage-summary-desc">仅移动 App 可见自动检查新版本也可手动检查</p>
<p class="usage-summary-note">
当前版本{{ appCurrentVersionText }}
</p>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--counts">
<div class="usage-summary-item">
<div class="label">最新版本</div>
<div class="value">{{ appLatestVersionText }}</div>
</div>
<div class="usage-summary-item">
<div class="label">安装包大小</div>
<div class="value">{{ appLatestFileSizeText }}</div>
</div>
<div class="usage-summary-item">
<div class="label">更新状态</div>
<div class="value" :class="{ 'value--warning': appHasUpdate }">
{{ appUpdateStateText }}
</div>
</div>
</div>
<div class="app-update-changelog" v-if="appUpdateChangelogText">
<div class="app-update-changelog-title">更新说明</div>
<pre class="app-update-changelog-content">{{ appUpdateChangelogText }}</pre>
</div>
<div class="usage-summary-meta">
<span v-if="appUpdateError" class="usage-summary-error">{{ appUpdateError }}</span>
<span v-else-if="appUpdateChecking">正在检查版本...</span>
<span v-else>{{ appUpdateCheckedText }}</span>
<div class="app-update-actions">
<button type="button" class="usage-summary-refresh" @click="checkAppUpdate" :disabled="appUpdateChecking">
{{ appUpdateChecking ? '检查中...' : '手动检查' }}
</button>
<button
type="button"
class="usage-summary-refresh"
:disabled="!appHasUpdate || !appDownloadUrl"
@click="downloadLatestApp"
>
下载更新
</button>
</div>
</div>
</div>
</section>
<section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page">
<div class="behavior-section">
<div class="behavior-field">
@ -767,7 +822,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { usePersonalizationStore, type BlockDisplayMode } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource';
@ -804,6 +859,7 @@ const baseTabs = [
{ id: 'preferences', label: '个性化设置', description: '称呼、语气与注意事项' },
{ id: 'model', label: '模型偏好', description: '默认模型选择' },
{ id: 'usage', label: '用量统计', description: '对话累计数据' },
{ id: 'app-update', label: '软件更新', description: '检查并下载新版 APK' },
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
{ id: 'skills', label: 'Skills', description: '可用技能开关' },
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
@ -815,6 +871,7 @@ type PersonalTab =
| 'preferences'
| 'model'
| 'usage'
| 'app-update'
| 'behavior'
| 'skills'
| 'image'
@ -823,9 +880,15 @@ type PersonalTab =
| 'admin-monitor';
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
const isAppShell = computed(() => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
if (params.has('app_shell')) return true;
return Boolean((window as any)?.AndroidThemeBridge);
});
const personalTabs = computed(() => {
const tabs = [...baseTabs];
const tabs = baseTabs.filter((tab) => (tab.id === 'app-update' ? isAppShell.value : true));
if (isAdmin.value) {
tabs.push({ id: 'admin-monitor' as const, label: '管理员监控', description: '系统总览' });
}
@ -900,6 +963,96 @@ const usageUpdatedText = computed(() => {
});
});
const appCurrentVersionCode = ref<number | null>(null);
const appCurrentVersionName = ref<string>('');
const appUpdateChecking = ref(false);
const appUpdateError = ref('');
const appUpdateCheckedAt = ref<string | null>(null);
const appUpdateInfo = ref<{
latestVersionCode: number;
latestVersionName: string;
apkUrl: string;
fileSizeBytes: number;
changelog?: string;
publishedAt?: string | null;
hasUpdate?: boolean | null;
} | null>(null);
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const vcRaw = params.get('app_vc') || '';
const vnRaw = params.get('app_vn') || '';
const bridge = (window as any)?.AndroidThemeBridge;
const bridgeVcRaw = bridge && typeof bridge.getAppVersionCode === 'function' ? String(bridge.getAppVersionCode() || '') : '';
const bridgeVnRaw = bridge && typeof bridge.getAppVersionName === 'function' ? String(bridge.getAppVersionName() || '') : '';
const finalVcRaw = vcRaw || bridgeVcRaw;
const finalVnRaw = vnRaw || bridgeVnRaw;
appCurrentVersionCode.value = /^\d+$/.test(finalVcRaw) ? Number(finalVcRaw) : null;
appCurrentVersionName.value = finalVnRaw || '';
}
const hydrateAppVersionFromBridge = () => {
if (typeof window === 'undefined') return;
const bridge = (window as any)?.AndroidThemeBridge;
if (!bridge) return;
try {
const vcRaw = typeof bridge.getAppVersionCode === 'function' ? String(bridge.getAppVersionCode() || '') : '';
const vnRaw = typeof bridge.getAppVersionName === 'function' ? String(bridge.getAppVersionName() || '') : '';
if (vcRaw && /^\d+$/.test(vcRaw)) {
appCurrentVersionCode.value = Number(vcRaw);
}
if (vnRaw) {
appCurrentVersionName.value = vnRaw;
}
} catch {
// ignore bridge read failures
}
};
const appCurrentVersionText = computed(() => {
const vn = appCurrentVersionName.value || '未知';
const vc = appCurrentVersionCode.value ?? '?';
return `${vn} (${vc})`;
});
const appLatestVersionText = computed(() => {
if (!appUpdateInfo.value) return '--';
return `${appUpdateInfo.value.latestVersionName || '未知'} (${appUpdateInfo.value.latestVersionCode ?? '?'})`;
});
const appLatestFileSizeText = computed(() => {
const bytes = Number(appUpdateInfo.value?.fileSizeBytes || 0);
if (!bytes) return '--';
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(mb >= 100 ? 0 : 1)} MB`;
});
const appUpdateChangelogText = computed(() => {
const text = (appUpdateInfo.value?.changelog || '').trim();
return text || '';
});
const appHasUpdate = computed(() => {
if (!appUpdateInfo.value) return false;
if (typeof appUpdateInfo.value.hasUpdate === 'boolean') return appUpdateInfo.value.hasUpdate;
if (appCurrentVersionCode.value == null) return false;
return Number(appUpdateInfo.value.latestVersionCode || 0) > appCurrentVersionCode.value;
});
const appDownloadUrl = computed(() => appUpdateInfo.value?.apkUrl || '');
const appUpdateStateText = computed(() => {
if (!appUpdateInfo.value) return '未检查';
return appHasUpdate.value ? '发现新版本' : '已是最新';
});
const appUpdateCheckedText = computed(() => {
if (!appUpdateCheckedAt.value) return '尚未检查更新';
const d = new Date(appUpdateCheckedAt.value);
if (Number.isNaN(d.getTime())) return '刚刚检查过';
return `最近检查:${d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`;
});
const fetchUsageSummary = async () => {
if (usageLoading.value) {
return;
@ -929,12 +1082,62 @@ const fetchUsageSummary = async () => {
}
};
const checkAppUpdate = async () => {
if (appUpdateChecking.value) return;
hydrateAppVersionFromBridge();
appUpdateChecking.value = true;
appUpdateError.value = '';
try {
const params = new URLSearchParams();
if (appCurrentVersionCode.value != null) {
params.set('currentVersionCode', String(appCurrentVersionCode.value));
}
const suffix = params.toString() ? `?${params.toString()}` : '';
const resp = await fetch(`/api/app/version${suffix}`);
const payload = await resp.json();
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '检查更新失败');
}
const data = payload?.data || {};
appUpdateInfo.value = {
latestVersionCode: Number(data.latestVersionCode || 0),
latestVersionName: String(data.latestVersionName || ''),
apkUrl: String(data.apkUrl || ''),
fileSizeBytes: Number(data.fileSizeBytes || 0),
changelog: String(data.changelog || ''),
publishedAt: data.publishedAt || null,
hasUpdate: typeof data.hasUpdate === 'boolean' ? data.hasUpdate : null
};
appUpdateCheckedAt.value = new Date().toISOString();
} catch (error: any) {
appUpdateError.value = error?.message || '检查更新失败';
} finally {
appUpdateChecking.value = false;
}
};
const downloadLatestApp = () => {
const url = appDownloadUrl.value;
if (!url) {
appUpdateError.value = '未拿到下载地址';
return;
}
window.location.href = url;
};
onMounted(() => {
hydrateAppVersionFromBridge();
});
watch(
() => [activeTab.value, visible.value],
([tab, isVisible]) => {
if (isVisible && tab === 'usage') {
fetchUsageSummary();
}
if (isVisible && tab === 'app-update' && isAppShell.value && !appUpdateInfo.value && !appUpdateChecking.value) {
checkAppUpdate();
}
}
);
@ -1198,6 +1401,44 @@ const applyThemeOption = (theme: ThemeKey) => {
box-shadow: 0 14px 28px rgba(61, 57, 41, 0.22);
}
.app-update-page .app-update-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.app-update-changelog {
border: 1px solid var(--claude-border);
border-radius: 14px;
padding: 12px;
background: var(--claude-panel);
max-height: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.app-update-changelog-title {
font-size: 13px;
font-weight: 600;
color: var(--claude-text);
margin-bottom: 8px;
}
.app-update-changelog-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.55;
color: var(--claude-text-secondary);
flex: 1 1 auto;
min-height: 0;
max-height: 240px;
overflow: auto;
}
.usage-summary-grid {
display: grid;
gap: 14px;

View File

@ -1736,10 +1736,20 @@
.mobile-panel-sheet--conversation,
.mobile-panel-sheet--workspace {
background: var(--claude-left-rail);
}
.mobile-panel-sheet--conversation {
background: var(--claude-left-rail);
width: min(420px, 65vw);
max-width: 65vw;
}
.mobile-panel-sheet--workspace {
width: fit-content;
min-width: min(420px, 60vw);
max-width: 100vw;
}
.mobile-panel-sheet--focus {
background: var(--claude-panel);
}

136
upload_android_apk.sh Executable file
View File

@ -0,0 +1,136 @@
#!/bin/bash
set -euo pipefail
# 专用:上传本地 APK 到服务器,覆盖
# /opt/agent/agents/android-webview-app/app/release/app-release.apk
# 并同步版本元数据build.gradle.kts + APP_CHANGELOG.md
# 支持命令行自动打包assembleRelease+ 上传
# ===== 可按需覆盖(环境变量优先)=====
REMOTE_HOST="${REMOTE_HOST:-59.110.19.30}"
REMOTE_USER="${REMOTE_USER:-root}"
REMOTE_PORT="${REMOTE_PORT:-22}"
REMOTE_APK_PATH="${REMOTE_APK_PATH:-/opt/agent/agents/android-webview-app/app/release/app-release.apk}"
REMOTE_GRADLE_PATH="${REMOTE_GRADLE_PATH:-/opt/agent/agents/android-webview-app/app/build.gradle.kts}"
REMOTE_CHANGELOG_PATH="${REMOTE_CHANGELOG_PATH:-/opt/agent/agents/android-webview-app/APP_CHANGELOG.md}"
REMOTE_PASS="${REMOTE_PASS:-}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ANDROID_APP_DIR="${ANDROID_APP_DIR:-$SCRIPT_DIR/android-webview-app}"
LOCAL_APK_PATH="${LOCAL_APK_PATH:-}"
LOCAL_GRADLE_PATH="${LOCAL_GRADLE_PATH:-$SCRIPT_DIR/android-webview-app/app/build.gradle.kts}"
LOCAL_CHANGELOG_PATH="${LOCAL_CHANGELOG_PATH:-$SCRIPT_DIR/android-webview-app/APP_CHANGELOG.md}"
BUILD_APK="${BUILD_APK:-1}" # 1=先打包再上传0=仅上传
SSH_OPTS=(
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
)
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || { echo "缺少命令: $1"; exit 1; }
}
main() {
need_cmd ssh
need_cmd scp
if [[ "$BUILD_APK" == "1" ]]; then
if [[ ! -d "$ANDROID_APP_DIR" ]]; then
echo "Android 工程目录不存在: $ANDROID_APP_DIR"
exit 1
fi
if [[ ! -x "$ANDROID_APP_DIR/gradlew" ]]; then
echo "未找到可执行的 gradlew: $ANDROID_APP_DIR/gradlew"
exit 1
fi
log "先执行 Android 打包: assembleRelease"
(
cd "$ANDROID_APP_DIR"
./gradlew assembleRelease
)
fi
# 自动探测默认 APK 路径(按优先级)
if [[ -z "$LOCAL_APK_PATH" ]]; then
if [[ -f "$ANDROID_APP_DIR/app/build/outputs/apk/release/app-release.apk" ]]; then
LOCAL_APK_PATH="$ANDROID_APP_DIR/app/build/outputs/apk/release/app-release.apk"
elif [[ -f "$ANDROID_APP_DIR/app/build/outputs/apk/release/app-release-unsigned.apk" ]]; then
LOCAL_APK_PATH="$ANDROID_APP_DIR/app/build/outputs/apk/release/app-release-unsigned.apk"
elif [[ -f "$ANDROID_APP_DIR/app/release/app-release.apk" ]]; then
LOCAL_APK_PATH="$ANDROID_APP_DIR/app/release/app-release.apk"
fi
fi
if [[ ! -f "$LOCAL_APK_PATH" ]]; then
echo "本地 APK 不存在: $LOCAL_APK_PATH"
echo "尝试过路径:"
echo " - $ANDROID_APP_DIR/app/build/outputs/apk/release/app-release.apk"
echo " - $ANDROID_APP_DIR/app/build/outputs/apk/release/app-release-unsigned.apk"
echo " - $ANDROID_APP_DIR/app/release/app-release.apk"
echo "可用方式: BUILD_APK=1 bash upload_android_apk.sh"
echo "或指定现成包: BUILD_APK=0 LOCAL_APK_PATH=/your/app-release.apk bash upload_android_apk.sh"
exit 1
fi
if [[ ! -f "$LOCAL_GRADLE_PATH" ]]; then
echo "本地版本文件不存在: $LOCAL_GRADLE_PATH"
exit 1
fi
if [[ ! -f "$LOCAL_CHANGELOG_PATH" ]]; then
echo "本地更新说明不存在: $LOCAL_CHANGELOG_PATH"
exit 1
fi
log "本地 APK: $LOCAL_APK_PATH"
if [[ "$LOCAL_APK_PATH" == *"-unsigned.apk" ]]; then
log "错误:当前是 unsigned APK无法用于覆盖安装更新。请先配置签名后重新打包。"
log "需要环境变量ANDROID_KEYSTORE_PATH / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD"
exit 1
fi
log "本地版本文件: $LOCAL_GRADLE_PATH"
log "本地更新说明: $LOCAL_CHANGELOG_PATH"
log "目标服务器: $REMOTE_USER@$REMOTE_HOST:$REMOTE_APK_PATH"
local remote_apk_dir remote_gradle_dir remote_changelog_dir
remote_apk_dir="$(dirname "$REMOTE_APK_PATH")"
remote_gradle_dir="$(dirname "$REMOTE_GRADLE_PATH")"
remote_changelog_dir="$(dirname "$REMOTE_CHANGELOG_PATH")"
if [[ -n "$REMOTE_PASS" ]]; then
need_cmd sshpass
log "创建远程目录: $remote_apk_dir / $remote_gradle_dir / $remote_changelog_dir"
sshpass -p "$REMOTE_PASS" ssh -p "$REMOTE_PORT" "${SSH_OPTS[@]}" "$REMOTE_USER@$REMOTE_HOST" \
"mkdir -p '$remote_apk_dir' '$remote_gradle_dir' '$remote_changelog_dir'"
log "上传并覆盖 APK + 版本元数据"
sshpass -p "$REMOTE_PASS" scp -P "$REMOTE_PORT" "${SSH_OPTS[@]}" "$LOCAL_APK_PATH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_APK_PATH"
sshpass -p "$REMOTE_PASS" scp -P "$REMOTE_PORT" "${SSH_OPTS[@]}" "$LOCAL_GRADLE_PATH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_GRADLE_PATH"
sshpass -p "$REMOTE_PASS" scp -P "$REMOTE_PORT" "${SSH_OPTS[@]}" "$LOCAL_CHANGELOG_PATH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_CHANGELOG_PATH"
log "远程文件检查"
sshpass -p "$REMOTE_PASS" ssh -p "$REMOTE_PORT" "${SSH_OPTS[@]}" "$REMOTE_USER@$REMOTE_HOST" \
"ls -lh '$REMOTE_APK_PATH' '$REMOTE_GRADLE_PATH' '$REMOTE_CHANGELOG_PATH'"
else
log "使用 SSH Key 模式(未提供 REMOTE_PASS"
log "创建远程目录: $remote_apk_dir / $remote_gradle_dir / $remote_changelog_dir"
ssh -p "$REMOTE_PORT" "${SSH_OPTS[@]}" "$REMOTE_USER@$REMOTE_HOST" \
"mkdir -p '$remote_apk_dir' '$remote_gradle_dir' '$remote_changelog_dir'"
log "上传并覆盖 APK + 版本元数据"
scp -P "$REMOTE_PORT" "${SSH_OPTS[@]}" "$LOCAL_APK_PATH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_APK_PATH"
scp -P "$REMOTE_PORT" "${SSH_OPTS[@]}" "$LOCAL_GRADLE_PATH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_GRADLE_PATH"
scp -P "$REMOTE_PORT" "${SSH_OPTS[@]}" "$LOCAL_CHANGELOG_PATH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_CHANGELOG_PATH"
log "远程文件检查"
ssh -p "$REMOTE_PORT" "${SSH_OPTS[@]}" "$REMOTE_USER@$REMOTE_HOST" \
"ls -lh '$REMOTE_APK_PATH' '$REMOTE_GRADLE_PATH' '$REMOTE_CHANGELOG_PATH'"
fi
log "完成:服务器 APK 与版本信息已更新"
}
main "$@"