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

@@ -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) { }
}
}