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" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
</application> </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 android.widget.Toast
import retrofit2.* import retrofit2.*
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
class RetrofitHelper { 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.foundation.layout.Box
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.LaunchedEffect 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.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult 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.DigimonAnimationType
import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
import com.github.nacabaro.vbhelper.battle.HitEffectOverlay 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import kotlin.math.sin import kotlin.math.sin
@ -1640,6 +1646,15 @@ fun BattlesScreen() {
} }
var currentView by remember { mutableStateOf("main") } 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>()) } var opponentsList by remember { mutableStateOf(ArrayList<APIBattleCharacter>()) }
@ -1712,7 +1727,14 @@ fun BattlesScreen() {
} }
// Load opponents automatically based on player's stage // 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 val currentCharacter = activeUserCharacter
if (currentCharacter != null && canBattle && playerBattleType != null) { if (currentCharacter != null && canBattle && playerBattleType != null) {
println("BATTLESCREEN: Loading opponents for stage ${currentCharacter.stage}, battle type: $playerBattleType") 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 // Initialize sprite files on first load - check that they exist in external storage
// Only check if permission is granted // Only check if permission is granted
LaunchedEffect(hasStoragePermission) { LaunchedEffect(hasStoragePermission) {
@ -2061,7 +2298,38 @@ fun BattlesScreen() {
) { ) {
when (currentView) { when (currentView) {
"main" -> { "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) { when (spriteTesterView) {
"entry" -> spriteTesterEntry() "entry" -> spriteTesterEntry()
"testing" -> spriteTesterTesting() "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)
}
}
}