From 9365bc0215322f52f446765fbe601d06051ca0ee Mon Sep 17 00:00:00 2001 From: lightheel Date: Mon, 19 Jan 2026 22:16:09 -0500 Subject: [PATCH] API calls now use X-Session-Token. --- .../vbhelper/battle/AuthInterceptor.kt | 31 +++ .../nacabaro/vbhelper/battle/AuthService.kt | 3 + .../vbhelper/battle/AuthenticateResponse.kt | 3 +- .../vbhelper/battle/RetrofitHelper.kt | 215 ++++++++++++++---- .../vbhelper/screens/BattlesScreen.kt | 61 ++++- .../vbhelper/source/AuthRepository.kt | 18 +- 6 files changed, 274 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt new file mode 100644 index 0000000..df03762 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt @@ -0,0 +1,31 @@ +package com.github.nacabaro.vbhelper.battle + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * OkHttp interceptor that adds Authorization header to API requests. + * Skips adding header for auth endpoints. + */ +class AuthInterceptor(private val token: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // Skip adding auth header for auth endpoints + if (originalRequest.url.encodedPath.startsWith("/api/auth")) { + return chain.proceed(originalRequest) + } + + // Add authentication header for game endpoints + // Use X-Session-Token header (preferred) or Authorization: Bearer + val authenticatedRequest = originalRequest.newBuilder() + .header("X-Session-Token", token) + .build() + + // Debug: Log which header is being used (first few chars of token for security) + val tokenPreview = if (token.length > 8) "${token.take(4)}...${token.takeLast(4)}" else "***" + println("AuthInterceptor: Adding X-Session-Token header (token: $tokenPreview)") + + return chain.proceed(authenticatedRequest) + } +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt index 11f7b74..72659cc 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt @@ -7,5 +7,8 @@ import retrofit2.http.POST interface AuthService { @POST("api/auth/validate") fun validate(@Body request: AuthenticateRequest): Call + + @POST("api/auth/login") + fun login(@Body request: AuthenticateRequest): Call } diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt index 942791d..cb1f4fd 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt @@ -15,6 +15,7 @@ data class UserInfo( data class AuthenticateResponse( val success: Boolean, val message: String? = null, - val userInfo: UserInfo? = null + val userInfo: UserInfo? = null, + val sessionToken: String? = null ) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt index 91ba907..99d75ed 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt @@ -7,18 +7,131 @@ import retrofit2.* import retrofit2.converter.gson.GsonConverterFactory import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.github.nacabaro.vbhelper.battle.BattleAuthContainer class RetrofitHelper { + + /** + * Creates an OkHttpClient with authentication interceptor for game endpoints. + * Requires a non-null, non-empty token. + */ + private fun createAuthenticatedClient(token: String): OkHttpClient { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + return OkHttpClient.Builder() + .addInterceptor(AuthInterceptor(token)) + .addInterceptor(loggingInterceptor) + .build() + } + + /** + * Gets the session token from AuthRepository for API calls. + * Falls back to nacatech token if session token is not available (backward compatibility). + */ + private fun getAuthToken(context: Context): String? { + return try { + val authContainer = BattleAuthContainer(context) + runBlocking { + // Prefer session token, fall back to nacatech token for backward compatibility + val sessionToken = authContainer.authRepository.sessionToken.first() + if (!sessionToken.isNullOrEmpty()) { + println("RetrofitHelper: Using sessionToken for API call") + sessionToken + } else { + // Fallback to nacatech token (slower, but works) + val nacatechToken = authContainer.authRepository.authToken.first() + if (!nacatechToken.isNullOrEmpty()) { + println("RetrofitHelper: No sessionToken found, falling back to nacatechToken") + } + nacatechToken + } + } + } catch (e: Exception) { + println("RetrofitHelper: Error getting auth token: ${e.message}") + null + } + } + + /** + * Creates a Retrofit instance with authentication for game endpoints. + */ + private fun createAuthenticatedRetrofit(context: Context): Retrofit? { + val token = getAuthToken(context) + if (token.isNullOrEmpty()) { + println("RetrofitHelper: No auth token available") + return null + } + + val client = createAuthenticatedClient(token) + return Retrofit.Builder() + .baseUrl("http://battle.io-void.com:8080/") + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + /** + * Handles HTTP error responses (401, 403, 429). + * For 401/403, clears authentication state to trigger re-authentication. + */ + private fun handleErrorResponse(context: Context, response: Response<*>, errorMessage: String) { + when (response.code()) { + 401 -> { + println("RetrofitHelper: Authentication failed (401) - token may be expired") + clearAuthAndNotify(context, "Authentication failed. Please log in again.") + } + 403 -> { + println("RetrofitHelper: Access forbidden (403) - token may be expired or invalid") + // 403 could mean expired token, so clear auth state to trigger re-authentication + clearAuthAndNotify(context, "Session expired. Please log in again.") + } + 429 -> { + println("RetrofitHelper: Rate limit exceeded (429)") + Toast.makeText(context, "Too many requests. Please wait a moment.", Toast.LENGTH_SHORT).show() + } + else -> { + println("RetrofitHelper: API error (${response.code()}): $errorMessage") + Toast.makeText(context, "Request failed: ${response.code()}", Toast.LENGTH_SHORT).show() + } + } + } + + /** + * Clears authentication state and shows a message. + * This will trigger BattlesScreen to detect the auth state change and open the login page. + */ + private fun clearAuthAndNotify(context: Context, message: String) { + try { + val authContainer = BattleAuthContainer(context) + CoroutineScope(Dispatchers.IO).launch { + authContainer.authRepository.logout() + println("RetrofitHelper: Cleared authentication state due to expired/invalid token") + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } catch (e: Exception) { + println("RetrofitHelper: Error clearing auth state: ${e.message}") + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) { //println("RetrofitHelper: Starting API call for stage: $stage") try { - // Create a Retrofit instance with the base URL and - // a GsonConverterFactory for parsing the response. - val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://192.168.0.230:8080/").addConverterFactory( - GsonConverterFactory.create()).build() - //println("RetrofitHelper: Retrofit instance created") + // Create an authenticated Retrofit instance + val retrofit = createAuthenticatedRetrofit(context) + if (retrofit == null) { + println("RetrofitHelper: Cannot create authenticated Retrofit - no token available") + Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show() + return + } // Create an ApiService instance from the Retrofit instance. val service: OpponentService = retrofit.create(OpponentService::class.java) @@ -47,13 +160,16 @@ class RetrofitHelper { val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel callback(opponentsList) } else { - println("RetrofitHelper: Response not successful - Error: ${response.errorBody()?.string()}") + val errorBody = response.errorBody()?.string() + println("RetrofitHelper: Response not successful - Error: $errorBody") + handleErrorResponse(context, response, errorBody ?: "Unknown error") } } }) } catch (e: Exception) { println("RetrofitHelper: Exception in getOpponents: ${e.message}") e.printStackTrace() + Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show() } } @@ -62,7 +178,7 @@ class RetrofitHelper { // Create a Retrofit instance with the base URL and // a GsonConverterFactory for parsing the response. - val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://192.168.0.230:8080/").addConverterFactory( + val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://battle.io-void.com:8080/").addConverterFactory( GsonConverterFactory.create()).build() // Create an ApiService instance from the Retrofit instance. @@ -102,7 +218,7 @@ class RetrofitHelper { // Create a Retrofit instance with the base URL and // a GsonConverterFactory for parsing the response. - val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://192.168.0.230:8080/").addConverterFactory( + val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://battle.io-void.com:8080/").addConverterFactory( GsonConverterFactory.create()).build() // Create an ApiService instance from the Retrofit instance. @@ -141,42 +257,58 @@ class RetrofitHelper { fun getPVPWinner(context: Context, apiStage: Int, playerID: Int, playerDigi: String, playerStage: Int, critBar: Int, opponentDigi: String, opponentStage: Int, callback: (PVPDataModel) -> Unit) { - // Create a Retrofit instance with the base URL and - // a GsonConverterFactory for parsing the response. - val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://192.168.0.230:8080/").addConverterFactory( - GsonConverterFactory.create()).build() - - // Create an ApiService instance from the Retrofit instance. - val service: PVPService = retrofit.create(PVPService::class.java) - - // Call the getwinner() method of the ApiService - // to make an API request. - val call: Call = service.getwinner(apiStage, playerID, playerDigi, playerStage, critBar, opponentDigi, opponentStage) - - // Use the enqueue() method of the Call object to - // make an asynchronous API request. - call.enqueue(object : Callback { - // This is an anonymous inner class that implements the Callback interface. - - override fun onFailure(call: Call, t: Throwable) { - // This method is called when the API request fails. - Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show() + try { + // Create an authenticated Retrofit instance + val retrofit = createAuthenticatedRetrofit(context) + if (retrofit == null) { + println("RetrofitHelper: Cannot create authenticated Retrofit - no token available") + Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show() + return } - override fun onResponse(call: Call, response: Response) { - // This method is called when the API response is received successfully. + // Create an ApiService instance from the Retrofit instance. + val service: PVPService = retrofit.create(PVPService::class.java) - if(response.isSuccessful){ - // If the response is successful, parse the - // response body to a DataModel object. - val apiResults: PVPDataModel = response.body() as PVPDataModel + // Call the getwinner() method of the ApiService + // to make an API request. + val call: Call = service.getwinner(apiStage, playerID, playerDigi, playerStage, critBar, opponentDigi, opponentStage) - // Call the callback function with the DataModel - // object as a parameter. - callback(apiResults) + // Use the enqueue() method of the Call object to + // make an asynchronous API request. + call.enqueue(object : Callback { + // This is an anonymous inner class that implements the Callback interface. + + override fun onFailure(call: Call, t: Throwable) { + // This method is called when the API request fails. + println("RetrofitHelper: PVP API call failed: ${t.message}") + t.printStackTrace() + Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show() } - } - }) + + override fun onResponse(call: Call, response: Response) { + // This method is called when the API response is received successfully. + println("RetrofitHelper: PVP API response received - Code: ${response.code()}") + + if(response.isSuccessful){ + // If the response is successful, parse the + // response body to a DataModel object. + val apiResults: PVPDataModel = response.body() as PVPDataModel + + // Call the callback function with the DataModel + // object as a parameter. + callback(apiResults) + } else { + val errorBody = response.errorBody()?.string() + println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody") + handleErrorResponse(context, response, errorBody ?: "Unknown error") + } + } + }) + } catch (e: Exception) { + println("RetrofitHelper: Exception in getPVPWinner: ${e.message}") + e.printStackTrace() + Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show() + } } fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) { @@ -198,14 +330,15 @@ class RetrofitHelper { .build() val retrofit: Retrofit = Retrofit.Builder() - .baseUrl("http://192.168.0.230:8080/") + .baseUrl("http://battle.io-void.com:8080/") .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() val service: AuthService = retrofit.create(AuthService::class.java) val request = AuthenticateRequest(userToken = token) - val call: Call = service.validate(request) + // Use login endpoint instead of validate to get sessionToken + val call: Call = service.login(request) call.enqueue(object : Callback { override fun onFailure(call: Call, t: Throwable) { diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt index aac0482..fae0e01 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt @@ -72,6 +72,7 @@ import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage import com.github.nacabaro.vbhelper.battle.HitEffectOverlay import com.github.nacabaro.vbhelper.battle.BattleAuthContainer import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.collect import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items //import kotlin.math.sin @@ -805,7 +806,7 @@ fun MiddleBattleView( ) { // Enemy HP bar (top) LinearProgressIndicator( - progress = battleSystem.opponentHP / (opponentCharacter?.baseHp?.toFloat() ?: 100f), + progress = { battleSystem.opponentHP / (opponentCharacter?.baseHp?.toFloat() ?: 100f) }, modifier = getLandscapeModifier(), color = Color.Red, trackColor = Color.Gray @@ -1017,7 +1018,7 @@ fun MiddleBattleView( ) { // Critical bar LinearProgressIndicator( - progress = battleSystem.critBarProgress / 100f, + progress = { battleSystem.critBarProgress / 100f }, modifier = getLandscapeModifier(), color = Color.Yellow, trackColor = Color.Gray @@ -1035,7 +1036,7 @@ fun MiddleBattleView( ) { // Player HP bar LinearProgressIndicator( - progress = battleSystem.playerHP / (activeCharacter?.baseHp?.toFloat() ?: 100f), + progress = { battleSystem.playerHP / (activeCharacter?.baseHp?.toFloat() ?: 100f) }, modifier = getLandscapeModifier(), color = Color.Green, trackColor = Color.Gray @@ -1215,7 +1216,7 @@ fun PlayerBattleView( ) { // Health bar LinearProgressIndicator( - progress = battleSystem.playerHP / (activeCharacter?.baseHp?.toFloat() ?: 100f), + progress = { battleSystem.playerHP / (activeCharacter?.baseHp?.toFloat() ?: 100f) }, modifier = getLandscapeModifier(), color = Color.Green, trackColor = Color.Gray @@ -1401,7 +1402,7 @@ fun EnemyBattleView( ) { // Enemy HP bar LinearProgressIndicator( - progress = battleSystem.opponentHP / (activeCharacter?.baseHp?.toFloat() ?: 100f), + progress = { battleSystem.opponentHP / (activeCharacter?.baseHp?.toFloat() ?: 100f) }, modifier = getLandscapeModifier(), color = Color.Red, trackColor = Color.Gray @@ -1765,11 +1766,20 @@ fun BattlesScreen() { // Exchange token with battle server RetrofitHelper().authenticate(context, token) { response -> if (response.success) { - // Token already marked as processed before API call, just extract userId - // Extract userId from response + // Extract userId and sessionToken from response val extractedUserId = response.userInfo?.userId?.toLongOrNull() + val sessionToken = response.sessionToken + + println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}") + + // Store both nacatech token (for re-auth) and sessionToken (for API calls) kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { - battleAuthContainer.authRepository.setAuthenticated(true, token, extractedUserId) + battleAuthContainer.authRepository.setAuthenticated( + isAuthenticated = true, + nacatechToken = token, + sessionToken = sessionToken, + userId = extractedUserId + ) } // Update UI state on main thread kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { @@ -1877,16 +1887,23 @@ fun BattlesScreen() { kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { if (response.success) { val extractedUserId = response.userInfo?.userId?.toLongOrNull() - // Update stored userId + val sessionToken = response.sessionToken + + // Update stored userId and sessionToken if (extractedUserId != null) { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { - authRepository.setAuthenticated(true, storedToken, extractedUserId) + authRepository.setAuthenticated( + isAuthenticated = true, + nacatechToken = storedToken, + sessionToken = sessionToken, + userId = extractedUserId + ) } } isAuthenticated = true isCheckingAuth = false userId = extractedUserId - println("BATTLESCREEN: Got userId from validation: $extractedUserId") + println("BATTLESCREEN: Got userId from validation: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}") } else { println("BATTLESCREEN: Token validation failed: ${response.message}") // Check if it's a critical error that requires re-authentication @@ -1999,6 +2016,28 @@ fun BattlesScreen() { } } + // Watch auth repository state changes to detect when token is cleared (e.g., expired token) + LaunchedEffect(Unit) { + battleAuthContainer.authRepository.isAuthenticated.collect { authState -> + if (!authState && isAuthenticated) { + // Auth state was cleared (e.g., by RetrofitHelper due to expired token) + println("BATTLESCREEN: Auth state cleared, triggering re-authentication") + isAuthenticated = false + isCheckingAuth = false + // Open auth URL to get a fresh token + val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token=" + val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + try { + context.startActivity(authIntent) + println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl") + } catch (e: Exception) { + println("BATTLESCREEN: Failed to open auth URL: ${e.message}") + e.printStackTrace() + } + } + } + } + // Also check intent when authentication state changes // Only process if it's a fresh ACTION_VIEW intent (deep link) LaunchedEffect(isAuthenticated) { diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt index c630ca9..53bc737 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt @@ -14,7 +14,8 @@ class AuthRepository( ) { private companion object { val IS_AUTHENTICATED = booleanPreferencesKey("is_authenticated") - val AUTH_TOKEN = stringPreferencesKey("auth_token") + val AUTH_TOKEN = stringPreferencesKey("auth_token") // Nacatech token (for re-authentication) + val SESSION_TOKEN = stringPreferencesKey("session_token") // Session token (for API calls) val USER_ID = longPreferencesKey("user_id") } @@ -28,16 +29,24 @@ class AuthRepository( preferences[AUTH_TOKEN] } + val sessionToken: Flow = dataStore.data + .map { preferences -> + preferences[SESSION_TOKEN] + } + val userId: Flow = dataStore.data .map { preferences -> preferences[USER_ID] } - suspend fun setAuthenticated(isAuthenticated: Boolean, token: String? = null, userId: Long? = null) { + suspend fun setAuthenticated(isAuthenticated: Boolean, nacatechToken: String? = null, sessionToken: String? = null, userId: Long? = null) { dataStore.edit { preferences -> preferences[IS_AUTHENTICATED] = isAuthenticated - if (token != null) { - preferences[AUTH_TOKEN] = token + if (nacatechToken != null) { + preferences[AUTH_TOKEN] = nacatechToken + } + if (sessionToken != null) { + preferences[SESSION_TOKEN] = sessionToken } if (userId != null) { preferences[USER_ID] = userId @@ -49,6 +58,7 @@ class AuthRepository( dataStore.edit { preferences -> preferences[IS_AUTHENTICATED] = false preferences.remove(AUTH_TOKEN) + preferences.remove(SESSION_TOKEN) preferences.remove(USER_ID) } }