feat:增加在线更新功能&&增加全自动部署脚本
This commit is contained in:
@@ -13,10 +13,11 @@ android {
|
||||
applicationId = "com.memory.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "1.1.1"
|
||||
versionCode = 9
|
||||
versionName = "1.2.5"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
|
||||
buildConfigField("int", "VERSION_CODE", "9")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -23,6 +24,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -17,8 +17,11 @@ import coil.memory.MemoryCache
|
||||
import com.memory.app.data.CredentialsManager
|
||||
import com.memory.app.data.ThemeManager
|
||||
import com.memory.app.data.ThemeMode
|
||||
import com.memory.app.data.UpdateManager
|
||||
import com.memory.app.data.model.User
|
||||
import com.memory.app.data.model.VersionInfo
|
||||
import com.memory.app.data.repository.AuthRepository
|
||||
import com.memory.app.ui.components.UpdateDialog
|
||||
import com.memory.app.ui.navigation.MainNavigation
|
||||
import com.memory.app.ui.screen.LoginScreen
|
||||
import com.memory.app.ui.theme.MemoryTheme
|
||||
@@ -30,6 +33,7 @@ class MainActivity : ComponentActivity() {
|
||||
private lateinit var authRepository: AuthRepository
|
||||
private lateinit var themeManager: ThemeManager
|
||||
private lateinit var credentialsManager: CredentialsManager
|
||||
private lateinit var updateManager: UpdateManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -37,6 +41,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
themeManager = ThemeManager(this)
|
||||
credentialsManager = CredentialsManager(this)
|
||||
updateManager = UpdateManager(this)
|
||||
|
||||
// 配置 Coil 图片加载器
|
||||
val imageLoader = ImageLoader.Builder(this)
|
||||
@@ -75,6 +80,7 @@ class MainActivity : ComponentActivity() {
|
||||
var currentUser by remember { mutableStateOf<User?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var updateInfo by remember { mutableStateOf<VersionInfo?>(null) }
|
||||
|
||||
// Check login state
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -85,6 +91,27 @@ class MainActivity : ComponentActivity() {
|
||||
isLoggedIn = restored
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
LaunchedEffect(Unit) {
|
||||
updateInfo = updateManager.checkUpdate()
|
||||
}
|
||||
|
||||
// Update dialog
|
||||
updateInfo?.let { info ->
|
||||
UpdateDialog(
|
||||
versionInfo = info,
|
||||
onUpdate = {
|
||||
updateManager.downloadAndInstall(info)
|
||||
if (!info.forceUpdate) {
|
||||
updateInfo = null
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
updateInfo = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Observe user changes
|
||||
LaunchedEffect(Unit) {
|
||||
authRepository.currentUser.collect { user ->
|
||||
@@ -153,6 +180,21 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onToggleTheme = {
|
||||
themeManager.toggleTheme()
|
||||
},
|
||||
onCheckUpdate = {
|
||||
lifecycleScope.launch {
|
||||
val info = updateManager.checkUpdate()
|
||||
if (info != null) {
|
||||
updateInfo = info
|
||||
} else {
|
||||
// 已是最新版本,可以显示 Toast
|
||||
android.widget.Toast.makeText(
|
||||
this@MainActivity,
|
||||
"已是最新版本",
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.memory.app.data
|
||||
|
||||
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.Environment
|
||||
import androidx.core.content.FileProvider
|
||||
import com.memory.app.BuildConfig
|
||||
import com.memory.app.data.api.ApiClient
|
||||
import com.memory.app.data.model.VersionInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class UpdateManager(private val context: Context) {
|
||||
|
||||
suspend fun checkUpdate(): VersionInfo? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = ApiClient.apiNoAuth.getLatestVersion()
|
||||
if (response.isSuccessful) {
|
||||
val versionInfo = response.body()
|
||||
if (versionInfo != null && versionInfo.versionCode > BuildConfig.VERSION_CODE) {
|
||||
versionInfo
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall(versionInfo: VersionInfo) {
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
// 删除旧的 APK 文件
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "memory_update.apk")
|
||||
if (apkFile.exists()) {
|
||||
apkFile.delete()
|
||||
}
|
||||
|
||||
val request = DownloadManager.Request(Uri.parse(versionInfo.downloadUrl))
|
||||
.setTitle("Memory 更新")
|
||||
.setDescription("正在下载 v${versionInfo.versionName}")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "memory_update.apk")
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedOverRoaming(true)
|
||||
|
||||
val downloadId = downloadManager.enqueue(request)
|
||||
|
||||
// 注册下载完成广播
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||
if (id == downloadId) {
|
||||
installApk(apkFile)
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun installApk(apkFile: File) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
val apkUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
|
||||
} else {
|
||||
Uri.fromFile(apkFile)
|
||||
}
|
||||
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,26 @@ object ApiClient {
|
||||
retrofit.create(ApiService::class.java)
|
||||
}
|
||||
|
||||
// 不需要认证的 API 客户端(用于版本检查等)
|
||||
private val okHttpClientNoAuth by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val retrofitNoAuth by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(okHttpClientNoAuth)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
}
|
||||
|
||||
val apiNoAuth: ApiService by lazy {
|
||||
retrofitNoAuth.create(ApiService::class.java)
|
||||
}
|
||||
|
||||
fun setToken(newToken: String?) {
|
||||
token = newToken
|
||||
}
|
||||
|
||||
@@ -110,4 +110,8 @@ interface ApiService {
|
||||
|
||||
@PUT("admin/settings")
|
||||
suspend fun updateSettings(@Body settings: Map<String, String>): Response<MessageResponse>
|
||||
|
||||
// Version
|
||||
@GET("version")
|
||||
suspend fun getLatestVersion(): Response<VersionInfo>
|
||||
}
|
||||
|
||||
@@ -172,3 +172,12 @@ data class MessageResponse(
|
||||
data class ErrorResponse(
|
||||
val error: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VersionInfo(
|
||||
@SerialName("version_code") val versionCode: Int,
|
||||
@SerialName("version_name") val versionName: String,
|
||||
@SerialName("download_url") val downloadUrl: String,
|
||||
@SerialName("update_log") val updateLog: String,
|
||||
@SerialName("force_update") val forceUpdate: Boolean
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.memory.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.memory.app.data.model.VersionInfo
|
||||
import com.memory.app.ui.theme.Brand500
|
||||
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
versionInfo: VersionInfo,
|
||||
onUpdate: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = { if (!versionInfo.forceUpdate) onDismiss() }) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "🎉",
|
||||
fontSize = 48.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "发现新版本",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "v${versionInfo.versionName}",
|
||||
fontSize = 16.sp,
|
||||
color = Brand500,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (versionInfo.updateLog.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = versionInfo.updateLog,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onUpdate,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Brand500),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "立即更新",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!versionInfo.forceUpdate) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "稍后再说",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,8 @@ fun MainNavigation(
|
||||
likeCount: Int,
|
||||
isDarkTheme: Boolean = false,
|
||||
onLogout: () -> Unit,
|
||||
onToggleTheme: () -> Unit = {}
|
||||
onToggleTheme: () -> Unit = {},
|
||||
onCheckUpdate: () -> Unit = {}
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
var showCreatePost by remember { mutableStateOf(false) }
|
||||
@@ -125,7 +126,8 @@ fun MainNavigation(
|
||||
onUpdateAvatar = { uri ->
|
||||
profileViewModel.updateAvatar(context, uri)
|
||||
},
|
||||
onToggleTheme = onToggleTheme
|
||||
onToggleTheme = onToggleTheme,
|
||||
onCheckUpdate = onCheckUpdate
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,40 +60,13 @@ fun HomeScreen(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Avatar with ring
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.shadow(2.dp, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (user?.avatarUrl?.isNotEmpty() == true) {
|
||||
AsyncImage(
|
||||
model = user?.avatarUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = user?.nickname?.firstOrNull()?.toString() ?: "?",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = "时间轴",
|
||||
@@ -101,19 +74,6 @@ fun HomeScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
// Bell Icon
|
||||
IconButton(
|
||||
onClick = { },
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Notifications,
|
||||
contentDescription = "通知",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.outlined.DarkMode
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.LightMode
|
||||
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -46,7 +47,8 @@ fun ProfileScreen(
|
||||
onLogout: () -> Unit,
|
||||
onUpdateNickname: ((String) -> Unit)? = null,
|
||||
onUpdateAvatar: ((Uri) -> Unit)? = null,
|
||||
onToggleTheme: (() -> Unit)? = null
|
||||
onToggleTheme: (() -> Unit)? = null,
|
||||
onCheckUpdate: (() -> Unit)? = null
|
||||
) {
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
var editedNickname by remember(user) { mutableStateOf(user?.nickname ?: "") }
|
||||
@@ -76,29 +78,57 @@ fun ProfileScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Theme Toggle Button
|
||||
Surface(
|
||||
onClick = { onToggleTheme?.invoke() },
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Theme Toggle Button
|
||||
Surface(
|
||||
onClick = { onToggleTheme?.invoke() },
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
|
||||
contentDescription = "切换主题",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = if (isDarkTheme) "深色" else "浅色",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
|
||||
contentDescription = "切换主题",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = if (isDarkTheme) "深色" else "浅色",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check Update Button
|
||||
Surface(
|
||||
onClick = { onCheckUpdate?.invoke() },
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SystemUpdate,
|
||||
contentDescription = "检查更新",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "更新",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path
|
||||
name="downloads"
|
||||
path="Download/" />
|
||||
</paths>
|
||||
271
release.sh
Executable file
271
release.sh
Executable file
@@ -0,0 +1,271 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Memory App 发布脚本
|
||||
# 功能:自动递增版本号、编译 APK、上传到 R2、更新服务器版本信息
|
||||
|
||||
set -e
|
||||
|
||||
# ============ 配置区域 ============
|
||||
# R2 配置
|
||||
R2_ACCOUNT_ID="ebf33b5ee4eb26f32af0c6e06102e000"
|
||||
R2_ACCESS_KEY_ID="8acbc8a9386d60d0e8dac6bd8165c618"
|
||||
R2_ACCESS_KEY_SECRET="72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443"
|
||||
R2_BUCKET_NAME="memory"
|
||||
R2_PUBLIC_URL="https://cdn.amos.us.kg"
|
||||
|
||||
# API 配置
|
||||
API_BASE_URL="https://x.amos.us.kg/api"
|
||||
|
||||
# 服务器配置
|
||||
SERVER_HOST="95.181.190.226"
|
||||
SERVER_PORT="33"
|
||||
SERVER_USER="root"
|
||||
SERVER_PASSWORD="xiaobiao."
|
||||
SERVER_PROJECT_PATH="/amos/memory"
|
||||
|
||||
# 签名配置
|
||||
KEYSTORE_FILE="release.keystore"
|
||||
KEYSTORE_PASSWORD="memory123"
|
||||
KEY_ALIAS="memory"
|
||||
KEY_PASSWORD="memory123"
|
||||
|
||||
# ============ 函数定义 ============
|
||||
|
||||
# 获取当前版本号
|
||||
get_current_version() {
|
||||
grep "versionName" android/app/build.gradle.kts | head -1 | sed 's/.*"\(.*\)".*/\1/'
|
||||
}
|
||||
|
||||
# 获取当前版本代码
|
||||
get_current_version_code() {
|
||||
grep "versionCode" android/app/build.gradle.kts | head -1 | sed 's/[^0-9]*//g'
|
||||
}
|
||||
|
||||
# 递增版本号 (1.1.9 -> 1.2.0, 1.1.0 -> 1.1.1)
|
||||
increment_version() {
|
||||
local version=$1
|
||||
local major=$(echo $version | cut -d. -f1)
|
||||
local minor=$(echo $version | cut -d. -f2)
|
||||
local patch=$(echo $version | cut -d. -f3)
|
||||
|
||||
patch=$((patch + 1))
|
||||
if [ $patch -ge 10 ]; then
|
||||
patch=0
|
||||
minor=$((minor + 1))
|
||||
if [ $minor -ge 10 ]; then
|
||||
minor=0
|
||||
major=$((major + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "${major}.${minor}.${patch}"
|
||||
}
|
||||
|
||||
# 更新 build.gradle.kts 中的版本号
|
||||
update_version_in_gradle() {
|
||||
local new_version=$1
|
||||
local new_code=$2
|
||||
|
||||
sed -i '' "s/versionCode = [0-9]*/versionCode = ${new_code}/" android/app/build.gradle.kts
|
||||
sed -i '' "s/versionName = \"[^\"]*\"/versionName = \"${new_version}\"/" android/app/build.gradle.kts
|
||||
sed -i '' "s/VERSION_CODE\", \"[0-9]*\"/VERSION_CODE\", \"${new_code}\"/" android/app/build.gradle.kts
|
||||
}
|
||||
|
||||
# 编译 Docker 镜像并导出
|
||||
build_docker() {
|
||||
echo "🐳 编译 Docker 镜像..."
|
||||
cd server
|
||||
docker build --platform linux/amd64 -t memory-server:latest .
|
||||
docker save memory-server:latest -o memory-server.tar
|
||||
cd ..
|
||||
echo "✅ Docker 镜像已导出到 server/memory-server.tar"
|
||||
}
|
||||
|
||||
# 部署 Docker 镜像到服务器
|
||||
deploy_docker() {
|
||||
echo "🚀 部署 Docker 镜像到服务器..."
|
||||
|
||||
# 上传镜像到服务器
|
||||
echo "📤 上传镜像文件..."
|
||||
sshpass -p "${SERVER_PASSWORD}" scp -P ${SERVER_PORT} -o StrictHostKeyChecking=no server/memory-server.tar ${SERVER_USER}@${SERVER_HOST}:${SERVER_PROJECT_PATH}/
|
||||
|
||||
# 在服务器上加载镜像并重启
|
||||
echo "🔄 加载镜像并重启服务..."
|
||||
sshpass -p "${SERVER_PASSWORD}" ssh -p ${SERVER_PORT} -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_HOST} << EOF
|
||||
cd ${SERVER_PROJECT_PATH}
|
||||
docker load -i memory-server.tar
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
# 清理旧镜像
|
||||
echo "🧹 清理旧镜像..."
|
||||
docker image prune -f
|
||||
# 删除镜像文件
|
||||
rm -f memory-server.tar
|
||||
echo "✅ 服务已重启"
|
||||
EOF
|
||||
|
||||
echo "✅ 部署完成!"
|
||||
}
|
||||
|
||||
# 编译 APK
|
||||
build_apk() {
|
||||
echo "📦 编译 APK..."
|
||||
cd android
|
||||
KEYSTORE_FILE=$KEYSTORE_FILE \
|
||||
KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD \
|
||||
KEY_ALIAS=$KEY_ALIAS \
|
||||
KEY_PASSWORD=$KEY_PASSWORD \
|
||||
gradle assembleRelease --quiet
|
||||
cd ..
|
||||
}
|
||||
|
||||
# 上传到 R2
|
||||
upload_to_r2() {
|
||||
local version=$1
|
||||
local apk_path="android/app/build/outputs/apk/release/app-release.apk"
|
||||
local remote_path="releases/memory-${version}.apk"
|
||||
|
||||
echo "☁️ 上传到 R2..."
|
||||
|
||||
# 使用 AWS CLI 上传 (需要配置 AWS CLI)
|
||||
AWS_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID \
|
||||
AWS_SECRET_ACCESS_KEY=$R2_ACCESS_KEY_SECRET \
|
||||
aws s3 cp "$apk_path" "s3://${R2_BUCKET_NAME}/${remote_path}" \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
|
||||
--content-type "application/vnd.android.package-archive"
|
||||
|
||||
echo "${R2_PUBLIC_URL}/${remote_path}"
|
||||
}
|
||||
|
||||
# 更新服务器版本信息
|
||||
update_server_version() {
|
||||
local version_code=$1
|
||||
local version_name=$2
|
||||
local download_url=$3
|
||||
local update_log=$4
|
||||
|
||||
echo "🔄 更新服务器版本信息..."
|
||||
|
||||
curl -s -X POST "${API_BASE_URL}/admin/version" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"version_code\": ${version_code},
|
||||
\"version_name\": \"${version_name}\",
|
||||
\"download_url\": \"${download_url}\",
|
||||
\"update_log\": \"${update_log}\",
|
||||
\"force_update\": false
|
||||
}"
|
||||
}
|
||||
|
||||
# ============ 主流程 ============
|
||||
|
||||
echo "🚀 Memory App 发布脚本"
|
||||
echo "========================"
|
||||
|
||||
# 选择发布内容
|
||||
echo ""
|
||||
echo "请选择要发布的内容:"
|
||||
echo " 1) 只发布 APK"
|
||||
echo " 2) 只部署后端"
|
||||
echo " 3) 发布 APK + 部署后端"
|
||||
echo ""
|
||||
read -p "请选择 (1/2/3): " choice
|
||||
|
||||
BUILD_APK=false
|
||||
DEPLOY_BACKEND=false
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
BUILD_APK=true
|
||||
;;
|
||||
2)
|
||||
DEPLOY_BACKEND=true
|
||||
;;
|
||||
3)
|
||||
BUILD_APK=true
|
||||
DEPLOY_BACKEND=true
|
||||
;;
|
||||
*)
|
||||
echo "❌ 无效选择"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# APK 发布流程
|
||||
if [ "$BUILD_APK" = true ]; then
|
||||
# 获取当前版本
|
||||
current_version=$(get_current_version)
|
||||
current_code=$(get_current_version_code)
|
||||
echo ""
|
||||
echo "📌 当前版本: v${current_version} (code: ${current_code})"
|
||||
|
||||
# 计算新版本
|
||||
new_version=$(increment_version $current_version)
|
||||
new_code=$((current_code + 1))
|
||||
echo "📌 新版本: v${new_version} (code: ${new_code})"
|
||||
|
||||
# 获取更新日志
|
||||
echo ""
|
||||
echo "📝 请输入更新日志 (输入空行结束):"
|
||||
update_log=""
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
if [ -z "$update_log" ]; then
|
||||
update_log="$line"
|
||||
else
|
||||
update_log="${update_log}\\n${line}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$update_log" ]; then
|
||||
update_log="Bug 修复和性能优化"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📋 发布信息:"
|
||||
echo " 版本: v${new_version} (code: ${new_code})"
|
||||
echo " 更新日志: ${update_log}"
|
||||
echo ""
|
||||
read -p "确认发布 APK? (y/n) " confirm
|
||||
if [ "$confirm" != "y" ]; then
|
||||
echo "❌ APK 发布已取消"
|
||||
BUILD_APK=false
|
||||
else
|
||||
# 更新版本号
|
||||
echo ""
|
||||
echo "📝 更新版本号..."
|
||||
update_version_in_gradle $new_version $new_code
|
||||
|
||||
# 编译
|
||||
build_apk
|
||||
|
||||
# 上传
|
||||
download_url=$(upload_to_r2 $new_version)
|
||||
echo "✅ 上传完成: $download_url"
|
||||
|
||||
# 更新服务器
|
||||
update_server_version $new_code "$new_version" "$download_url" "$update_log"
|
||||
|
||||
echo ""
|
||||
echo "🎉 APK 发布完成!"
|
||||
echo " 版本: v${new_version}"
|
||||
echo " 下载: ${download_url}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 后端部署流程
|
||||
if [ "$DEPLOY_BACKEND" = true ]; then
|
||||
echo ""
|
||||
read -p "确认部署后端? (y/n) " deploy_confirm
|
||||
if [ "$deploy_confirm" = "y" ]; then
|
||||
build_docker
|
||||
deploy_docker
|
||||
echo ""
|
||||
echo "🎉 后端部署完成!"
|
||||
else
|
||||
echo "❌ 后端部署已取消"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✨ 全部完成!"
|
||||
@@ -127,5 +127,18 @@ func migrate(db *sql.DB) error {
|
||||
// 设置 amos 为超管
|
||||
db.Exec("UPDATE users SET is_superadmin = 1 WHERE username = 'amos'")
|
||||
|
||||
// 迁移:创建版本表
|
||||
db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_version (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version_code INTEGER NOT NULL,
|
||||
version_name TEXT NOT NULL,
|
||||
download_url TEXT NOT NULL,
|
||||
update_log TEXT DEFAULT '',
|
||||
force_update INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
90
server/internal/handler/version.go
Normal file
90
server/internal/handler/version.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"memory/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VersionHandler struct {
|
||||
db *sql.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewVersionHandler(db *sql.DB, cfg *config.Config) *VersionHandler {
|
||||
return &VersionHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
type VersionInfo struct {
|
||||
VersionCode int `json:"version_code"`
|
||||
VersionName string `json:"version_name"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
UpdateLog string `json:"update_log"`
|
||||
ForceUpdate bool `json:"force_update"`
|
||||
}
|
||||
|
||||
// 获取最新版本信息
|
||||
func (h *VersionHandler) GetLatestVersion(c *gin.Context) {
|
||||
var versionCode int
|
||||
var versionName, downloadURL, updateLog string
|
||||
var forceUpdate int
|
||||
|
||||
err := h.db.QueryRow(`
|
||||
SELECT version_code, version_name, download_url, update_log, force_update
|
||||
FROM app_version ORDER BY version_code DESC LIMIT 1
|
||||
`).Scan(&versionCode, &versionName, &downloadURL, &updateLog, &forceUpdate)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// 没有版本信息,返回默认值
|
||||
c.JSON(http.StatusOK, VersionInfo{
|
||||
VersionCode: 1,
|
||||
VersionName: "1.0.0",
|
||||
DownloadURL: "",
|
||||
UpdateLog: "",
|
||||
ForceUpdate: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, VersionInfo{
|
||||
VersionCode: versionCode,
|
||||
VersionName: versionName,
|
||||
DownloadURL: downloadURL,
|
||||
UpdateLog: updateLog,
|
||||
ForceUpdate: forceUpdate == 1,
|
||||
})
|
||||
}
|
||||
|
||||
// 设置版本信息(管理员接口)
|
||||
func (h *VersionHandler) SetVersion(c *gin.Context) {
|
||||
var req VersionInfo
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
forceUpdate := 0
|
||||
if req.ForceUpdate {
|
||||
forceUpdate = 1
|
||||
}
|
||||
|
||||
_, err := h.db.Exec(`
|
||||
INSERT INTO app_version (version_code, version_name, download_url, update_log, force_update)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, req.VersionCode, req.VersionName, req.DownloadURL, req.UpdateLog, forceUpdate)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set version"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "version updated"})
|
||||
}
|
||||
@@ -43,9 +43,13 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
|
||||
// R2 文件代理 (公开访问)
|
||||
r.GET("/files/*filepath", uploadHandler.GetFile)
|
||||
|
||||
// Version handler
|
||||
versionHandler := handler.NewVersionHandler(db, cfg)
|
||||
|
||||
// 公开接口
|
||||
r.POST("/api/auth/register", authHandler.Register)
|
||||
r.POST("/api/auth/login", authHandler.Login)
|
||||
r.GET("/api/version", versionHandler.GetLatestVersion)
|
||||
|
||||
// 需要认证的接口
|
||||
auth := r.Group("/api")
|
||||
@@ -86,6 +90,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
|
||||
{
|
||||
admin.GET("/settings", userHandler.GetSettings)
|
||||
admin.PUT("/settings", userHandler.UpdateSettings)
|
||||
admin.POST("/version", versionHandler.SetVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user