Added NacaAuth for logging into Battles.

This commit is contained in:
lightheel 2025-11-17 14:31:18 -05:00
parent b4d509aad9
commit 29ff2805c3
8 changed files with 435 additions and 2 deletions

View File

@ -32,6 +32,19 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="vbhelper" android:host="auth" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="localhost" android:port="8080" android:pathPrefix="/authenticate" />
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
</intent-filter>
</activity>
</application>

View File

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

View File

@ -0,0 +1,6 @@
package com.github.nacabaro.vbhelper.battle
data class AuthenticateRequest(
val userToken: String
)

View File

@ -0,0 +1,7 @@
package com.github.nacabaro.vbhelper.battle
data class AuthenticateResponse(
val success: Boolean,
val message: String? = null
)

View File

@ -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<Preferences> by preferencesDataStore(
name = BATTLE_AUTH_PREFERENCES_NAME
)
class BattleAuthContainer(private val context: Context) {
val authRepository: AuthRepository = AuthRepository(context.battleAuthStore)
}

View File

@ -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>(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<AuthenticateResponse> = service.validate(request)
call.enqueue(object : Callback<AuthenticateResponse> {
override fun onFailure(call: Call<AuthenticateResponse>, 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<AuthenticateResponse>, response: Response<AuthenticateResponse>) {
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()
}
}
}

View File

@ -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<Set<String>>(emptySet()) }
var opponentsList by remember { mutableStateOf(ArrayList<APIBattleCharacter>()) }
@ -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()

View File

@ -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<Preferences>
) {
private companion object {
val IS_AUTHENTICATED = booleanPreferencesKey("is_authenticated")
val AUTH_TOKEN = stringPreferencesKey("auth_token")
}
val isAuthenticated: Flow<Boolean> = dataStore.data
.map { preferences ->
preferences[IS_AUTHENTICATED] ?: false
}
val authToken: Flow<String?> = 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)
}
}
}