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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user