diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c72cce..8e59f52 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,6 +32,19 @@ + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..11f7b74 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt @@ -0,0 +1,11 @@ +package com.github.nacabaro.vbhelper.battle + +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthService { + @POST("api/auth/validate") + fun validate(@Body request: AuthenticateRequest): Call +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateRequest.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateRequest.kt new file mode 100644 index 0000000..d1391c3 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateRequest.kt @@ -0,0 +1,6 @@ +package com.github.nacabaro.vbhelper.battle + +data class AuthenticateRequest( + val userToken: String +) + 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 new file mode 100644 index 0000000..3f8e13f --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt @@ -0,0 +1,7 @@ +package com.github.nacabaro.vbhelper.battle + +data class AuthenticateResponse( + val success: Boolean, + val message: String? = null +) + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleAuthContainer.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleAuthContainer.kt new file mode 100644 index 0000000..6024048 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleAuthContainer.kt @@ -0,0 +1,16 @@ +package com.github.nacabaro.vbhelper.battle + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.github.nacabaro.vbhelper.source.AuthRepository + +private const val BATTLE_AUTH_PREFERENCES_NAME = "battle_auth_preferences" +val Context.battleAuthStore: androidx.datastore.core.DataStore by preferencesDataStore( + name = BATTLE_AUTH_PREFERENCES_NAME +) + +class BattleAuthContainer(private val context: Context) { + val authRepository: AuthRepository = AuthRepository(context.battleAuthStore) +} + 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 1cfca0e..1dca1ae 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 @@ -5,6 +5,8 @@ import retrofit2.Retrofit import android.widget.Toast import retrofit2.* import retrofit2.converter.gson.GsonConverterFactory +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor class RetrofitHelper { @@ -176,4 +178,69 @@ class RetrofitHelper { } }) } + + fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) { + println("RetrofitHelper: Starting validate API call with token: $token") + + if (token.isEmpty()) { + println("RetrofitHelper: ERROR - Token is empty!") + Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show() + return + } + + try { + // Add logging interceptor to see the actual HTTP request + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + + val retrofit: Retrofit = Retrofit.Builder() + .baseUrl("http://192.168.0.230:8080/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val service: AuthService = retrofit.create(AuthService::class.java) + val request = AuthenticateRequest(userToken = token) + println("RetrofitHelper: Sending request to api/auth/validate with userToken: $token") + println("RetrofitHelper: Request object: $request") + println("RetrofitHelper: Request JSON will be: {\"userToken\": \"$token\"}") + val call: Call = service.validate(request) + + call.enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + println("RetrofitHelper: Validate API call failed: ${t.message}") + t.printStackTrace() + Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show() + } + + override fun onResponse(call: Call, response: Response) { + println("RetrofitHelper: Validate API response received - Code: ${response.code()}") + println("RetrofitHelper: Response body: ${response.body()}") + + if (response.isSuccessful) { + val authResponse: AuthenticateResponse? = response.body() + if (authResponse != null) { + println("RetrofitHelper: Validation successful: ${authResponse.success}, message: ${authResponse.message}") + callback(authResponse) + } else { + println("RetrofitHelper: Validation failed: Invalid response body") + Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show() + } + } else { + val errorBody = response.errorBody()?.string() + println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody") + Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show() + } + } + }) + } catch (e: Exception) { + println("RetrofitHelper: Exception in validate: ${e.message}") + e.printStackTrace() + Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show() + } + } } \ No newline at end of file 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 91271e8..b5177ce 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 @@ -34,6 +34,10 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.foundation.layout.Box import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult @@ -63,6 +67,8 @@ import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem import com.github.nacabaro.vbhelper.battle.DigimonAnimationType 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 androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import kotlin.math.sin @@ -1640,6 +1646,15 @@ fun BattlesScreen() { } var currentView by remember { mutableStateOf("main") } + + // Create BattleAuthContainer + val battleAuthContainer = remember { BattleAuthContainer(context) } + + // Auth state + var isAuthenticated by remember { mutableStateOf(false) } + var isCheckingAuth by remember { mutableStateOf(true) } + // Track processed tokens to prevent duplicate API calls + var processedTokens by remember { mutableStateOf>(emptySet()) } var opponentsList by remember { mutableStateOf(ArrayList()) } @@ -1712,7 +1727,14 @@ fun BattlesScreen() { } // Load opponents automatically based on player's stage - LaunchedEffect(activeUserCharacter) { + // Only load if authenticated and character is ready + LaunchedEffect(activeUserCharacter, isAuthenticated) { + // Wait for authentication to complete before loading opponents + if (!isAuthenticated) { + println("BATTLESCREEN: Skipping opponent load - not authenticated yet") + return@LaunchedEffect + } + val currentCharacter = activeUserCharacter if (currentCharacter != null && canBattle && playerBattleType != null) { println("BATTLESCREEN: Loading opponents for stage ${currentCharacter.stage}, battle type: $playerBattleType") @@ -1744,6 +1766,221 @@ fun BattlesScreen() { } } + // Helper lambda to extract token from URI and authenticate + // The token can be in 'c' parameter (from localhost:8080/authenticate?c=...) or 'token' parameter + val handleTokenFromUri: (Uri) -> Unit = { uri -> + // Try 'c' parameter first (from localhost:8080/authenticate?c=...) + var token = uri.getQueryParameter("c") + // Fall back to 'token' parameter if 'c' is not found + if (token == null || token.isEmpty()) { + token = uri.getQueryParameter("token") + } + + if (token != null && token.isNotEmpty()) { + // Check if we've already processed this token + if (!processedTokens.contains(token)) { + // Mark token as being processed + processedTokens = processedTokens + token + println("BATTLESCREEN: Received token from URI: $token (URI: $uri)") + + // Exchange token with battle server + RetrofitHelper().authenticate(context, token) { response -> + if (response.success) { + kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { + battleAuthContainer.authRepository.setAuthenticated(true, token) + } + // Update UI state on main thread + kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { + isAuthenticated = true + isCheckingAuth = false + println("BATTLESCREEN: Authentication successful") + android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show() + } + } else { + println("BATTLESCREEN: Authentication failed: ${response.message}") + // Show toast on main thread + kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { + android.widget.Toast.makeText(context, "Authentication failed: ${response.message}", android.widget.Toast.LENGTH_SHORT).show() + } + // Remove token from processed set on failure so it can be retried if needed + processedTokens = processedTokens - token + } + } + } else { + println("BATTLESCREEN: Token already processed, skipping: $token") + } + } else { + println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)") + } + } + + // Check authentication status on load + LaunchedEffect(Unit) { + try { + val authRepository = battleAuthContainer.authRepository + val localAuthState = authRepository.isAuthenticated.first() + val storedToken = authRepository.authToken.first() + println("BATTLESCREEN: Local authentication status - isAuthenticated: $localAuthState, hasToken: ${storedToken != null}") + + // Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent) + // This prevents processing stale tokens from previous sessions + val activity = context as? ComponentActivity + val intent = activity?.intent + if (intent?.action == Intent.ACTION_VIEW) { + intent.data?.let { uri -> + println("BATTLESCREEN: Found ACTION_VIEW intent with URI: $uri") + if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + println("BATTLESCREEN: Token found in fresh deep link, processing...") + handleTokenFromUri(uri) + return@LaunchedEffect // Don't open auth URL if we're processing a token + } + } + } + + // If we have a stored token, validate it with the server + if (localAuthState && storedToken != null && storedToken.isNotEmpty()) { + println("BATTLESCREEN: Validating stored token with server...") + RetrofitHelper().authenticate(context, storedToken) { response -> + // Update UI on main thread + kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { + if (response.success) { + println("BATTLESCREEN: Token validation successful") + isAuthenticated = true + isCheckingAuth = false + } else { + println("BATTLESCREEN: Token validation failed: ${response.message}") + // Clear authentication state + kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { + authRepository.logout() + } + isAuthenticated = false + isCheckingAuth = false + // Open auth URL + val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token=" + val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + context.startActivity(authIntent) + println("BATTLESCREEN: Opened auth URL after validation failure: $authUrl") + } + } + } + } else { + // No stored token or not authenticated locally + isAuthenticated = false + isCheckingAuth = false + // If not authenticated and no fresh token in intent, open auth URL + val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token=" + val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + context.startActivity(authIntent) + println("BATTLESCREEN: Opened auth URL: $authUrl") + } + } catch (e: Exception) { + println("BATTLESCREEN: Error checking authentication status: ${e.message}") + isAuthenticated = false + isCheckingAuth = false + } + } + + // Handle deep link callback to get token + // Check intent data on initial load - handle both vbhelper:// and http://localhost:8080/authenticate?c= + // Only process if it's a fresh ACTION_VIEW intent (deep link) + LaunchedEffect(Unit) { + // Small delay to ensure activity is fully initialized + kotlinx.coroutines.delay(100) + + val activity = context as? ComponentActivity + val intent = activity?.intent + + // Only process if this is a fresh deep link (ACTION_VIEW) + if (intent?.action == Intent.ACTION_VIEW) { + val uri = intent.data + if (uri != null) { + println("BATTLESCREEN: Checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}, path: ${uri.path}") + println("BATTLESCREEN: All query parameters: ${uri.queryParameterNames}") + + // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link + if (uri.scheme == "vbhelper" && uri.host == "auth") { + println("BATTLESCREEN: Detected vbhelper://auth deep link") + handleTokenFromUri(uri) + } + // Handle http://localhost:8080/authenticate?c= redirect + else if ((uri.scheme == "http" || uri.scheme == "https") && + (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) { + println("BATTLESCREEN: Detected localhost redirect, checking for token") + handleTokenFromUri(uri) + } + // Also check if there's a 'c' or 'token' parameter in any URL + else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + println("BATTLESCREEN: Found token parameter (c or token) in URI, attempting to authenticate") + handleTokenFromUri(uri) + } else { + println("BATTLESCREEN: URI found but no token parameter detected") + } + } else { + println("BATTLESCREEN: ACTION_VIEW intent but no URI found") + } + } else { + println("BATTLESCREEN: Not an ACTION_VIEW intent, skipping deep link processing") + } + } + + // Check intent when screen becomes visible or when authentication state changes + // This handles cases where the app is already running and receives a deep link + DisposableEffect(Unit) { + val activity = context as? ComponentActivity + val lifecycleOwner = activity as? LifecycleOwner + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) { + // Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent + val intent = activity?.intent + if (intent?.action == Intent.ACTION_VIEW) { + intent.data?.let { uri -> + println("BATTLESCREEN: Activity resumed with ACTION_VIEW intent, checking for token - URI: $uri") + if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + println("BATTLESCREEN: Found token in fresh deep link on resume") + handleTokenFromUri(uri) + } + } + } + } + } + + lifecycleOwner?.lifecycle?.addObserver(observer) + + onDispose { + lifecycleOwner?.lifecycle?.removeObserver(observer) + } + } + + // Also check intent when authentication state changes + // Only process if it's a fresh ACTION_VIEW intent (deep link) + LaunchedEffect(isAuthenticated) { + if (!isAuthenticated) { + kotlinx.coroutines.delay(200) // Small delay to ensure intent is available + val activity = context as? ComponentActivity + val intent = activity?.intent + // Only process if this is a fresh deep link (ACTION_VIEW) + if (intent?.action == Intent.ACTION_VIEW) { + intent.data?.let { uri -> + println("BATTLESCREEN: Re-checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}") + // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link + if (uri.scheme == "vbhelper" && uri.host == "auth") { + handleTokenFromUri(uri) + } + // Handle http://localhost:8080/authenticate?c= redirect + else if ((uri.scheme == "http" || uri.scheme == "https") && + (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) { + handleTokenFromUri(uri) + } + // Also check if there's a 'c' or 'token' parameter in any URL + else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + handleTokenFromUri(uri) + } + } + } + } + } + // Initialize sprite files on first load - check that they exist in external storage // Only check if permission is granted LaunchedEffect(hasStoragePermission) { @@ -2061,7 +2298,38 @@ fun BattlesScreen() { ) { when (currentView) { "main" -> { - if (showSpriteTester) { + // Show loading/authentication message if not authenticated + if (isCheckingAuth || !isAuthenticated) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + ) { + if (isCheckingAuth) { + Text( + text = "Checking authentication...", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } else { + Text( + text = "Please complete authentication in your browser", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text( + text = "You will be redirected back to the app after logging in", + fontSize = 14.sp, + color = Color.Gray, + textAlign = TextAlign.Center + ) + } + } + } else if (showSpriteTester) { when (spriteTesterView) { "entry" -> spriteTesterEntry() "testing" -> spriteTesterTesting() 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 new file mode 100644 index 0000000..a9327eb --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt @@ -0,0 +1,45 @@ +package com.github.nacabaro.vbhelper.source + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class AuthRepository( + private val dataStore: DataStore +) { + private companion object { + val IS_AUTHENTICATED = booleanPreferencesKey("is_authenticated") + val AUTH_TOKEN = stringPreferencesKey("auth_token") + } + + val isAuthenticated: Flow = dataStore.data + .map { preferences -> + preferences[IS_AUTHENTICATED] ?: false + } + + val authToken: Flow = dataStore.data + .map { preferences -> + preferences[AUTH_TOKEN] + } + + suspend fun setAuthenticated(isAuthenticated: Boolean, token: String? = null) { + dataStore.edit { preferences -> + preferences[IS_AUTHENTICATED] = isAuthenticated + if (token != null) { + preferences[AUTH_TOKEN] = token + } + } + } + + suspend fun logout() { + dataStore.edit { preferences -> + preferences[IS_AUTHENTICATED] = false + preferences.remove(AUTH_TOKEN) + } + } +} +