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