API calls now use X-Session-Token.

This commit is contained in:
lightheel 2026-01-19 22:16:09 -05:00
parent efa4bab144
commit 9365bc0215
6 changed files with 274 additions and 57 deletions

View File

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

View File

@ -7,5 +7,8 @@ import retrofit2.http.POST
interface AuthService {
@POST("api/auth/validate")
fun validate(@Body request: AuthenticateRequest): Call<AuthenticateResponse>
@POST("api/auth/login")
fun login(@Body request: AuthenticateRequest): Call<AuthenticateResponse>
}

View File

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

View File

@ -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>(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,10 +257,14 @@ 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()
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
}
// Create an ApiService instance from the Retrofit instance.
val service: PVPService = retrofit.create<PVPService>(PVPService::class.java)
@ -160,11 +280,14 @@ class RetrofitHelper {
override fun onFailure(call: Call<PVPDataModel>, 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<PVPDataModel>, response: Response<PVPDataModel>) {
// 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
@ -174,9 +297,18 @@ class RetrofitHelper {
// 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>(AuthService::class.java)
val request = AuthenticateRequest(userToken = token)
val call: Call<AuthenticateResponse> = service.validate(request)
// Use login endpoint instead of validate to get sessionToken
val call: Call<AuthenticateResponse> = service.login(request)
call.enqueue(object : Callback<AuthenticateResponse> {
override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) {

View File

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

View File

@ -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<String?> = dataStore.data
.map { preferences ->
preferences[SESSION_TOKEN]
}
val userId: Flow<Long?> = 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)
}
}