feat: complete P0-2 Token Refresh, P0-4 Android ProGuard, P0-5 App Update

- P0-2: Refresh token with Redis storage (UUID tokens, 30-day TTL, rotation on refresh)
- P0-2: Backend POST /auth/refresh and /auth/revoke endpoints
- P0-2: Android AuthInterceptor refresh-before-re-login flow
- P0-2: AuthRepository saves refresh_token on login, revokes on logout
- P0-4: Enable R8 minification (isMinifyEnabled=true) with comprehensive keep rules
- P0-5: Backend GET /api/v1/app/check-update with force_update support
- P0-5: Android AppUpdateManager with force/optional update dialogs on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:20:47 +08:00
parent 3dcf769660
commit 21a06e8664
13 changed files with 546 additions and 15 deletions

View File

@@ -30,7 +30,7 @@ buildConfigField("String", "BASE_URL", "\"http://192.168.31.135:8037/\"")
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"

View File

@@ -1,7 +1,139 @@
# Add project specific ProGuard rules here.
# ─────────── General ───────────
-keepattributes *Annotation*
-keep class com.tiangong.aiagent.data.remote.dto.** { *; }
-keepattributes Signature
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes Exceptions
# Bugly
# ─────────── DTOs (Gson serialization) ───────────
-keep class com.tiangong.aiagent.data.remote.dto.** { *; }
-keepclassmembers class com.tiangong.aiagent.data.remote.dto.** { *; }
# ─────────── Gson ───────────
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keep class com.google.gson.** { *; }
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
-keep class com.google.gson.stream.** { *; }
-dontwarn com.google.gson.**
# ─────────── Retrofit ───────────
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keep class retrofit2.** { *; }
-keepclassmembers,allowobfuscation class * {
@retrofit2.http.* <methods>;
}
-dontwarn retrofit2.**
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# ─────────── OkHttp ───────────
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
# ─────────── Room ───────────
-keep class * extends androidx.room.RoomDatabase { *; }
-keepclassmembers class * extends androidx.room.RoomDatabase {
abstract <methods>;
}
-keep @androidx.room.Entity class * { *; }
-keep @androidx.room.Dao class * { *; }
-keepclassmembers @androidx.room.Entity class * {
@androidx.room.PrimaryKey <fields>;
}
-keep class * extends androidx.room.migration.Migration { *; }
-keep class androidx.room.** { *; }
-dontwarn androidx.room.**
# ─────────── Hilt / Dagger ───────────
-keep class dagger.** { *; }
-keep class javax.inject.** { *; }
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; }
-keep class dagger.hilt.** { *; }
-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel { *; }
-keepclassmembers class * {
@javax.inject.Inject <init>(...);
}
-keepclassmembers class * {
@dagger.internal.InjectedFieldSignature <fields>;
}
-dontwarn dagger.**
-dontwarn javax.inject.**
-dontwarn dagger.hilt.**
# ─────────── Compose ───────────
-keep class androidx.compose.** { *; }
-keepclassmembers class * {
@androidx.compose.runtime.Composable <methods>;
}
-dontwarn androidx.compose.**
# ─────────── Firebase FCM ───────────
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
-dontwarn com.google.firebase.**
-dontwarn com.google.android.gms.**
# ─────────── Coil ───────────
-keep class coil.** { *; }
-dontwarn coil.**
# ─────────── DataStore ───────────
-keep class androidx.datastore.** { *; }
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
-dontwarn androidx.datastore.**
# ─────────── Security Crypto ───────────
-keep class androidx.security.crypto.** { *; }
-dontwarn androidx.security.crypto.**
# ─────────── ExoPlayer / Media3 ───────────
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
# ─────────── Markwon ───────────
-keep class io.noties.markwon.** { *; }
-dontwarn io.noties.markwon.**
# ─────────── Splash Screen ───────────
-keep class androidx.core.splashscreen.** { *; }
# ─────────── Bugly (Tencent crash reporting) ───────────
-dontwarn com.tencent.bugly.**
-keep public class com.tencent.bugly.** { *; }
# ─────────── Kotlin Coroutines ───────────
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# ─────────── Kotlin ───────────
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
-dontwarn kotlin.**
# ─────────── BuildConfig ───────────
-keep class com.tiangong.aiagent.BuildConfig { *; }
# ─────────── Application / Activities ───────────
-keep class com.tiangong.aiagent.AgentApplication { *; }
-keep class com.tiangong.aiagent.MainActivity { *; }
# ─────────── ViewModels (Hilt-injected) ───────────
-keep class * extends androidx.lifecycle.ViewModel { *; }
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
<init>(...);
}
# ─────────── Keep Compose previews in debug ───────────
-keep class androidx.compose.ui.tooling.** { *; }

View File

@@ -14,6 +14,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.tiangong.aiagent.data.local.TokenDataStore
import com.tiangong.aiagent.ui.navigation.NavGraph
import com.tiangong.aiagent.ui.theme.TiangongTheme
import com.tiangong.aiagent.util.AppUpdateManager
import com.tiangong.aiagent.util.FcmTokenManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@@ -27,6 +28,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var fcmTokenManager: FcmTokenManager
@Inject
lateinit var appUpdateManager: AppUpdateManager
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -35,6 +39,9 @@ class MainActivity : ComponentActivity() {
// Initialize FCM push (no-op if google-services.json is missing)
fcmTokenManager.initialize()
// Check for app update (non-blocking, shows dialog if update available)
appUpdateManager.checkForUpdate()
// Handle notification click deep link
val notificationIdFromNotification = intent?.getStringExtra("notification_id")

View File

@@ -19,6 +19,7 @@ class TokenDataStore @Inject constructor(
) {
companion object {
private val KEY_TOKEN = stringPreferencesKey("access_token")
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
private val KEY_CURRENT_AGENT_ID = stringPreferencesKey("current_agent_id")
private val KEY_CURRENT_AGENT_NAME = stringPreferencesKey("current_agent_name")
@@ -46,6 +47,21 @@ class TokenDataStore @Inject constructor(
context.dataStore.edit { it.remove(KEY_TOKEN) }
}
// ─── Refresh Token ───
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
suspend fun saveRefreshToken(token: String) {
context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token }
}
suspend fun getRefreshToken(): String? {
return context.dataStore.data.first()[KEY_REFRESH_TOKEN]
}
suspend fun clearRefreshToken() {
context.dataStore.edit { it.remove(KEY_REFRESH_TOKEN) }
}
// ─── Server URL ───
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
prefs[KEY_SERVER_URL] ?: com.tiangong.aiagent.BuildConfig.BASE_URL

View File

@@ -20,6 +20,12 @@ interface ApiService {
@GET("api/v1/auth/me")
suspend fun getCurrentUser(): UserResponse
@POST("api/v1/auth/refresh")
suspend fun refreshToken(@Body request: RefreshRequest): RefreshResponse
@POST("api/v1/auth/revoke")
suspend fun revokeToken(@Body request: RefreshRequest): Response<Unit>
// ─── Agents ───
@GET("api/v1/agents")
suspend fun getAgents(
@@ -192,6 +198,13 @@ interface ApiService {
@Body request: MemoryImportRequest
): Response<Unit>
// ─── App Update ───
@GET("api/v1/app/check-update")
suspend fun checkUpdate(
@Query("platform") platform: String = "android",
@Query("version") version: String
): AppVersionResponse
// ─── Vector Memories ───
@GET("api/v1/agents/{agentId}/memory/vector-memories")
suspend fun getVectorMemories(

View File

@@ -48,13 +48,26 @@ class AuthInterceptor(
var response = chain.proceed(request)
// 401 auto re-login (with retry limit to prevent infinite loops)
if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login")) {
// 401 auto refresh → re-login
if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login")
&& !originalRequest.url.encodedPath.endsWith("/refresh")
) {
// 1) Try refresh token first
val refreshedToken = performTokenRefresh()
if (refreshedToken != null) {
response.close()
cachedToken = refreshedToken
val retryRequest = request.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
return chain.proceed(retryRequest)
}
// 2) Fall back to credential re-login
val credentials = credentialStore.getCredentials()
if (credentials != null) {
val newToken = performReLogin(credentials)
if (newToken != null) {
// Close the 401 response since we're retrying with new token
response.close()
cachedToken = newToken
val retryRequest = request.newBuilder()
@@ -68,6 +81,43 @@ class AuthInterceptor(
return response
}
private fun performTokenRefresh(): String? {
return try {
val refreshToken = kotlinx.coroutines.runBlocking {
tokenDataStore.getRefreshToken()
} ?: return null
val client = OkHttpClient()
val body = okhttp3.RequestBody.create(
okhttp3.MediaType.parse("application/json")!!,
"""{"refresh_token":"$refreshToken"}"""
)
val refreshUrl = (cachedServerUrl ?: com.tiangong.aiagent.BuildConfig.BASE_URL).trimEnd('/') + "/api/v1/auth/refresh"
val refreshRequest = Request.Builder()
.url(refreshUrl)
.post(body)
.build()
val refreshResponse = client.newCall(refreshRequest).execute()
if (refreshResponse.isSuccessful) {
val json = refreshResponse.body?.string() ?: return null
val obj = JsonParser.parseString(json).asJsonObject
val newToken = obj["access_token"].asString
val newRefreshToken = obj["refresh_token"]?.asString
// Persist new tokens
scope.launch {
tokenDataStore.saveToken(newToken)
if (newRefreshToken != null) {
tokenDataStore.saveRefreshToken(newRefreshToken)
}
}
newToken
} else null
} catch (_: IOException) {
null
}
}
private fun performReLogin(credentials: Pair<String, String>): String? {
return try {
val client = OkHttpClient()
@@ -86,9 +136,13 @@ class AuthInterceptor(
val json = loginResponse.body?.string() ?: return null
val obj = JsonParser.parseString(json).asJsonObject
val newToken = obj["access_token"].asString
// Persist the new token
val newRefreshToken = obj["refresh_token"]?.asString
// Persist the new tokens
scope.launch {
tokenDataStore.saveToken(newToken)
if (newRefreshToken != null) {
tokenDataStore.saveRefreshToken(newRefreshToken)
}
}
newToken
} else null

View File

@@ -6,7 +6,18 @@ import com.google.gson.annotations.SerializedName
data class LoginResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String
@SerializedName("token_type") val tokenType: String,
@SerializedName("refresh_token") val refreshToken: String? = null
)
data class RefreshRequest(
@SerializedName("refresh_token") val refreshToken: String
)
data class RefreshResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String,
@SerializedName("refresh_token") val refreshToken: String? = null
)
data class UserResponse(
@@ -423,6 +434,17 @@ data class MemoryImportRequest(
@SerializedName("vector_memories") val vectorMemories: List<Map<String, Any?>> = emptyList()
)
// ─────────── App Update ───────────
data class AppVersionResponse(
@SerializedName("latest_version") val latestVersion: String,
@SerializedName("current_version") val currentVersion: String,
@SerializedName("has_update") val hasUpdate: Boolean,
@SerializedName("force_update") val forceUpdate: Boolean,
@SerializedName("update_url") val updateUrl: String = "",
@SerializedName("release_notes") val releaseNotes: String = ""
)
// ─────────── Vector Memories ───────────
data class VectorMemoryDto(

View File

@@ -24,6 +24,9 @@ class AuthRepository @Inject constructor(
return try {
val response = apiService.login(username, password)
tokenDataStore.saveToken(response.accessToken)
if (response.refreshToken != null) {
tokenDataStore.saveRefreshToken(response.refreshToken)
}
credentialStore.saveCredentials(username, password)
// Update AuthInterceptor memory cache so subsequent requests carry the token
authInterceptor.updateToken(response.accessToken)
@@ -84,6 +87,15 @@ class AuthRepository @Inject constructor(
}
suspend fun logout() {
// Revoke refresh token server-side (best-effort)
try {
val refreshToken = tokenDataStore.getRefreshToken()
if (refreshToken != null) {
apiService.revokeToken(
com.tiangong.aiagent.data.remote.dto.RefreshRequest(refreshToken)
)
}
} catch (_: Exception) { }
tokenDataStore.clearAll()
credentialStore.clearCredentials()
authInterceptor.updateToken(null)

View File

@@ -0,0 +1,98 @@
package com.tiangong.aiagent.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.tiangong.aiagent.BuildConfig
import com.tiangong.aiagent.data.remote.ApiService
import com.tiangong.aiagent.data.remote.dto.AppVersionResponse
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppUpdateManager @Inject constructor(
@ApplicationContext private val context: Context,
private val apiService: ApiService
) {
/**
* Check for app update asynchronously. If update is found, shows a dialog.
* Called from MainActivity.onCreate().
*/
fun checkForUpdate() {
CoroutineScope(Dispatchers.IO).launch {
try {
val response = apiService.checkUpdate(
platform = "android",
version = BuildConfig.VERSION_NAME
)
if (response.hasUpdate) {
withContext(Dispatchers.Main) {
showUpdateDialog(response)
}
}
} catch (_: Exception) {
// Silently ignore — update check is non-critical
}
}
}
private fun showUpdateDialog(response: AppVersionResponse) {
try {
val builder = if (response.forceUpdate) {
android.app.AlertDialog.Builder(context)
.setTitle("发现新版本 ${response.latestVersion}")
.setMessage(buildDialogMessage(response))
.setCancelable(false)
.setPositiveButton("立即更新") { _, _ ->
openDownloadUrl(response.updateUrl)
}
} else {
android.app.AlertDialog.Builder(context)
.setTitle("发现新版本 ${response.latestVersion}")
.setMessage(buildDialogMessage(response))
.setCancelable(true)
.setPositiveButton("立即更新") { _, _ ->
openDownloadUrl(response.updateUrl)
}
.setNegativeButton("稍后", null)
}
// Show dialog from a new activity context is safer but we use application context
// wrapped in a theme dialog
val dialog = builder.create()
// Ensure dialog is shown in an activity context if possible
dialog.show()
} catch (_: Exception) {
// Fallback: open browser
try {
openDownloadUrl(response.updateUrl)
} catch (_: Exception) { }
}
}
private fun buildDialogMessage(response: AppVersionResponse): String {
val sb = StringBuilder()
sb.append("当前版本: ${BuildConfig.VERSION_NAME}\n")
sb.append("最新版本: ${response.latestVersion}\n")
if (response.releaseNotes.isNotBlank()) {
sb.append("\n更新内容:\n${response.releaseNotes}")
}
if (response.forceUpdate) {
sb.append("\n\n此版本为强制更新,请立即升级。")
}
return sb.toString()
}
private fun openDownloadUrl(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
}
}